Skip to main content

reinhardt_admin/core/
export.rs

1//! Export functionality for admin data
2//!
3//! This module provides export capabilities for admin data in various formats
4//! including CSV, JSON, and Excel.
5
6use crate::types::{AdminError, AdminResult};
7use csv::Writer;
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11/// Export format
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13pub enum ExportFormat {
14	/// Comma-separated values
15	CSV,
16	/// JSON format
17	JSON,
18	/// Excel format (XLSX)
19	Excel,
20	/// Tab-separated values
21	TSV,
22	/// XML format
23	XML,
24}
25
26impl ExportFormat {
27	/// Get file extension for this format
28	pub fn extension(&self) -> &'static str {
29		match self {
30			ExportFormat::CSV => "csv",
31			ExportFormat::JSON => "json",
32			ExportFormat::Excel => "xlsx",
33			ExportFormat::TSV => "tsv",
34			ExportFormat::XML => "xml",
35		}
36	}
37
38	/// Get MIME type for this format
39	pub fn mime_type(&self) -> &'static str {
40		match self {
41			ExportFormat::CSV => "text/csv",
42			ExportFormat::JSON => "application/json",
43			ExportFormat::Excel => {
44				"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
45			}
46			ExportFormat::TSV => "text/tab-separated-values",
47			ExportFormat::XML => "application/xml",
48		}
49	}
50}
51
52/// Export configuration
53///
54/// # Examples
55///
56/// ```
57/// use reinhardt_admin::core::ExportConfig;
58/// use reinhardt_admin::core::export::ExportFormat;
59///
60/// let config = ExportConfig::new("User", ExportFormat::CSV)
61///     .with_field("id")
62///     .with_field("username")
63///     .with_field("email");
64///
65/// assert_eq!(config.model_name(), "User");
66/// assert_eq!(config.field_count(), 3);
67/// ```
68#[derive(Debug, Clone)]
69pub struct ExportConfig {
70	/// Model name
71	model_name: String,
72	/// Export format
73	format: ExportFormat,
74	/// Fields to export (empty means all fields)
75	fields: Vec<String>,
76	/// Field labels (for headers)
77	field_labels: HashMap<String, String>,
78	/// Filter conditions
79	filters: HashMap<String, String>,
80	/// Sort order
81	ordering: Vec<String>,
82	/// Maximum number of rows to export
83	max_rows: Option<usize>,
84	/// Include column headers
85	include_headers: bool,
86}
87
88impl ExportConfig {
89	/// Create a new export configuration
90	pub fn new(model_name: impl Into<String>, format: ExportFormat) -> Self {
91		Self {
92			model_name: model_name.into(),
93			format,
94			fields: Vec::new(),
95			field_labels: HashMap::new(),
96			filters: HashMap::new(),
97			ordering: Vec::new(),
98			max_rows: None,
99			include_headers: true,
100		}
101	}
102
103	/// Get model name
104	pub fn model_name(&self) -> &str {
105		&self.model_name
106	}
107
108	/// Get export format
109	pub fn format(&self) -> ExportFormat {
110		self.format
111	}
112
113	/// Add a field to export
114	pub fn with_field(mut self, field: impl Into<String>) -> Self {
115		self.fields.push(field.into());
116		self
117	}
118
119	/// Set fields to export
120	pub fn with_fields(mut self, fields: Vec<String>) -> Self {
121		self.fields = fields;
122		self
123	}
124
125	/// Get fields
126	pub fn fields(&self) -> &[String] {
127		&self.fields
128	}
129
130	/// Get field count
131	pub fn field_count(&self) -> usize {
132		self.fields.len()
133	}
134
135	/// Set field label
136	pub fn with_field_label(mut self, field: impl Into<String>, label: impl Into<String>) -> Self {
137		self.field_labels.insert(field.into(), label.into());
138		self
139	}
140
141	/// Get field label
142	pub fn get_field_label(&self, field: &str) -> Option<&String> {
143		self.field_labels.get(field)
144	}
145
146	/// Add a filter
147	pub fn with_filter(mut self, field: impl Into<String>, value: impl Into<String>) -> Self {
148		self.filters.insert(field.into(), value.into());
149		self
150	}
151
152	/// Get filters
153	pub fn filters(&self) -> &HashMap<String, String> {
154		&self.filters
155	}
156
157	/// Set ordering
158	pub fn with_ordering(mut self, ordering: Vec<String>) -> Self {
159		self.ordering = ordering;
160		self
161	}
162
163	/// Get ordering
164	pub fn ordering(&self) -> &[String] {
165		&self.ordering
166	}
167
168	/// Set maximum rows
169	pub fn with_max_rows(mut self, max: usize) -> Self {
170		self.max_rows = Some(max);
171		self
172	}
173
174	/// Get maximum rows
175	pub fn max_rows(&self) -> Option<usize> {
176		self.max_rows
177	}
178
179	/// Set whether to include headers
180	pub fn with_headers(mut self, include: bool) -> Self {
181		self.include_headers = include;
182		self
183	}
184
185	/// Check if headers should be included
186	pub fn include_headers(&self) -> bool {
187		self.include_headers
188	}
189}
190
191/// Export result
192#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct ExportResult {
194	/// Exported data as bytes
195	pub data: Vec<u8>,
196	/// MIME type
197	pub mime_type: String,
198	/// Suggested filename
199	pub filename: String,
200	/// Number of rows exported
201	pub row_count: usize,
202}
203
204impl ExportResult {
205	/// Create a new export result
206	pub fn new(
207		data: Vec<u8>,
208		mime_type: impl Into<String>,
209		filename: impl Into<String>,
210		row_count: usize,
211	) -> Self {
212		Self {
213			data,
214			mime_type: mime_type.into(),
215			filename: filename.into(),
216			row_count,
217		}
218	}
219
220	/// Get data size in bytes
221	pub fn size_bytes(&self) -> usize {
222		self.data.len()
223	}
224
225	/// Get data size in kilobytes
226	pub fn size_kb(&self) -> f64 {
227		self.data.len() as f64 / 1024.0
228	}
229}
230
231/// CSV exporter
232pub struct CsvExporter;
233
234impl CsvExporter {
235	/// Export data to CSV format
236	///
237	/// # Examples
238	///
239	/// ```
240	/// use reinhardt_admin::core::CsvExporter;
241	/// use std::collections::HashMap;
242	///
243	/// let fields = vec!["id".to_string(), "name".to_string()];
244	/// let mut row1 = HashMap::new();
245	/// row1.insert("id".to_string(), "1".to_string());
246	/// row1.insert("name".to_string(), "Alice".to_string());
247	///
248	/// let data = vec![row1];
249	/// let result = CsvExporter::export(&fields, &data, true);
250	///
251	/// assert!(result.is_ok());
252	/// ```
253	pub fn export(
254		fields: &[String],
255		data: &[HashMap<String, String>],
256		include_headers: bool,
257	) -> AdminResult<Vec<u8>> {
258		// Use csv crate for RFC 4180 compliant CSV writing
259		let mut writer = Writer::from_writer(Vec::new());
260
261		// Write headers
262		if include_headers {
263			writer.write_record(fields).map_err(|e| {
264				AdminError::ValidationError(format!("Failed to write CSV headers: {}", e))
265			})?;
266		}
267
268		// Write data rows
269		for row in data {
270			let values: Vec<&str> = fields
271				.iter()
272				.map(|field| row.get(field).map(|v| v.as_str()).unwrap_or(""))
273				.collect();
274
275			writer.write_record(&values).map_err(|e| {
276				AdminError::ValidationError(format!("Failed to write CSV row: {}", e))
277			})?;
278		}
279
280		// Flush and get the output
281		writer.flush().map_err(|e| {
282			AdminError::ValidationError(format!("Failed to flush CSV writer: {}", e))
283		})?;
284
285		let output = writer
286			.into_inner()
287			.map_err(|e| AdminError::ValidationError(format!("Failed to get CSV output: {}", e)))?;
288
289		Ok(output)
290	}
291}
292
293/// JSON exporter
294pub struct JsonExporter;
295
296impl JsonExporter {
297	/// Export data to JSON format
298	///
299	/// # Examples
300	///
301	/// ```
302	/// use reinhardt_admin::core::JsonExporter;
303	/// use std::collections::HashMap;
304	///
305	/// let mut row1 = HashMap::new();
306	/// row1.insert("id".to_string(), "1".to_string());
307	/// row1.insert("name".to_string(), "Alice".to_string());
308	///
309	/// let data = vec![row1];
310	/// let result = JsonExporter::export(&data);
311	///
312	/// assert!(result.is_ok());
313	/// ```
314	pub fn export(data: &[HashMap<String, String>]) -> AdminResult<Vec<u8>> {
315		serde_json::to_vec_pretty(data)
316			.map_err(|e| AdminError::ValidationError(format!("JSON export failed: {}", e)))
317	}
318}
319
320/// TSV (Tab-Separated Values) exporter
321pub struct TsvExporter;
322
323impl TsvExporter {
324	/// Export data to TSV format
325	pub fn export(
326		fields: &[String],
327		data: &[HashMap<String, String>],
328		include_headers: bool,
329	) -> AdminResult<Vec<u8>> {
330		let mut output = Vec::new();
331
332		// Write headers
333		if include_headers {
334			let header_line = fields.join("\t");
335			output.extend_from_slice(header_line.as_bytes());
336			output.extend_from_slice(b"\r\n");
337		}
338
339		// Write data rows
340		for row in data {
341			let values: Vec<String> = fields
342				.iter()
343				.map(|field| {
344					// Escape tabs, newlines, and carriage returns to prevent field corruption
345					// Replace \r\n first to avoid double-space from individual \r and \n replacements
346					row.get(field)
347						.map(|v| v.replace("\r\n", " ").replace(['\t', '\n', '\r'], " "))
348						.unwrap_or_default()
349				})
350				.collect();
351			let line = values.join("\t");
352			output.extend_from_slice(line.as_bytes());
353			output.extend_from_slice(b"\r\n");
354		}
355
356		Ok(output)
357	}
358}
359
360/// Export builder for fluent API
361///
362/// # Examples
363///
364/// ```
365/// use reinhardt_admin::core::ExportBuilder;
366/// use reinhardt_admin::core::export::ExportFormat;
367/// use std::collections::HashMap;
368///
369/// let mut row = HashMap::new();
370/// row.insert("id".to_string(), "1".to_string());
371///
372/// let result = ExportBuilder::new("User", ExportFormat::CSV)
373///     .field("id")
374///     .field("username")
375///     .data(vec![row])
376///     .build();
377///
378/// assert!(result.is_ok());
379/// ```
380pub struct ExportBuilder {
381	config: ExportConfig,
382	data: Vec<HashMap<String, String>>,
383}
384
385impl ExportBuilder {
386	/// Create a new export builder
387	pub fn new(model_name: impl Into<String>, format: ExportFormat) -> Self {
388		Self {
389			config: ExportConfig::new(model_name, format),
390			data: Vec::new(),
391		}
392	}
393
394	/// Add a field
395	pub fn field(mut self, field: impl Into<String>) -> Self {
396		self.config = self.config.with_field(field);
397		self
398	}
399
400	/// Add fields
401	pub fn fields(mut self, fields: Vec<String>) -> Self {
402		self.config = self.config.with_fields(fields);
403		self
404	}
405
406	/// Set field label
407	pub fn field_label(mut self, field: impl Into<String>, label: impl Into<String>) -> Self {
408		self.config = self.config.with_field_label(field, label);
409		self
410	}
411
412	/// Set data
413	pub fn data(mut self, data: Vec<HashMap<String, String>>) -> Self {
414		self.data = data;
415		self
416	}
417
418	/// Set maximum rows
419	pub fn max_rows(mut self, max: usize) -> Self {
420		self.config = self.config.with_max_rows(max);
421		self
422	}
423
424	/// Build and export
425	pub fn build(self) -> AdminResult<ExportResult> {
426		let fields = if self.config.fields().is_empty() {
427			// Extract all unique field names from data
428			let mut all_fields: Vec<String> = self
429				.data
430				.iter()
431				.flat_map(|row| row.keys().cloned())
432				.collect::<std::collections::HashSet<_>>()
433				.into_iter()
434				.collect();
435			all_fields.sort();
436			all_fields
437		} else {
438			self.config.fields().to_vec()
439		};
440
441		// Apply max_rows limit if configured
442		let effective_data = if let Some(max) = self.config.max_rows() {
443			&self.data[..self.data.len().min(max)]
444		} else {
445			&self.data
446		};
447
448		let exported = match self.config.format() {
449			ExportFormat::CSV => {
450				CsvExporter::export(&fields, effective_data, self.config.include_headers())?
451			}
452			ExportFormat::JSON => JsonExporter::export(effective_data)?,
453			ExportFormat::TSV => {
454				TsvExporter::export(&fields, effective_data, self.config.include_headers())?
455			}
456			ExportFormat::Excel | ExportFormat::XML => {
457				return Err(AdminError::ValidationError(format!(
458					"{:?} export not yet implemented",
459					self.config.format()
460				)));
461			}
462		};
463
464		let filename = format!(
465			"{}_{}.{}",
466			self.config.model_name(),
467			chrono::Utc::now().format("%Y%m%d_%H%M%S"),
468			self.config.format().extension()
469		);
470
471		Ok(ExportResult::new(
472			exported,
473			self.config.format().mime_type().to_string(),
474			filename,
475			effective_data.len(),
476		))
477	}
478}
479
480#[cfg(all(test, server))]
481mod tests {
482	use super::*;
483	use rstest::rstest;
484
485	#[test]
486	fn test_export_format_extension() {
487		assert_eq!(ExportFormat::CSV.extension(), "csv");
488		assert_eq!(ExportFormat::JSON.extension(), "json");
489		assert_eq!(ExportFormat::Excel.extension(), "xlsx");
490		assert_eq!(ExportFormat::TSV.extension(), "tsv");
491	}
492
493	#[test]
494	fn test_export_format_mime_type() {
495		assert_eq!(ExportFormat::CSV.mime_type(), "text/csv");
496		assert_eq!(ExportFormat::JSON.mime_type(), "application/json");
497	}
498
499	#[test]
500	fn test_export_config_new() {
501		let config = ExportConfig::new("User", ExportFormat::CSV);
502		assert_eq!(config.model_name(), "User");
503		assert_eq!(config.format(), ExportFormat::CSV);
504		assert!(config.include_headers());
505	}
506
507	#[test]
508	fn test_export_config_with_field() {
509		let config = ExportConfig::new("User", ExportFormat::CSV)
510			.with_field("id")
511			.with_field("username");
512
513		assert_eq!(config.field_count(), 2);
514	}
515
516	#[test]
517	fn test_csv_exporter_basic() {
518		let fields = vec!["id".to_string(), "name".to_string()];
519		let mut row1 = HashMap::new();
520		row1.insert("id".to_string(), "1".to_string());
521		row1.insert("name".to_string(), "Alice".to_string());
522
523		let mut row2 = HashMap::new();
524		row2.insert("id".to_string(), "2".to_string());
525		row2.insert("name".to_string(), "Bob".to_string());
526
527		let data = vec![row1, row2];
528		let result = CsvExporter::export(&fields, &data, true);
529
530		assert!(result.is_ok());
531		let output = String::from_utf8(result.unwrap()).unwrap();
532		assert!(output.contains("id,name"));
533		assert!(output.contains("1,Alice"));
534		assert!(output.contains("2,Bob"));
535	}
536
537	#[test]
538	fn test_csv_exporter_escape() {
539		let fields = vec!["id".to_string(), "name".to_string()];
540		let mut row = HashMap::new();
541		row.insert("id".to_string(), "1".to_string());
542		row.insert("name".to_string(), "Smith, John".to_string());
543
544		let data = vec![row];
545		let result = CsvExporter::export(&fields, &data, true);
546
547		assert!(result.is_ok());
548		let output = String::from_utf8(result.unwrap()).unwrap();
549		assert!(output.contains("\"Smith, John\""));
550	}
551
552	#[test]
553	fn test_json_exporter() {
554		let mut row1 = HashMap::new();
555		row1.insert("id".to_string(), "1".to_string());
556		row1.insert("name".to_string(), "Alice".to_string());
557
558		let data = vec![row1];
559		let result = JsonExporter::export(&data);
560
561		assert!(result.is_ok());
562		let output = String::from_utf8(result.unwrap()).unwrap();
563		assert!(output.contains("\"id\""));
564		assert!(output.contains("\"Alice\""));
565	}
566
567	#[test]
568	fn test_tsv_exporter() {
569		let fields = vec!["id".to_string(), "name".to_string()];
570		let mut row = HashMap::new();
571		row.insert("id".to_string(), "1".to_string());
572		row.insert("name".to_string(), "Alice".to_string());
573
574		let data = vec![row];
575		let result = TsvExporter::export(&fields, &data, true);
576
577		assert!(result.is_ok());
578		let output = String::from_utf8(result.unwrap()).unwrap();
579		assert!(output.contains("id\tname\r\n"));
580		assert!(output.contains("1\tAlice\r\n"));
581	}
582
583	#[rstest]
584	fn test_tsv_exporter_escapes_newlines() {
585		let fields = vec!["id".to_string(), "bio".to_string()];
586		let mut row = HashMap::new();
587		row.insert("id".to_string(), "1".to_string());
588		row.insert("bio".to_string(), "line1\nline2\r\nline3".to_string());
589
590		let data = vec![row];
591		let result = TsvExporter::export(&fields, &data, true);
592
593		assert!(result.is_ok());
594		let output = String::from_utf8(result.unwrap()).unwrap();
595		// Newlines and carriage returns in field values must be escaped
596		assert_eq!(output, "id\tbio\r\n1\tline1 line2 line3\r\n");
597	}
598
599	#[test]
600	fn test_export_builder() {
601		let mut row = HashMap::new();
602		row.insert("id".to_string(), "1".to_string());
603		row.insert("username".to_string(), "alice".to_string());
604
605		let result = ExportBuilder::new("User", ExportFormat::CSV)
606			.field("id")
607			.field("username")
608			.data(vec![row])
609			.build();
610
611		let export = result.unwrap();
612		assert_eq!(export.row_count, 1);
613		assert!(export.filename.starts_with("User_"));
614		assert!(export.filename.ends_with(".csv"));
615	}
616
617	#[test]
618	fn test_export_result() {
619		let data = vec![1, 2, 3, 4, 5];
620		let result = ExportResult::new(data, "text/csv".to_string(), "test.csv".to_string(), 10);
621
622		assert_eq!(result.row_count, 10);
623		assert_eq!(result.size_bytes(), 5);
624		assert!((result.size_kb() - 0.00488).abs() < 0.001);
625	}
626
627	#[test]
628	fn test_export_config_filters() {
629		let config = ExportConfig::new("User", ExportFormat::CSV)
630			.with_filter("status", "active")
631			.with_filter("role", "admin");
632
633		assert_eq!(config.filters().len(), 2);
634		assert_eq!(config.filters().get("status"), Some(&"active".to_string()));
635	}
636
637	#[test]
638	fn test_export_config_ordering() {
639		let config = ExportConfig::new("User", ExportFormat::CSV)
640			.with_ordering(vec!["name".to_string(), "-created_at".to_string()]);
641
642		assert_eq!(config.ordering().len(), 2);
643	}
644
645	#[rstest]
646	fn test_export_builder_max_rows_truncates_output() {
647		// Arrange
648		let mut row1 = HashMap::new();
649		row1.insert("id".to_string(), "1".to_string());
650		row1.insert("name".to_string(), "Alice".to_string());
651
652		let mut row2 = HashMap::new();
653		row2.insert("id".to_string(), "2".to_string());
654		row2.insert("name".to_string(), "Bob".to_string());
655
656		let mut row3 = HashMap::new();
657		row3.insert("id".to_string(), "3".to_string());
658		row3.insert("name".to_string(), "Carol".to_string());
659
660		// Act
661		let result = ExportBuilder::new("User", ExportFormat::CSV)
662			.field("id")
663			.field("name")
664			.data(vec![row1, row2, row3])
665			.max_rows(2)
666			.build();
667
668		// Assert
669		let export = result.unwrap();
670		assert_eq!(export.row_count, 2);
671		let output = String::from_utf8(export.data).unwrap();
672		assert!(output.contains("1,Alice"));
673		assert!(output.contains("2,Bob"));
674		assert!(!output.contains("3,Carol"));
675	}
676}