vapor_cli/
export.rs

1use anyhow::{Context, Result};
2use rusqlite::Connection;
3use std::path::Path;
4
5/// Export query results to CSV with comprehensive error handling and validation
6pub fn export_to_csv(conn: &Connection, query: &str, filename: &str) -> Result<()> {
7    // Validate inputs
8    validate_export_inputs(query, filename)?;
9    
10    // Prepare the statement with error handling
11    let mut stmt = conn.prepare(query)
12        .with_context(|| format!("Failed to prepare export query. Check SQL syntax: {}", query))?;
13        
14    let column_names: Vec<String> = stmt.column_names()
15        .iter()
16        .map(|&s| s.to_string())
17        .collect();
18
19    if column_names.is_empty() {
20        anyhow::bail!("Query returned no columns. Make sure your query includes SELECT statements.");
21    }
22
23    // Create the CSV writer with error handling
24    let mut wtr = csv::Writer::from_path(filename)
25        .with_context(|| format!("Failed to create CSV file '{}'. Check permissions and disk space.", filename))?;
26
27    // Write header row
28    wtr.write_record(&column_names)
29        .with_context(|| format!("Failed to write CSV header to '{}'", filename))?;
30
31    // Execute query and write rows with progress tracking
32    let mut rows = stmt.query([])
33        .with_context(|| format!("Failed to execute export query: {}", query))?;
34        
35    let mut row_count = 0;
36    let mut error_count = 0;
37    
38    while let Some(row) = rows.next()
39        .with_context(|| format!("Failed to fetch row {} from query results", row_count + 1))? {
40        
41        match process_row(&row, &column_names) {
42            Ok(record) => {
43                if let Err(e) = wtr.write_record(&record) {
44                    error_count += 1;
45                    eprintln!("Warning: Failed to write row {}: {}", row_count + 1, e);
46                    
47                    if error_count > 10 {
48                        anyhow::bail!("Too many write errors ({}). Stopping export.", error_count);
49                    }
50                } else {
51                    row_count += 1;
52                    
53                    // Progress indicator for large exports
54                    if row_count % 10000 == 0 {
55                        println!("Exported {} rows...", row_count);
56                    }
57                }
58            }
59            Err(e) => {
60                error_count += 1;
61                eprintln!("Warning: Failed to process row {}: {}", row_count + 1, e);
62                
63                if error_count > 10 {
64                    anyhow::bail!("Too many processing errors ({}). Stopping export.", error_count);
65                }
66            }
67        }
68    }
69
70    // Ensure all data is written to disk
71    wtr.flush()
72        .with_context(|| format!("Failed to flush data to CSV file '{}'", filename))?;
73
74    // Verify the file was created successfully
75    verify_export_file(filename, row_count)?;
76
77    if error_count > 0 {
78        println!("Export completed with {} warning(s)", error_count);
79    }
80    
81    println!("Successfully exported {} rows to '{}'", row_count, filename);
82    
83    Ok(())
84}
85
86fn validate_export_inputs(query: &str, filename: &str) -> Result<()> {
87    // Validate query
88    if query.trim().is_empty() {
89        anyhow::bail!("Export query cannot be empty");
90    }
91    
92    let query_lower = query.to_lowercase();
93    if !query_lower.contains("select") {
94        anyhow::bail!("Export query must contain a SELECT statement");
95    }
96    
97    // Check for potentially dangerous operations
98    let dangerous_keywords = ["drop", "delete", "update", "insert", "create", "alter"];
99    for keyword in &dangerous_keywords {
100        if query_lower.contains(keyword) {
101            eprintln!("Warning: Query contains '{}' - this may modify data", keyword);
102        }
103    }
104    
105    // Validate filename
106    if filename.trim().is_empty() {
107        anyhow::bail!("Filename cannot be empty");
108    }
109    
110    if filename.len() > 255 {
111        anyhow::bail!("Filename is too long (maximum 255 characters)");
112    }
113    
114    // Check for invalid filename characters (but allow path separators)
115    let path = Path::new(filename);
116    if let Some(file_name) = path.file_name() {
117        let name_str = file_name.to_string_lossy();
118        // Only check the actual filename part, not the full path
119        if name_str.chars().any(|c| c.is_control() || "\\:*?\"<>|".contains(c)) {
120            anyhow::bail!("Filename contains invalid characters. Avoid: \\ : * ? \" < > |");
121        }
122    }
123    
124    // Check if file already exists and warn user
125    if Path::new(filename).exists() {
126        eprintln!("Warning: File '{}' already exists and will be overwritten", filename);
127    }
128    
129    // Check if the directory is writable
130    if let Some(parent) = Path::new(filename).parent() {
131        if parent != Path::new("") && !parent.exists() {
132            anyhow::bail!("Directory '{}' does not exist. Create it first or use a different path.", parent.display());
133        }
134    }
135    
136    Ok(())
137}
138
139fn process_row(row: &rusqlite::Row, column_names: &[String]) -> Result<Vec<String>> {
140    let mut record = Vec::with_capacity(column_names.len());
141    
142    for i in 0..column_names.len() {
143        let val: rusqlite::types::Value = row.get(i)
144            .with_context(|| format!("Failed to get value from column {} ('{}')", i, column_names[i]))?;
145            
146        let value_str = match val {
147            rusqlite::types::Value::Null => String::new(),
148            rusqlite::types::Value::Integer(i) => i.to_string(),
149            rusqlite::types::Value::Real(f) => {
150                // Handle special float values
151                if f.is_nan() {
152                    "NaN".to_string()
153                } else if f.is_infinite() {
154                    if f.is_sign_positive() {
155                        "Infinity".to_string()
156                    } else {
157                        "-Infinity".to_string()
158                    }
159                } else {
160                    f.to_string()
161                }
162            },
163            rusqlite::types::Value::Text(t) => {
164                // Escape CSV special characters if needed
165                if t.contains(',') || t.contains('"') || t.contains('\n') {
166                    format!("\"{}\"", t.replace('"', "\"\""))
167                } else {
168                    t
169                }
170            },
171            rusqlite::types::Value::Blob(b) => {
172                // For binary data, provide a more informative representation
173                format!("[BLOB {} bytes]", b.len())
174            },
175        };
176        
177        record.push(value_str);
178    }
179    
180    Ok(record)
181}
182
183fn verify_export_file(filename: &str, expected_rows: usize) -> Result<()> {
184    let path = Path::new(filename);
185    
186    if !path.exists() {
187        anyhow::bail!("Export file '{}' was not created", filename);
188    }
189    
190    let metadata = std::fs::metadata(path)
191        .with_context(|| format!("Cannot read metadata for export file '{}'", filename))?;
192    
193    if metadata.len() == 0 && expected_rows > 0 {
194        anyhow::bail!("Export file '{}' is empty but {} rows were expected", filename, expected_rows);
195    }
196    
197    // Basic file size sanity check
198    let file_size = metadata.len();
199    if file_size > 0 {
200        println!("Export file size: {} bytes", file_size);
201    }
202    
203    Ok(())
204}