1use anyhow::{Context, Result};
2use rusqlite::Connection;
3use std::path::Path;
4
5pub fn export_to_csv(conn: &Connection, query: &str, filename: &str) -> Result<()> {
7 validate_export_inputs(query, filename)?;
9
10 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 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 wtr.write_record(&column_names)
29 .with_context(|| format!("Failed to write CSV header to '{}'", filename))?;
30
31 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 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 wtr.flush()
72 .with_context(|| format!("Failed to flush data to CSV file '{}'", filename))?;
73
74 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 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 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 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 let path = Path::new(filename);
116 if let Some(file_name) = path.file_name() {
117 let name_str = file_name.to_string_lossy();
118 if name_str.chars().any(|c| c.is_control() || "\\:*?\"<>|".contains(c)) {
120 anyhow::bail!("Filename contains invalid characters. Avoid: \\ : * ? \" < > |");
121 }
122 }
123
124 if Path::new(filename).exists() {
126 eprintln!("Warning: File '{}' already exists and will be overwritten", filename);
127 }
128
129 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 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 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 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 let file_size = metadata.len();
199 if file_size > 0 {
200 println!("Export file size: {} bytes", file_size);
201 }
202
203 Ok(())
204}