pub mod common;
use std::{
env::temp_dir,
fs::{self, File, read_to_string},
io::{Cursor, Read},
};
use ::serde::{Deserialize, Serialize};
use rand::distr::{Alphanumeric, SampleString};
use spring_batch_rs::{
core::{
item::{ItemProcessor, ItemProcessorResult},
job::{Job, JobBuilder},
step::{StepBuilder, StepStatus},
},
error::BatchError,
item::csv::csv_reader::CsvItemReaderBuilder,
item::csv::csv_writer::CsvItemWriterBuilder,
item::xml::xml_reader::XmlItemReaderBuilder,
item::xml::xml_writer::XmlItemWriterBuilder,
};
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
struct Product {
#[serde(rename = "@id")]
id: String,
#[serde(rename = "@available")]
available: bool,
name: String,
price: f64,
#[serde(default)]
description: Option<String>,
}
#[derive(Default)]
struct ProductProcessor;
impl ItemProcessor<Product, Product> for ProductProcessor {
fn process(&self, item: &Product) -> ItemProcessorResult<Product> {
let description = match &item.description {
Some(desc) => Some(desc.to_uppercase()),
None => Some("NO DESCRIPTION AVAILABLE".to_string()),
};
let product = Product {
id: item.id.clone(),
available: item.available,
name: item.name.to_uppercase(),
price: item.price * 1.1, description,
};
Ok(Some(product))
}
}
#[test]
fn transform_from_xml_file_to_csv_file_without_error() {
let xml_content = r#"
<catalog>
<product id="P001" available="true">
<name>Wireless Headphones</name>
<price>79.99</price>
<description>Noise-cancelling wireless headphones with 20hr battery life</description>
</product>
<product id="P002" available="false">
<name>USB-C Cable</name>
<price>12.99</price>
</product>
<product id="P003" available="true">
<name>Smart Watch</name>
<price>149.99</price>
<description>Fitness tracking smart watch with heart rate monitor</description>
</product>
</catalog>
"#;
let file_name = Alphanumeric.sample_string(&mut rand::rng(), 16);
let xml_path = temp_dir().join(format!("{}.xml", file_name));
fs::write(&xml_path, xml_content).expect("Failed to write XML file");
let file = File::open(&xml_path).expect("Unable to open XML file");
let reader = XmlItemReaderBuilder::<Product>::new()
.tag("product")
.from_reader(file);
let processor = ProductProcessor;
let csv_path = temp_dir().join(format!("{}.csv", file_name));
let writer = CsvItemWriterBuilder::new()
.has_headers(true)
.delimiter(b',')
.from_path(&csv_path);
let step = StepBuilder::new("test")
.chunk::<Product, Product>(2)
.reader(&reader)
.processor(&processor)
.writer(&writer)
.build();
let job = JobBuilder::new().start(&step).build();
let result = job.run();
assert!(result.is_ok());
let step_execution = job.get_step_execution("test").unwrap();
assert!(step_execution.status == StepStatus::Success);
assert!(step_execution.read_count == 3);
assert!(step_execution.write_count == 3);
assert!(step_execution.read_error_count == 0);
assert!(step_execution.write_error_count == 0);
let csv_content =
read_to_string(&csv_path).expect("Should have been able to read the CSV file");
assert!(!csv_content.is_empty());
assert!(csv_content.contains("WIRELESS HEADPHONES"));
assert!(csv_content.contains("USB-C CABLE"));
assert!(csv_content.contains("SMART WATCH"));
assert!(csv_content.contains("NOISE-CANCELLING WIRELESS HEADPHONES WITH 20HR BATTERY LIFE"));
assert!(csv_content.contains("NO DESCRIPTION AVAILABLE"));
fs::remove_file(&xml_path).ok();
fs::remove_file(&csv_path).ok();
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
struct Manufacturer {
#[serde(rename = "@country")]
country: String,
name: String,
#[serde(rename = "foundedYear")]
founded_year: i32,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
struct Category {
name: String,
#[serde(rename = "@main")]
main: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
struct EnhancedProduct {
#[serde(rename = "@id")]
id: String,
#[serde(rename = "@sku")]
sku: String,
name: String,
price: f64,
manufacturer: Manufacturer,
categories: Vec<Category>,
in_stock: bool,
}
#[test]
fn convert_csv_to_xml_with_nested_structures() {
let csv_content = r#"id,sku,name,price,manufacturer_country,manufacturer_name,manufacturer_founded_year,category1_name,category1_main,category2_name,category2_main,in_stock
P001,SKU123,Laptop,999.99,USA,TechCorp,1985,Electronics,true,Computers,false,true
P002,SKU456,Smartphone,599.99,Korea,MobileTech,1995,Electronics,true,Mobile,false,true
P003,SKU789,Headphones,129.99,Japan,AudioInc,1978,Electronics,false,Audio,true,false"#;
let file_name = Alphanumeric.sample_string(&mut rand::rng(), 16);
let csv_path = temp_dir().join(format!("{}.csv", file_name));
fs::write(&csv_path, csv_content).expect("Failed to write CSV file");
let file = File::open(&csv_path).expect("Unable to open CSV file");
struct CsvToEnhancedProductProcessor;
impl ItemProcessor<Vec<String>, EnhancedProduct> for CsvToEnhancedProductProcessor {
fn process(&self, item: &Vec<String>) -> ItemProcessorResult<EnhancedProduct> {
if item.len() < 12 {
return Err(BatchError::ItemProcessor(
"CSV row has too few columns".to_string(),
));
}
let product = EnhancedProduct {
id: item[0].clone(),
sku: item[1].clone(),
name: item[2].clone(),
price: item[3].parse().unwrap_or(0.0),
manufacturer: Manufacturer {
country: item[4].clone(),
name: item[5].clone(),
founded_year: item[6].parse().unwrap_or(0),
},
categories: vec![
Category {
name: item[7].clone(),
main: item[8].parse().unwrap_or(false),
},
Category {
name: item[9].clone(),
main: item[10].parse().unwrap_or(false),
},
],
in_stock: item[11].parse().unwrap_or(false),
};
Ok(Some(product))
}
}
let reader = CsvItemReaderBuilder::<Vec<String>>::new()
.has_headers(true)
.from_reader(file);
let xml_path = temp_dir().join(format!("{}.xml", file_name));
let writer = XmlItemWriterBuilder::new()
.root_tag("products")
.item_tag("product")
.from_path(&xml_path)
.expect("Failed to create XML writer");
let processor = CsvToEnhancedProductProcessor;
let step = StepBuilder::new("test")
.chunk::<Vec<String>, EnhancedProduct>(2)
.reader(&reader)
.processor(&processor)
.writer(&writer)
.build();
let job = JobBuilder::new().start(&step).build();
let result = job.run();
assert!(result.is_ok());
let step_execution = job.get_step_execution("test").unwrap();
assert!(step_execution.status == StepStatus::Success);
assert!(step_execution.read_count == 3);
assert!(step_execution.write_count == 3);
assert!(step_execution.read_error_count == 0);
assert!(step_execution.write_error_count == 0);
let mut xml_content = String::new();
File::open(&xml_path)
.expect("Failed to open XML file")
.read_to_string(&mut xml_content)
.expect("Failed to read XML file");
assert!(xml_content.contains("<products>"));
assert!(xml_content.contains("<product id=\"P001\" sku=\"SKU123\">"));
assert!(xml_content.contains("<name>Laptop</name>"));
assert!(xml_content.contains("<manufacturer country=\"USA\">"));
assert!(xml_content.contains("<name>TechCorp</name>"));
assert!(xml_content.contains("<foundedYear>1985</foundedYear>"));
assert!(xml_content.contains("Electronics"));
assert!(xml_content.contains("Computers"));
fs::remove_file(&csv_path).ok();
fs::remove_file(&xml_path).ok();
}
#[test]
fn test_xml_reader_with_error_handling() {
let xml_content = r#"
<catalog>
<product id="P001" available="true">
<name>Wireless Headphones</name>
<price>79.99</price>
<description>Good headphones</description>
</product>
<!-- Malformed product missing required price field -->
<product id="P002" available="false">
<name>USB-C Cable</name>
<!-- price is missing -->
</product>
<product id="P003" available="true">
<name>Smart Watch</name>
<price>149.99</price>
<description>Fitness tracker</description>
</product>
</catalog>
"#;
let file_name = Alphanumeric.sample_string(&mut rand::rng(), 16);
let xml_path = temp_dir().join(format!("{}.xml", file_name));
fs::write(&xml_path, xml_content).expect("Failed to write XML file");
let file = File::open(&xml_path).expect("Unable to open XML file");
let reader = XmlItemReaderBuilder::<Product>::new()
.tag("product")
.from_reader(file);
let buffer = Cursor::new(Vec::new());
let writer = XmlItemWriterBuilder::new()
.root_tag("filtered_catalog")
.item_tag("product")
.from_writer(buffer);
let processor = ProductProcessor;
let step = StepBuilder::new("test")
.chunk::<Product, Product>(2)
.reader(&reader)
.processor(&processor)
.writer(&writer)
.skip_limit(1) .build();
let job = JobBuilder::new().start(&step).build();
let result = job.run();
assert!(result.is_ok());
let step_execution = job.get_step_execution("test").unwrap();
assert!(step_execution.status == StepStatus::Success);
assert!(step_execution.read_count == 2);
assert!(step_execution.write_count == 2);
assert!(step_execution.read_error_count == 1);
assert!(step_execution.write_error_count == 0);
fs::remove_file(&xml_path).ok();
}