1use anyhow::Result;
7use chrono::{DateTime, Utc};
8use oxisql_sqlite_compat::blocking::SqliteConnectionBlocking;
9use serde::{Deserialize, Serialize};
10use std::collections::{HashMap, HashSet};
11use uuid::Uuid;
12
13#[derive(Debug, Clone)]
15pub struct DataExportManager {
16 config: ExportConfig,
18 active_jobs: HashMap<Uuid, ExportJob>,
20 export_history: Vec<ExportRecord>,
22 supported_formats: Vec<ExportFormat>,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct ExportConfig {
29 pub default_directory: String,
31 pub max_file_size: u64,
33 pub enable_compression: bool,
35 pub default_format: ExportFormat,
37 pub include_metadata: bool,
39 pub templates: Vec<ExportTemplate>,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct ExportJob {
46 pub id: Uuid,
48 pub name: String,
50 pub format: ExportFormat,
52 pub output_path: String,
54 pub status: ExportStatus,
56 pub progress: f64,
58 pub started_at: DateTime<Utc>,
60 pub completed_at: Option<DateTime<Utc>>,
62 pub data_size: u64,
64 pub error_message: Option<String>,
66 pub options: ExportOptions,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct ExportRecord {
73 pub id: Uuid,
75 pub job_id: Uuid,
77 pub timestamp: DateTime<Utc>,
79 pub file_path: String,
81 pub file_size: u64,
83 pub format: ExportFormat,
85 pub success: bool,
87 pub duration: f64,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct ExportTemplate {
94 pub id: String,
96 pub name: String,
98 pub description: String,
100 pub format: ExportFormat,
102 pub options: ExportOptions,
104 pub filters: DataFilters,
106 pub tags: Vec<String>,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct ExportOptions {
113 pub include_headers: bool,
115 pub date_format: String,
117 pub float_precision: u32,
119 pub separator: String,
121 pub compression_level: u32,
123 pub include_metadata: bool,
125 pub custom_options: HashMap<String, serde_json::Value>,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct DataFilters {
132 pub date_range: Option<DateRange>,
134 pub data_types: Vec<DataType>,
136 pub exclude_fields: Vec<String>,
138 pub include_fields: Option<Vec<String>>,
140 pub custom_filters: HashMap<String, serde_json::Value>,
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct DateRange {
147 pub start: DateTime<Utc>,
149 pub end: DateTime<Utc>,
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Hash, Eq)]
155pub enum ExportFormat {
156 Csv,
158 Excel,
160 Json,
162 JsonPretty,
164 Hdf5,
166 Parquet,
168 Xml,
170 Yaml,
172 Sqlite,
174 MessagePack,
176 Arrow,
178 Custom(String),
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize)]
184pub enum ExportStatus {
185 Pending,
186 InProgress,
187 Completed,
188 Failed,
189 Cancelled,
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize)]
194pub enum DataType {
195 TensorData,
196 GradientData,
197 PerformanceMetrics,
198 MemoryProfiles,
199 ActivityLogs,
200 AnnotationData,
201 CommentData,
202 ModelDiagnostics,
203 TrainingDynamics,
204 ArchitectureAnalysis,
205 Custom(String),
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct ExportableData {
211 pub id: Uuid,
213 pub name: String,
215 pub data_type: DataType,
217 pub timestamp: DateTime<Utc>,
219 pub content: ExportDataContent,
221 pub metadata: HashMap<String, serde_json::Value>,
223 pub size: u64,
225}
226
227#[derive(Debug, Clone, Serialize, Deserialize)]
229pub enum ExportDataContent {
230 Table(TableData),
232 TimeSeries(TimeSeriesData),
234 KeyValue(HashMap<String, serde_json::Value>),
236 Structured(serde_json::Value),
238 Binary(Vec<u8>),
240 Text(String),
242}
243
244#[derive(Debug, Clone, Serialize, Deserialize)]
246pub struct TableData {
247 pub headers: Vec<String>,
249 pub rows: Vec<Vec<serde_json::Value>>,
251 pub column_types: HashMap<String, ColumnType>,
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct TimeSeriesData {
258 pub timestamps: Vec<DateTime<Utc>>,
260 pub series: HashMap<String, Vec<f64>>,
262 pub metadata: HashMap<String, String>,
264}
265
266#[derive(Debug, Clone, Serialize, Deserialize)]
268pub enum ColumnType {
269 Integer,
270 Float,
271 String,
272 Boolean,
273 DateTime,
274 Binary,
275}
276
277impl DataExportManager {
278 pub fn new(config: ExportConfig) -> Self {
280 let supported_formats = vec![
281 ExportFormat::Csv,
282 ExportFormat::Excel,
283 ExportFormat::Json,
284 ExportFormat::JsonPretty,
285 ExportFormat::Xml,
286 ExportFormat::Yaml,
287 ExportFormat::Sqlite,
288 ];
289
290 Self {
291 config,
292 active_jobs: HashMap::new(),
293 export_history: Vec::new(),
294 supported_formats,
295 }
296 }
297
298 pub fn start_export(
300 &mut self,
301 name: String,
302 data: Vec<ExportableData>,
303 format: ExportFormat,
304 output_path: String,
305 options: ExportOptions,
306 ) -> Result<Uuid> {
307 let job_id = Uuid::new_v4();
308
309 let data_size: u64 = data.iter().map(|d| d.size).sum();
311
312 if data_size > self.config.max_file_size {
314 return Err(anyhow::anyhow!("Data size exceeds maximum file size limit"));
315 }
316
317 let job = ExportJob {
318 id: job_id,
319 name: name.clone(),
320 format: format.clone(),
321 output_path: output_path.clone(),
322 status: ExportStatus::Pending,
323 progress: 0.0,
324 started_at: Utc::now(),
325 completed_at: None,
326 data_size,
327 error_message: None,
328 options: options.clone(),
329 };
330
331 self.active_jobs.insert(job_id, job);
332
333 self.execute_export(job_id, data, options)?;
335
336 Ok(job_id)
337 }
338
339 fn execute_export(
341 &mut self,
342 job_id: Uuid,
343 data: Vec<ExportableData>,
344 options: ExportOptions,
345 ) -> Result<()> {
346 let (format, output_path) = {
348 if let Some(job) = self.active_jobs.get_mut(&job_id) {
349 job.status = ExportStatus::InProgress;
350 (job.format.clone(), job.output_path.clone())
351 } else {
352 return Err(anyhow::anyhow!("Export job not found"));
353 }
354 };
355
356 let result = match format {
357 ExportFormat::Csv => self.export_csv(&data, &output_path, &options),
358 ExportFormat::Json => self.export_json(&data, &output_path, &options),
359 ExportFormat::JsonPretty => self.export_json_pretty(&data, &output_path, &options),
360 ExportFormat::Excel => self.export_excel(&data, &output_path, &options),
361 ExportFormat::Xml => self.export_xml(&data, &output_path, &options),
362 ExportFormat::Yaml => self.export_yaml(&data, &output_path, &options),
363 ExportFormat::Sqlite => self.export_sqlite(&data, &output_path, &options),
364 ExportFormat::Hdf5 => Err(anyhow::anyhow!(
368 "HDF5 export is not supported in this build. Use JSON or CSV instead."
369 )),
370 ExportFormat::Parquet => Err(anyhow::anyhow!(
371 "Parquet export is not supported in this build. Use JSON or CSV instead."
372 )),
373 ExportFormat::MessagePack => Err(anyhow::anyhow!(
374 "MessagePack export is not supported in this build. Use JSON instead."
375 )),
376 ExportFormat::Arrow => Err(anyhow::anyhow!(
377 "Apache Arrow export is not supported in this build. Use JSON or CSV instead."
378 )),
379 ExportFormat::Custom(ref name) => Err(anyhow::anyhow!(
380 "Custom export format '{}' is not registered. \
381 Register a handler or use one of the built-in formats.",
382 name
383 )),
384 };
385
386 if let Some(job) = self.active_jobs.get_mut(&job_id) {
388 match result {
389 Ok(_) => {
390 job.status = ExportStatus::Completed;
391 job.progress = 100.0;
392 job.completed_at = Some(Utc::now());
393
394 let job_copy = job.clone();
396 self.add_export_record(&job_copy);
397 },
398 Err(e) => {
399 job.status = ExportStatus::Failed;
400 job.error_message = Some(e.to_string());
401 },
402 }
403 }
404
405 Ok(())
406 }
407
408 fn export_csv(
410 &mut self,
411 data: &[ExportableData],
412 output_path: &str,
413 options: &ExportOptions,
414 ) -> Result<()> {
415 use std::fs::File;
416 use std::io::Write;
417
418 let mut file = File::create(output_path)?;
419
420 for item in data {
421 match &item.content {
422 ExportDataContent::Table(table_data) => {
423 if options.include_headers {
425 let header_line = table_data.headers.join(&options.separator);
426 writeln!(file, "{}", header_line)?;
427 }
428
429 for row in &table_data.rows {
431 let row_values: Vec<String> =
432 row.iter().map(|v| self.format_value_for_csv(v, options)).collect();
433 let row_line = row_values.join(&options.separator);
434 writeln!(file, "{}", row_line)?;
435 }
436 },
437 ExportDataContent::TimeSeries(ts_data) => {
438 if options.include_headers {
440 let mut headers = vec!["timestamp".to_string()];
441 headers.extend(ts_data.series.keys().cloned());
442 let header_line = headers.join(&options.separator);
443 writeln!(file, "{}", header_line)?;
444 }
445
446 for (i, timestamp) in ts_data.timestamps.iter().enumerate() {
447 let mut row = vec![timestamp.format(&options.date_format).to_string()];
448 for series_name in ts_data.series.keys() {
449 if let Some(series) = ts_data.series.get(series_name) {
450 if let Some(value) = series.get(i) {
451 row.push(format!(
452 "{:.precision$}",
453 value,
454 precision = options.float_precision as usize
455 ));
456 } else {
457 row.push("".to_string());
458 }
459 }
460 }
461 let row_line = row.join(&options.separator);
462 writeln!(file, "{}", row_line)?;
463 }
464 },
465 _ => {
466 let json_str = serde_json::to_string(&item.content)?;
468 writeln!(file, "{}", json_str)?;
469 },
470 }
471 }
472
473 Ok(())
474 }
475
476 fn export_json(
478 &mut self,
479 data: &[ExportableData],
480 output_path: &str,
481 _options: &ExportOptions,
482 ) -> Result<()> {
483 use std::fs::File;
484
485 let file = File::create(output_path)?;
486 serde_json::to_writer(file, data)?;
487 Ok(())
488 }
489
490 fn export_json_pretty(
492 &mut self,
493 data: &[ExportableData],
494 output_path: &str,
495 _options: &ExportOptions,
496 ) -> Result<()> {
497 use std::fs::File;
498
499 let file = File::create(output_path)?;
500 serde_json::to_writer_pretty(file, data)?;
501 Ok(())
502 }
503
504 fn export_excel(
506 &mut self,
507 data: &[ExportableData],
508 output_path: &str,
509 options: &ExportOptions,
510 ) -> Result<()> {
511 use oxiarc_archive::zip::ZipWriter;
512
513 let rows = build_xlsx_rows(data, options)?;
514 let sheet_xml = build_xlsx_sheet(&rows);
515
516 let mut buffer: Vec<u8> = Vec::new();
517 {
518 let mut writer = ZipWriter::new(&mut buffer);
519 writer
520 .add_file("[Content_Types].xml", XLSX_CONTENT_TYPES.as_bytes())
521 .map_err(|e| anyhow::anyhow!("xlsx: failed to write [Content_Types].xml: {e}"))?;
522 writer
523 .add_file("_rels/.rels", XLSX_ROOT_RELS.as_bytes())
524 .map_err(|e| anyhow::anyhow!("xlsx: failed to write _rels/.rels: {e}"))?;
525 writer
526 .add_file("xl/workbook.xml", XLSX_WORKBOOK.as_bytes())
527 .map_err(|e| anyhow::anyhow!("xlsx: failed to write xl/workbook.xml: {e}"))?;
528 writer
529 .add_file("xl/_rels/workbook.xml.rels", XLSX_WORKBOOK_RELS.as_bytes())
530 .map_err(|e| {
531 anyhow::anyhow!("xlsx: failed to write xl/_rels/workbook.xml.rels: {e}")
532 })?;
533 writer.add_file("xl/worksheets/sheet1.xml", sheet_xml.as_bytes()).map_err(|e| {
534 anyhow::anyhow!("xlsx: failed to write xl/worksheets/sheet1.xml: {e}")
535 })?;
536 writer
537 .finish()
538 .map_err(|e| anyhow::anyhow!("xlsx: failed to finalize workbook package: {e}"))?;
539 }
540
541 std::fs::write(output_path, &buffer)?;
542 Ok(())
543 }
544
545 fn export_xml(
547 &mut self,
548 data: &[ExportableData],
549 output_path: &str,
550 _options: &ExportOptions,
551 ) -> Result<()> {
552 use std::fs::File;
553 use std::io::Write;
554
555 let mut file = File::create(output_path)?;
556
557 writeln!(file, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>")?;
558 writeln!(file, "<export_data>")?;
559
560 for item in data {
561 writeln!(
562 file,
563 " <data_item id=\"{}\" type=\"{:?}\">",
564 item.id, item.data_type
565 )?;
566 writeln!(file, " <name>{}</name>", item.name)?;
567 writeln!(
568 file,
569 " <timestamp>{}</timestamp>",
570 item.timestamp.to_rfc3339()
571 )?;
572 writeln!(file, " <size>{}</size>", item.size)?;
573
574 let content_json = serde_json::to_string(&item.content)?;
581 writeln!(file, " <content><![CDATA[{}]]></content>", content_json)?;
582
583 writeln!(file, " </data_item>")?;
584 }
585
586 writeln!(file, "</export_data>")?;
587 Ok(())
588 }
589
590 fn export_yaml(
592 &mut self,
593 data: &[ExportableData],
594 output_path: &str,
595 _options: &ExportOptions,
596 ) -> Result<()> {
597 use std::fs::File;
598
599 let file = File::create(output_path)?;
600 serde_json::to_writer_pretty(file, data)?;
601 Ok(())
602 }
603
604 fn export_sqlite(
618 &mut self,
619 data: &[ExportableData],
620 output_path: &str,
621 options: &ExportOptions,
622 ) -> Result<()> {
623 if std::path::Path::new(output_path).exists() {
625 std::fs::remove_file(output_path).map_err(|e| {
626 anyhow::anyhow!("failed to clear existing SQLite file '{output_path}': {e}")
627 })?;
628 }
629
630 let conn = SqliteConnectionBlocking::open(output_path)
631 .map_err(|e| anyhow::anyhow!("failed to open SQLite database '{output_path}': {e}"))?;
632
633 let mut used_names: HashSet<String> = HashSet::new();
634 for (index, item) in data.iter().enumerate() {
635 let table_name = unique_table_name(&item.name, index, &mut used_names);
636 match &item.content {
637 ExportDataContent::Table(table) => {
638 write_sqlite_table(
639 &conn,
640 &table_name,
641 &table.headers,
642 &table.rows,
643 &table.column_types,
644 )?;
645 },
646 ExportDataContent::TimeSeries(ts) => {
647 write_sqlite_timeseries(&conn, &table_name, ts, options)?;
648 },
649 other => {
650 let json = serde_json::to_string(other)?;
651 conn.execute(
652 &format!("CREATE TABLE IF NOT EXISTS \"{table_name}\" (content TEXT)"),
653 &[],
654 )
655 .map_err(|e| anyhow::anyhow!("CREATE TABLE '{table_name}' failed: {e}"))?;
656 conn.execute(
657 &format!(
658 "INSERT INTO \"{table_name}\" (content) VALUES ({})",
659 quote_sql_string(&json)
660 ),
661 &[],
662 )
663 .map_err(|e| anyhow::anyhow!("INSERT into '{table_name}' failed: {e}"))?;
664 },
665 }
666 }
667
668 Ok(())
669 }
670
671 fn format_value_for_csv(&self, value: &serde_json::Value, options: &ExportOptions) -> String {
673 match value {
674 serde_json::Value::Number(n) => {
675 if let Some(f) = n.as_f64() {
676 format!(
677 "{:.precision$}",
678 f,
679 precision = options.float_precision as usize
680 )
681 } else {
682 n.to_string()
683 }
684 },
685 serde_json::Value::String(s) => {
686 if s.contains(',') || s.contains('"') || s.contains('\n') {
688 format!("\"{}\"", s.replace('"', "\"\""))
689 } else {
690 s.clone()
691 }
692 },
693 _ => value.to_string(),
694 }
695 }
696
697 fn add_export_record(&mut self, job: &ExportJob) {
699 let record = ExportRecord {
700 id: Uuid::new_v4(),
701 job_id: job.id,
702 timestamp: Utc::now(),
703 file_path: job.output_path.clone(),
704 file_size: job.data_size,
705 format: job.format.clone(),
706 success: matches!(job.status, ExportStatus::Completed),
707 duration: job
708 .completed_at
709 .map(|end| (end - job.started_at).num_milliseconds() as f64 / 1000.0)
710 .unwrap_or(0.0),
711 };
712
713 self.export_history.push(record);
714 }
715
716 pub fn get_job_status(&self, job_id: Uuid) -> Option<&ExportJob> {
718 self.active_jobs.get(&job_id)
719 }
720
721 pub fn get_export_history(&self) -> &[ExportRecord] {
723 &self.export_history
724 }
725
726 pub fn create_template(
728 &mut self,
729 name: String,
730 description: String,
731 format: ExportFormat,
732 options: ExportOptions,
733 filters: DataFilters,
734 tags: Vec<String>,
735 ) -> String {
736 let template_id = Uuid::new_v4().to_string();
737
738 let template = ExportTemplate {
739 id: template_id.clone(),
740 name,
741 description,
742 format,
743 options,
744 filters,
745 tags,
746 };
747
748 self.config.templates.push(template);
749 template_id
750 }
751
752 pub fn apply_template(
754 &self,
755 template_id: &str,
756 ) -> Option<(&ExportFormat, &ExportOptions, &DataFilters)> {
757 self.config
758 .templates
759 .iter()
760 .find(|t| t.id == template_id)
761 .map(|t| (&t.format, &t.options, &t.filters))
762 }
763
764 pub fn get_supported_formats(&self) -> &[ExportFormat] {
766 &self.supported_formats
767 }
768
769 pub fn cancel_job(&mut self, job_id: Uuid) -> Result<()> {
771 if let Some(job) = self.active_jobs.get_mut(&job_id) {
772 if matches!(job.status, ExportStatus::Pending | ExportStatus::InProgress) {
773 job.status = ExportStatus::Cancelled;
774 Ok(())
775 } else {
776 Err(anyhow::anyhow!("Job cannot be cancelled in current status"))
777 }
778 } else {
779 Err(anyhow::anyhow!("Job not found"))
780 }
781 }
782
783 pub fn get_export_statistics(&self) -> ExportStatistics {
785 let total_exports = self.export_history.len();
786 let successful_exports = self.export_history.iter().filter(|r| r.success).count();
787 let total_size: u64 = self.export_history.iter().map(|r| r.file_size).sum();
788 let avg_duration = if total_exports > 0 {
789 self.export_history.iter().map(|r| r.duration).sum::<f64>() / total_exports as f64
790 } else {
791 0.0
792 };
793
794 let format_stats: HashMap<ExportFormat, usize> =
795 self.export_history.iter().fold(HashMap::new(), |mut acc, record| {
796 *acc.entry(record.format.clone()).or_insert(0) += 1;
797 acc
798 });
799
800 ExportStatistics {
801 total_exports,
802 successful_exports,
803 failed_exports: total_exports - successful_exports,
804 total_size_bytes: total_size,
805 average_duration_seconds: avg_duration,
806 format_statistics: format_stats,
807 active_jobs: self.active_jobs.len(),
808 }
809 }
810}
811
812const XLSX_CONTENT_TYPES: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
814<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/></Types>"#;
815
816const XLSX_ROOT_RELS: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
818<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>"#;
819
820const XLSX_WORKBOOK: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
822<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets></workbook>"#;
823
824const XLSX_WORKBOOK_RELS: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
826<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/></Relationships>"#;
827
828enum XlsxCell {
830 Inline(String),
832 Number(String),
834 Empty,
836}
837
838fn xlsx_xml_escape(input: &str) -> String {
840 let mut out = String::with_capacity(input.len());
841 for ch in input.chars() {
842 match ch {
843 '&' => out.push_str("&"),
844 '<' => out.push_str("<"),
845 '>' => out.push_str(">"),
846 '"' => out.push_str("""),
847 '\'' => out.push_str("'"),
848 _ => out.push(ch),
849 }
850 }
851 out
852}
853
854fn xlsx_column_name(index: usize) -> String {
856 let mut n = index + 1;
857 let mut name = String::new();
858 while n > 0 {
859 let rem = (n - 1) % 26;
860 name.insert(0, (b'A' + rem as u8) as char);
861 n = (n - 1) / 26;
862 }
863 name
864}
865
866fn xlsx_value_to_cell(value: &serde_json::Value, options: &ExportOptions) -> XlsxCell {
868 match value {
869 serde_json::Value::Number(n) => {
870 if let Some(f) = n.as_f64() {
871 XlsxCell::Number(format!(
872 "{:.precision$}",
873 f,
874 precision = options.float_precision as usize
875 ))
876 } else {
877 XlsxCell::Number(n.to_string())
878 }
879 },
880 serde_json::Value::String(s) => XlsxCell::Inline(s.clone()),
881 _ => XlsxCell::Inline(value.to_string()),
882 }
883}
884
885fn build_xlsx_rows(data: &[ExportableData], options: &ExportOptions) -> Result<Vec<Vec<XlsxCell>>> {
887 let mut rows: Vec<Vec<XlsxCell>> = Vec::new();
888 for item in data {
889 match &item.content {
890 ExportDataContent::Table(table_data) => {
891 if options.include_headers {
892 rows.push(
893 table_data.headers.iter().map(|h| XlsxCell::Inline(h.clone())).collect(),
894 );
895 }
896 for row in &table_data.rows {
897 rows.push(row.iter().map(|v| xlsx_value_to_cell(v, options)).collect());
898 }
899 },
900 ExportDataContent::TimeSeries(ts_data) => {
901 if options.include_headers {
902 let mut header = vec![XlsxCell::Inline("timestamp".to_string())];
903 header.extend(ts_data.series.keys().map(|k| XlsxCell::Inline(k.clone())));
904 rows.push(header);
905 }
906 for (i, timestamp) in ts_data.timestamps.iter().enumerate() {
907 let mut cells = vec![XlsxCell::Inline(
908 timestamp.format(&options.date_format).to_string(),
909 )];
910 for series_name in ts_data.series.keys() {
911 if let Some(series) = ts_data.series.get(series_name) {
912 if let Some(value) = series.get(i) {
913 cells.push(XlsxCell::Number(format!(
914 "{:.precision$}",
915 value,
916 precision = options.float_precision as usize
917 )));
918 } else {
919 cells.push(XlsxCell::Empty);
920 }
921 }
922 }
923 rows.push(cells);
924 }
925 },
926 _ => {
927 let json_str = serde_json::to_string(&item.content)?;
928 rows.push(vec![XlsxCell::Inline(json_str)]);
929 },
930 }
931 }
932 Ok(rows)
933}
934
935fn build_xlsx_sheet(rows: &[Vec<XlsxCell>]) -> String {
937 let mut sheet = String::new();
938 sheet.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>");
939 sheet.push_str(
940 "<worksheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\">",
941 );
942 sheet.push_str("<sheetData>");
943 for (row_idx, row) in rows.iter().enumerate() {
944 let row_num = row_idx + 1;
945 sheet.push_str(&format!("<row r=\"{row_num}\">"));
946 for (col_idx, cell) in row.iter().enumerate() {
947 let cell_ref = format!("{}{row_num}", xlsx_column_name(col_idx));
948 match cell {
949 XlsxCell::Inline(text) => {
950 sheet.push_str(&format!(
951 "<c r=\"{cell_ref}\" t=\"inlineStr\"><is><t xml:space=\"preserve\">{}</t></is></c>",
952 xlsx_xml_escape(text)
953 ));
954 },
955 XlsxCell::Number(num) => {
956 sheet.push_str(&format!("<c r=\"{cell_ref}\"><v>{num}</v></c>"));
957 },
958 XlsxCell::Empty => {
959 sheet.push_str(&format!("<c r=\"{cell_ref}\"/>"));
960 },
961 }
962 }
963 sheet.push_str("</row>");
964 }
965 sheet.push_str("</sheetData></worksheet>");
966 sheet
967}
968
969fn sanitize_sql_identifier(name: &str) -> String {
976 let mut sanitized: String = name
977 .chars()
978 .map(|c| if c.is_ascii_alphanumeric() || c == '_' { c } else { '_' })
979 .collect();
980 let needs_prefix = sanitized
981 .chars()
982 .next()
983 .map(|c| !(c.is_ascii_alphabetic() || c == '_'))
984 .unwrap_or(true);
985 if needs_prefix {
986 sanitized = format!("t_{sanitized}");
987 }
988 sanitized
989}
990
991fn unique_table_name(name: &str, index: usize, used: &mut HashSet<String>) -> String {
993 let base = sanitize_sql_identifier(name);
994 if used.insert(base.clone()) {
995 return base;
996 }
997 let candidate = format!("{base}_{index}");
998 used.insert(candidate.clone());
999 candidate
1000}
1001
1002fn quote_sql_string(value: &str) -> String {
1004 format!("'{}'", value.replace('\'', "''"))
1005}
1006
1007fn json_value_to_sql_literal(value: &serde_json::Value) -> String {
1015 match value {
1016 serde_json::Value::Null => "NULL".to_string(),
1017 serde_json::Value::Bool(b) => if *b { "1" } else { "0" }.to_string(),
1018 serde_json::Value::Number(n) => n.to_string(),
1019 serde_json::Value::String(s) => quote_sql_string(s),
1020 other => quote_sql_string(&other.to_string()),
1021 }
1022}
1023
1024fn infer_sql_column_type(rows: &[Vec<serde_json::Value>], col: usize) -> &'static str {
1027 let mut integers = 0usize;
1028 let mut floats = 0usize;
1029 let mut bools = 0usize;
1030 let mut others = 0usize;
1031 for row in rows {
1032 match row.get(col) {
1033 None | Some(serde_json::Value::Null) => {},
1034 Some(serde_json::Value::Number(n)) => {
1035 if n.is_i64() || n.is_u64() {
1036 integers += 1;
1037 } else {
1038 floats += 1;
1039 }
1040 },
1041 Some(serde_json::Value::Bool(_)) => bools += 1,
1042 Some(_) => others += 1,
1043 }
1044 }
1045 if others > 0 {
1046 "TEXT"
1047 } else if floats > 0 {
1048 "REAL"
1049 } else if integers > 0 || bools > 0 {
1050 "INTEGER"
1051 } else {
1052 "TEXT"
1053 }
1054}
1055
1056fn column_type_to_sql(column_type: &ColumnType) -> &'static str {
1058 match column_type {
1059 ColumnType::Integer | ColumnType::Boolean => "INTEGER",
1060 ColumnType::Float => "REAL",
1061 ColumnType::Binary => "BLOB",
1062 ColumnType::String | ColumnType::DateTime => "TEXT",
1063 }
1064}
1065
1066fn write_sqlite_table(
1068 conn: &SqliteConnectionBlocking,
1069 table_name: &str,
1070 headers: &[String],
1071 rows: &[Vec<serde_json::Value>],
1072 column_types: &HashMap<String, ColumnType>,
1073) -> Result<()> {
1074 if headers.is_empty() {
1075 return Ok(());
1076 }
1077
1078 let column_idents: Vec<String> = headers.iter().map(|h| sanitize_sql_identifier(h)).collect();
1079 let column_defs: Vec<String> = headers
1080 .iter()
1081 .enumerate()
1082 .map(|(idx, header)| {
1083 let sql_type = column_types
1084 .get(header)
1085 .map(column_type_to_sql)
1086 .unwrap_or_else(|| infer_sql_column_type(rows, idx));
1087 format!("\"{}\" {}", column_idents[idx], sql_type)
1088 })
1089 .collect();
1090
1091 let create = format!(
1092 "CREATE TABLE IF NOT EXISTS \"{table_name}\" ({})",
1093 column_defs.join(", ")
1094 );
1095 conn.execute(&create, &[])
1096 .map_err(|e| anyhow::anyhow!("CREATE TABLE '{table_name}' failed: {e}"))?;
1097
1098 let column_list =
1099 column_idents.iter().map(|c| format!("\"{c}\"")).collect::<Vec<_>>().join(", ");
1100
1101 for row in rows {
1102 let values: Vec<String> = (0..headers.len())
1103 .map(|idx| match row.get(idx) {
1104 Some(value) => json_value_to_sql_literal(value),
1105 None => "NULL".to_string(),
1106 })
1107 .collect();
1108 let insert = format!(
1109 "INSERT INTO \"{table_name}\" ({column_list}) VALUES ({})",
1110 values.join(", ")
1111 );
1112 conn.execute(&insert, &[])
1113 .map_err(|e| anyhow::anyhow!("INSERT into '{table_name}' failed: {e}"))?;
1114 }
1115
1116 Ok(())
1117}
1118
1119fn write_sqlite_timeseries(
1121 conn: &SqliteConnectionBlocking,
1122 table_name: &str,
1123 ts: &TimeSeriesData,
1124 options: &ExportOptions,
1125) -> Result<()> {
1126 let mut series_names: Vec<String> = ts.series.keys().cloned().collect();
1128 series_names.sort();
1129
1130 let mut column_defs = vec!["\"timestamp\" TEXT".to_string()];
1131 for name in &series_names {
1132 column_defs.push(format!("\"{}\" REAL", sanitize_sql_identifier(name)));
1133 }
1134 let create = format!(
1135 "CREATE TABLE IF NOT EXISTS \"{table_name}\" ({})",
1136 column_defs.join(", ")
1137 );
1138 conn.execute(&create, &[])
1139 .map_err(|e| anyhow::anyhow!("CREATE TABLE '{table_name}' failed: {e}"))?;
1140
1141 let mut column_list = vec!["\"timestamp\"".to_string()];
1142 for name in &series_names {
1143 column_list.push(format!("\"{}\"", sanitize_sql_identifier(name)));
1144 }
1145 let column_list = column_list.join(", ");
1146
1147 for (i, timestamp) in ts.timestamps.iter().enumerate() {
1148 let mut values = vec![quote_sql_string(
1149 ×tamp.format(&options.date_format).to_string(),
1150 )];
1151 for name in &series_names {
1152 match ts.series.get(name).and_then(|series| series.get(i)) {
1153 Some(value) => values.push(value.to_string()),
1154 None => values.push("NULL".to_string()),
1155 }
1156 }
1157 let insert = format!(
1158 "INSERT INTO \"{table_name}\" ({column_list}) VALUES ({})",
1159 values.join(", ")
1160 );
1161 conn.execute(&insert, &[])
1162 .map_err(|e| anyhow::anyhow!("INSERT into '{table_name}' failed: {e}"))?;
1163 }
1164
1165 Ok(())
1166}
1167
1168#[derive(Debug, Clone, Serialize, Deserialize)]
1170pub struct ExportStatistics {
1171 pub total_exports: usize,
1172 pub successful_exports: usize,
1173 pub failed_exports: usize,
1174 pub total_size_bytes: u64,
1175 pub average_duration_seconds: f64,
1176 pub format_statistics: HashMap<ExportFormat, usize>,
1177 pub active_jobs: usize,
1178}
1179
1180impl Default for ExportConfig {
1181 fn default() -> Self {
1182 Self {
1183 default_directory: "./exports".to_string(),
1184 max_file_size: 1024 * 1024 * 1024, enable_compression: true,
1186 default_format: ExportFormat::Json,
1187 include_metadata: true,
1188 templates: Vec::new(),
1189 }
1190 }
1191}
1192
1193impl Default for ExportOptions {
1194 fn default() -> Self {
1195 Self {
1196 include_headers: true,
1197 date_format: "%Y-%m-%d %H:%M:%S UTC".to_string(),
1198 float_precision: 6,
1199 separator: ",".to_string(),
1200 compression_level: 6,
1201 include_metadata: true,
1202 custom_options: HashMap::new(),
1203 }
1204 }
1205}
1206
1207impl Default for DataFilters {
1208 fn default() -> Self {
1209 Self {
1210 date_range: None,
1211 data_types: vec![
1212 DataType::TensorData,
1213 DataType::GradientData,
1214 DataType::PerformanceMetrics,
1215 ],
1216 exclude_fields: Vec::new(),
1217 include_fields: None,
1218 custom_filters: HashMap::new(),
1219 }
1220 }
1221}
1222
1223#[cfg(test)]
1224mod tests {
1225 use super::*;
1226 use tempfile::tempdir;
1227
1228 #[allow(clippy::approx_constant)]
1230 fn create_test_data() -> Vec<ExportableData> {
1231 let table_data = TableData {
1232 headers: vec![
1233 "id".to_string(),
1234 "value".to_string(),
1235 "timestamp".to_string(),
1236 ],
1237 rows: vec![
1238 vec![
1239 serde_json::Value::Number(serde_json::Number::from(1)),
1240 serde_json::Value::Number(
1241 serde_json::Number::from_f64(3.14).expect("operation failed in test"),
1242 ),
1243 serde_json::Value::String("2023-01-01T12:00:00Z".to_string()),
1244 ],
1245 vec![
1246 serde_json::Value::Number(serde_json::Number::from(2)),
1247 serde_json::Value::Number(
1248 serde_json::Number::from_f64(2.71).expect("operation failed in test"),
1249 ),
1250 serde_json::Value::String("2023-01-01T12:01:00Z".to_string()),
1251 ],
1252 ],
1253 column_types: HashMap::new(),
1254 };
1255
1256 vec![ExportableData {
1257 id: Uuid::new_v4(),
1258 name: "Test Data".to_string(),
1259 data_type: DataType::TensorData,
1260 timestamp: Utc::now(),
1261 content: ExportDataContent::Table(table_data),
1262 metadata: HashMap::new(),
1263 size: 1024,
1264 }]
1265 }
1266
1267 #[test]
1268 fn test_export_manager_creation() {
1269 let config = ExportConfig::default();
1270 let manager = DataExportManager::new(config);
1271
1272 assert!(manager.get_supported_formats().contains(&ExportFormat::Json));
1273 assert!(manager.get_supported_formats().contains(&ExportFormat::Csv));
1274 }
1275
1276 #[test]
1277 fn test_csv_export() {
1278 let config = ExportConfig::default();
1279 let mut manager = DataExportManager::new(config);
1280 let test_data = create_test_data();
1281
1282 let temp_dir = tempdir().expect("temp file creation failed");
1283 let output_path = temp_dir.path().join("test.csv").to_string_lossy().to_string();
1284
1285 let job_id = manager
1286 .start_export(
1287 "Test CSV Export".to_string(),
1288 test_data,
1289 ExportFormat::Csv,
1290 output_path.clone(),
1291 ExportOptions::default(),
1292 )
1293 .expect("operation failed in test");
1294
1295 assert!(manager.active_jobs.contains_key(&job_id));
1297
1298 assert!(std::path::Path::new(&output_path).exists());
1300 }
1301
1302 #[test]
1303 fn test_json_export() {
1304 let config = ExportConfig::default();
1305 let mut manager = DataExportManager::new(config);
1306 let test_data = create_test_data();
1307
1308 let temp_dir = tempdir().expect("temp file creation failed");
1309 let output_path = temp_dir.path().join("test.json").to_string_lossy().to_string();
1310
1311 let job_id = manager
1312 .start_export(
1313 "Test JSON Export".to_string(),
1314 test_data,
1315 ExportFormat::Json,
1316 output_path.clone(),
1317 ExportOptions::default(),
1318 )
1319 .expect("operation failed in test");
1320
1321 assert!(manager.active_jobs.contains_key(&job_id));
1322 assert!(std::path::Path::new(&output_path).exists());
1323 }
1324
1325 #[test]
1326 fn test_export_template() {
1327 let config = ExportConfig::default();
1328 let mut manager = DataExportManager::new(config);
1329
1330 let template_id = manager.create_template(
1331 "CSV Template".to_string(),
1332 "Standard CSV export".to_string(),
1333 ExportFormat::Csv,
1334 ExportOptions::default(),
1335 DataFilters::default(),
1336 vec!["csv".to_string(), "standard".to_string()],
1337 );
1338
1339 let (format, options, _filters) =
1340 manager.apply_template(&template_id).expect("temp file creation failed");
1341 assert_eq!(*format, ExportFormat::Csv);
1342 assert!(options.include_headers);
1343 }
1344
1345 #[test]
1346 fn test_export_statistics() {
1347 let config = ExportConfig::default();
1348 let mut manager = DataExportManager::new(config);
1349
1350 manager.export_history.push(ExportRecord {
1352 id: Uuid::new_v4(),
1353 job_id: Uuid::new_v4(),
1354 timestamp: Utc::now(),
1355 file_path: "test1.csv".to_string(),
1356 file_size: 1024,
1357 format: ExportFormat::Csv,
1358 success: true,
1359 duration: 2.5,
1360 });
1361
1362 manager.export_history.push(ExportRecord {
1363 id: Uuid::new_v4(),
1364 job_id: Uuid::new_v4(),
1365 timestamp: Utc::now(),
1366 file_path: "test2.json".to_string(),
1367 file_size: 2048,
1368 format: ExportFormat::Json,
1369 success: true,
1370 duration: 1.8,
1371 });
1372
1373 let stats = manager.get_export_statistics();
1374 assert_eq!(stats.total_exports, 2);
1375 assert_eq!(stats.successful_exports, 2);
1376 assert_eq!(stats.total_size_bytes, 3072);
1377 }
1378
1379 #[test]
1380 fn test_excel_export_roundtrip() {
1381 use oxiarc_archive::zip::ZipReader;
1382 use std::io::Cursor;
1383
1384 let config = ExportConfig::default();
1385 let mut manager = DataExportManager::new(config);
1386 let test_data = create_test_data();
1387
1388 let file_name = format!("trustformers_xlsx_test_{}.xlsx", Uuid::new_v4());
1389 let output_path = std::env::temp_dir().join(file_name).to_string_lossy().to_string();
1390
1391 manager
1392 .start_export(
1393 "Test Excel Export".to_string(),
1394 test_data,
1395 ExportFormat::Excel,
1396 output_path.clone(),
1397 ExportOptions::default(),
1398 )
1399 .expect("excel export should succeed");
1400
1401 assert!(std::path::Path::new(&output_path).exists());
1402
1403 let bytes = std::fs::read(&output_path).expect("read xlsx bytes");
1404 let mut reader = ZipReader::new(Cursor::new(bytes)).expect("open xlsx as zip");
1405 let entries = reader.entries().to_vec();
1406 let names: Vec<String> = entries.iter().map(|e| e.name.clone()).collect();
1407
1408 assert!(
1409 names.iter().any(|n| n == "[Content_Types].xml"),
1410 "missing [Content_Types].xml; entries = {names:?}"
1411 );
1412 assert!(
1413 names.iter().any(|n| n == "xl/workbook.xml"),
1414 "missing xl/workbook.xml; entries = {names:?}"
1415 );
1416 assert!(
1417 names.iter().any(|n| n == "xl/worksheets/sheet1.xml"),
1418 "missing xl/worksheets/sheet1.xml; entries = {names:?}"
1419 );
1420
1421 let sheet_entry = entries
1422 .iter()
1423 .find(|e| e.name == "xl/worksheets/sheet1.xml")
1424 .expect("sheet1.xml entry");
1425 let sheet_bytes = reader.extract(sheet_entry).expect("extract sheet1.xml");
1426 let sheet_xml = String::from_utf8(sheet_bytes).expect("sheet1.xml is utf8");
1427
1428 assert!(sheet_xml.contains("<worksheet"));
1429 assert!(sheet_xml.contains("id"), "sheet missing header text");
1431 assert!(sheet_xml.contains("value"), "sheet missing header text");
1432 assert!(sheet_xml.contains("timestamp"), "sheet missing header text");
1433
1434 let _ = std::fs::remove_file(&output_path);
1435 }
1436
1437 #[test]
1438 #[allow(clippy::approx_constant)]
1440 fn test_sqlite_export_roundtrip() {
1441 let config = ExportConfig::default();
1442 let mut manager = DataExportManager::new(config);
1443 let test_data = create_test_data();
1444
1445 let file_name = format!("trustformers_sqlite_test_{}.sqlite3", Uuid::new_v4());
1446 let output_path = std::env::temp_dir().join(file_name).to_string_lossy().to_string();
1447
1448 let job_id = manager
1449 .start_export(
1450 "Test SQLite Export".to_string(),
1451 test_data,
1452 ExportFormat::Sqlite,
1453 output_path.clone(),
1454 ExportOptions::default(),
1455 )
1456 .expect("sqlite export should succeed");
1457
1458 let status = manager.get_job_status(job_id).expect("job should exist");
1460 assert!(
1461 matches!(status.status, ExportStatus::Completed),
1462 "export status = {:?}",
1463 status.status
1464 );
1465 assert!(
1466 std::path::Path::new(&output_path).exists(),
1467 "sqlite file should exist"
1468 );
1469
1470 let conn = SqliteConnectionBlocking::open(&output_path).expect("open sqlite file");
1472 let tables = conn.tables().expect("list tables");
1473 assert!(!tables.is_empty(), "expected at least one table");
1474
1475 let rows = conn
1477 .query(
1478 "SELECT \"id\", \"value\", \"timestamp\" FROM \"Test_Data\"",
1479 &[],
1480 )
1481 .expect("query rows back");
1482 assert_eq!(rows.len(), 2, "two rows should persist");
1483
1484 let mut ids: Vec<i64> = rows
1486 .iter()
1487 .map(|r| r.try_get::<i64>("id").expect("id column is INTEGER"))
1488 .collect();
1489 ids.sort_unstable();
1490 assert_eq!(ids, vec![1, 2]);
1491
1492 let first = rows
1494 .iter()
1495 .find(|r| r.try_get::<i64>("id").map(|v| v == 1).unwrap_or(false))
1496 .expect("row with id=1");
1497 let value: f64 = first.try_get("value").expect("value column is REAL");
1498 assert!((value - 3.14).abs() < 1e-9, "value = {value}");
1499 let timestamp: String = first.try_get("timestamp").expect("timestamp column is TEXT");
1500 assert_eq!(timestamp, "2023-01-01T12:00:00Z");
1501
1502 let _ = std::fs::remove_file(&output_path);
1503 }
1504}