1use crate::core::item::{ItemWriter, ItemWriterResult};
2use crate::error::BatchError;
3use quick_xml::{
4 events::{BytesEnd, BytesStart, Event},
5 Writer,
6};
7use serde::Serialize;
8use std::cell::RefCell;
9use std::fs::File;
10use std::io::{BufWriter, Write};
11use std::marker::PhantomData;
12use std::path::Path;
13
14pub struct XmlItemWriter<O, W: Write = File> {
99 writer: RefCell<Writer<BufWriter<W>>>,
100 item_tag: String,
101 root_tag: String,
102 _phantom: PhantomData<O>,
103}
104
105impl<O, W: Write> ItemWriter<O> for XmlItemWriter<O, W>
106where
107 O: Serialize,
108{
109 fn write(&self, items: &[O]) -> ItemWriterResult {
110 for item in items {
111 self.writer
112 .borrow_mut()
113 .write_serializable(&self.item_tag, item)
114 .map_err(|e| BatchError::ItemWriter(format!("Failed to write XML item: {}", e)))?;
115 }
116 Ok(())
117 }
118
119 fn flush(&self) -> ItemWriterResult {
120 let result = self.writer.borrow_mut().get_mut().flush();
121 match result {
122 Ok(()) => Ok(()),
123 Err(e) => Err(BatchError::ItemWriter(format!(
124 "Failed to flush XML file: {}",
125 e
126 ))),
127 }
128 }
129
130 fn open(&self) -> ItemWriterResult {
131 let root = BytesStart::new(&self.root_tag);
132 self.writer
133 .borrow_mut()
134 .write_event(Event::Start(root))
135 .map_err(|e| BatchError::ItemWriter(format!("Failed to write XML root: {}", e)))?;
136 Ok(())
137 }
138
139 fn close(&self) -> ItemWriterResult {
140 self.writer
141 .borrow_mut()
142 .write_event(Event::End(BytesEnd::new(&self.root_tag)))
143 .map_err(|e| BatchError::ItemWriter(format!("Failed to write XML end: {}", e)))?;
144 self.flush()
145 }
146}
147
148#[derive(Default)]
206pub struct XmlItemWriterBuilder<O> {
207 root_tag: String,
208 item_tag: Option<String>,
209 _pd: PhantomData<O>,
210}
211
212impl<O> XmlItemWriterBuilder<O> {
213 pub fn new() -> Self {
232 Self {
233 root_tag: "root".to_string(),
234 item_tag: None,
235 _pd: PhantomData,
236 }
237 }
238
239 pub fn root_tag(mut self, root_tag: &str) -> Self {
258 self.root_tag = root_tag.to_string();
259 self
260 }
261
262 pub fn item_tag(mut self, item_tag: &str) -> Self {
283 self.item_tag = Some(item_tag.to_string());
284 self
285 }
286
287 pub fn from_path<P: AsRef<Path>>(self, path: P) -> Result<XmlItemWriter<O>, BatchError> {
311 let file = File::create(path)
312 .map_err(|e| BatchError::ItemWriter(format!("Failed to create XML file: {}", e)))?;
313 let writer = Writer::new(BufWriter::new(file));
314 let item_tag = self.item_tag.unwrap_or_else(|| {
315 std::any::type_name::<O>()
316 .split("::")
317 .last()
318 .unwrap_or("item")
319 .to_lowercase()
320 });
321
322 Ok(XmlItemWriter {
323 writer: RefCell::new(writer),
324 item_tag,
325 root_tag: self.root_tag,
326 _phantom: PhantomData,
327 })
328 }
329
330 pub fn from_writer<W: Write>(self, wtr: W) -> XmlItemWriter<O, W> {
362 let writer = Writer::new(BufWriter::new(wtr));
363 let item_tag = self.item_tag.unwrap_or_else(|| {
364 std::any::type_name::<O>()
365 .split("::")
366 .last()
367 .unwrap_or("item")
368 .to_lowercase()
369 });
370
371 XmlItemWriter {
372 writer: RefCell::new(writer),
373 item_tag,
374 root_tag: self.root_tag,
375 _phantom: PhantomData,
376 }
377 }
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383 use serde::{Deserialize, Serialize};
384 use std::io::Cursor;
385 use tempfile::NamedTempFile;
386
387 #[derive(Debug, Deserialize, Serialize, PartialEq, Clone)]
388 struct Contact {
389 #[serde(rename = "@type")]
390 contact_type: String,
391 name: String,
392 email: String,
393 phone: String,
394 }
395
396 #[derive(Debug, Deserialize, Serialize, PartialEq, Clone)]
397 struct Location {
398 #[serde(rename = "@country")]
399 country: String,
400 city: String,
401 #[serde(rename = "@timezone")]
402 timezone: String,
403 coordinates: Coordinates,
404 }
405
406 #[derive(Debug, Deserialize, Serialize, PartialEq, Clone)]
407 struct Coordinates {
408 #[serde(rename = "@format")]
409 format: String,
410 latitude: f64,
411 longitude: f64,
412 }
413
414 #[derive(Debug, Deserialize, Serialize, PartialEq, Clone)]
415 struct Company {
416 #[serde(rename = "@id")]
417 id: i32,
418 #[serde(rename = "@type")]
419 company_type: String,
420 name: String,
421 founded_year: i32,
422 contact: Vec<Contact>,
423 location: Location,
424 #[serde(rename = "@active")]
425 active: bool,
426 }
427
428 #[derive(Debug, Serialize, Deserialize, PartialEq)]
429 struct SimpleItem {
430 id: i32,
431 name: String,
432 value: f64,
433 }
434
435 #[derive(Debug, Serialize, Deserialize, PartialEq)]
436 struct Product {
437 id: i32,
438 name: String,
439 price: f64,
440 tags: Vec<String>,
441 }
442
443 #[test]
444 fn test_xml_writer_builder() {
445 let temp_file = NamedTempFile::new().unwrap();
446 let writer = XmlItemWriterBuilder::<Company>::new()
447 .root_tag("companies")
448 .item_tag("company")
449 .from_path(temp_file.path())
450 .unwrap();
451
452 let items = vec![
453 Company {
454 id: 1,
455 company_type: "tech".to_string(),
456 name: "TechCorp".to_string(),
457 founded_year: 2010,
458 active: true,
459 contact: vec![
460 Contact {
461 contact_type: "primary".to_string(),
462 name: "John Doe".to_string(),
463 email: "john@techcorp.com".to_string(),
464 phone: "+1-555-0123".to_string(),
465 },
466 Contact {
467 contact_type: "secondary".to_string(),
468 name: "Jane Smith".to_string(),
469 email: "jane@techcorp.com".to_string(),
470 phone: "+1-555-0124".to_string(),
471 },
472 ],
473 location: Location {
474 country: "USA".to_string(),
475 city: "San Francisco".to_string(),
476 timezone: "PST".to_string(),
477 coordinates: Coordinates {
478 format: "decimal".to_string(),
479 latitude: 37.7749,
480 longitude: -122.4194,
481 },
482 },
483 },
484 Company {
485 id: 2,
486 company_type: "finance".to_string(),
487 name: "FinanceCo".to_string(),
488 founded_year: 2000,
489 active: true,
490 contact: vec![Contact {
491 contact_type: "primary".to_string(),
492 name: "Alice Brown".to_string(),
493 email: "alice@financeco.com".to_string(),
494 phone: "+1-555-0125".to_string(),
495 }],
496 location: Location {
497 country: "UK".to_string(),
498 city: "London".to_string(),
499 timezone: "GMT".to_string(),
500 coordinates: Coordinates {
501 format: "decimal".to_string(),
502 latitude: 51.5074,
503 longitude: -0.1278,
504 },
505 },
506 },
507 ];
508
509 writer.open().unwrap();
510 writer.write(&items).unwrap();
511 writer.close().unwrap();
512
513 let content = std::fs::read_to_string(temp_file.path()).unwrap();
515 println!("Generated XML:\n{}", content);
516
517 assert!(content.contains("<companies>"));
519 assert!(content.contains("</companies>"));
520
521 assert!(content.contains("<company id=\"1\" type=\"tech\" active=\"true\">"));
523 assert!(content.contains("<name>TechCorp</name>"));
524 assert!(content.contains("<founded_year>2010</founded_year>"));
525
526 assert!(content.contains("<contact type=\"primary\">"));
528 assert!(content.contains("<name>John Doe</name>"));
529 assert!(content.contains("<email>john@techcorp.com</email>"));
530 assert!(content.contains("<phone>+1-555-0123</phone>"));
531 assert!(content.contains("<contact type=\"secondary\">"));
532 assert!(content.contains("<name>Jane Smith</name>"));
533
534 assert!(content.contains("<location country=\"USA\" timezone=\"PST\">"));
536 assert!(content.contains("<city>San Francisco</city>"));
537
538 assert!(content.contains("<coordinates format=\"decimal\">"));
540 assert!(content.contains("<latitude>37.7749</latitude>"));
541 assert!(content.contains("<longitude>-122.4194</longitude>"));
542
543 assert!(content.contains("<company id=\"2\" type=\"finance\" active=\"true\">"));
545 assert!(content.contains("<name>FinanceCo</name>"));
546 assert!(content.contains("<founded_year>2000</founded_year>"));
547 assert!(content.contains("<location country=\"UK\" timezone=\"GMT\">"));
548 assert!(content.contains("<city>London</city>"));
549 }
550
551 #[test]
552 fn test_in_memory_writing() {
553 let buffer = Cursor::new(Vec::new());
554 let writer = XmlItemWriterBuilder::<SimpleItem>::new()
555 .root_tag("items")
556 .item_tag("item")
557 .from_writer(buffer);
558
559 let items = vec![
560 SimpleItem {
561 id: 1,
562 name: "Item 1".to_string(),
563 value: 10.5,
564 },
565 SimpleItem {
566 id: 2,
567 name: "Item 2".to_string(),
568 value: 20.75,
569 },
570 ];
571
572 writer.open().unwrap();
573 writer.write(&items).unwrap();
574 writer.close().unwrap();
575
576 let content = {
578 let buf_writer = writer.writer.borrow_mut();
579 let cursor = buf_writer.get_ref().get_ref();
580 String::from_utf8(cursor.get_ref().clone()).unwrap()
581 };
582
583 assert!(content.contains("<items>"));
584 assert!(content.contains("<item>"));
585 assert!(content.contains("<id>1</id>"));
586 assert!(content.contains("<name>Item 1</name>"));
587 assert!(content.contains("<value>10.5</value>"));
588 assert!(content.contains("<id>2</id>"));
589 assert!(content.contains("<name>Item 2</name>"));
590 assert!(content.contains("<value>20.75</value>"));
591 assert!(content.contains("</item>"));
592 assert!(content.contains("</items>"));
593 }
594
595 #[test]
596 fn test_empty_collection() {
597 let buffer = Cursor::new(Vec::new());
598 let writer = XmlItemWriterBuilder::<SimpleItem>::new()
599 .root_tag("items")
600 .item_tag("item")
601 .from_writer(buffer);
602
603 let empty_items: Vec<SimpleItem> = vec![];
604
605 writer.open().unwrap();
606 writer.write(&empty_items).unwrap();
607 writer.close().unwrap();
608
609 let content = {
611 let buf_writer = writer.writer.borrow_mut();
612 let cursor = buf_writer.get_ref().get_ref();
613 String::from_utf8(cursor.get_ref().clone()).unwrap()
614 };
615
616 assert_eq!(content, "<items></items>");
617 }
618
619 #[test]
620 fn test_default_item_tag() {
621 let buffer = Cursor::new(Vec::new());
622
623 let writer = XmlItemWriterBuilder::<SimpleItem>::new()
625 .root_tag("items")
626 .from_writer(buffer);
627
628 let items = vec![SimpleItem {
629 id: 1,
630 name: "Test".to_string(),
631 value: 1.0,
632 }];
633
634 writer.open().unwrap();
635 writer.write(&items).unwrap();
636 writer.close().unwrap();
637
638 let content = {
640 let buf_writer = writer.writer.borrow_mut();
641 let cursor = buf_writer.get_ref().get_ref();
642 String::from_utf8(cursor.get_ref().clone()).unwrap()
643 };
644
645 assert!(content.contains("<simpleitem>"));
647 assert!(content.contains("</simpleitem>"));
648 }
649
650 #[test]
651 fn test_xml_escaping() {
652 let buffer = Cursor::new(Vec::new());
653 let writer = XmlItemWriterBuilder::<SimpleItem>::new()
654 .root_tag("items")
655 .item_tag("item")
656 .from_writer(buffer);
657
658 let items = vec![
660 SimpleItem {
661 id: 1,
662 name: "Item with < and > symbols".to_string(),
663 value: 10.5,
664 },
665 SimpleItem {
666 id: 2,
667 name: "Item with & and \" characters".to_string(),
668 value: 20.75,
669 },
670 ];
671
672 writer.open().unwrap();
673 writer.write(&items).unwrap();
674 writer.close().unwrap();
675
676 let content = {
678 let buf_writer = writer.writer.borrow_mut();
679 let cursor = buf_writer.get_ref().get_ref();
680 String::from_utf8(cursor.get_ref().clone()).unwrap()
681 };
682
683 println!("XML content: {}", content);
685
686 assert!(content.contains("Item with < and > symbols"));
688 assert!(content.contains("Item with &") || content.contains("Item with &"));
690 assert!(content.contains("\"") || content.contains("""));
691 }
692
693 #[test]
694 fn test_array_fields() {
695 let buffer = Cursor::new(Vec::new());
696 let writer = XmlItemWriterBuilder::<Product>::new()
697 .root_tag("products")
698 .item_tag("product")
699 .from_writer(buffer);
700
701 let items = vec![
702 Product {
703 id: 1,
704 name: "Laptop".to_string(),
705 price: 999.99,
706 tags: vec![
707 "electronics".to_string(),
708 "computer".to_string(),
709 "portable".to_string(),
710 ],
711 },
712 Product {
713 id: 2,
714 name: "Smartphone".to_string(),
715 price: 699.99,
716 tags: vec!["electronics".to_string(), "mobile".to_string()],
717 },
718 ];
719
720 writer.open().unwrap();
721 writer.write(&items).unwrap();
722 writer.close().unwrap();
723
724 let content = {
726 let buf_writer = writer.writer.borrow_mut();
727 let cursor = buf_writer.get_ref().get_ref();
728 String::from_utf8(cursor.get_ref().clone()).unwrap()
729 };
730
731 assert!(content.contains("<products>"));
733 assert!(content.contains("<product>"));
734 assert!(content.contains("<id>1</id>"));
735 assert!(content.contains("<name>Laptop</name>"));
736 assert!(content.contains("<price>999.99</price>"));
737 assert!(content.contains("<tags>electronics</tags>"));
738 assert!(content.contains("<tags>computer</tags>"));
739 assert!(content.contains("<tags>portable</tags>"));
740 assert!(content.contains("<id>2</id>"));
741 assert!(content.contains("<name>Smartphone</name>"));
742 assert!(content.contains("<price>699.99</price>"));
743 assert!(content.contains("</product>"));
744 assert!(content.contains("</products>"));
745 }
746
747 #[test]
748 fn should_use_type_name_as_default_item_tag_when_not_set() {
749 let temp_file = NamedTempFile::new().unwrap();
750 let writer = XmlItemWriterBuilder::<SimpleItem>::new()
752 .root_tag("items")
753 .from_path(temp_file.path())
754 .unwrap();
755 assert_eq!(writer.item_tag, "simpleitem");
757 }
758
759 #[test]
760 fn should_return_error_when_flush_fails_on_io() {
761 struct FailWriter;
762 impl Write for FailWriter {
763 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
764 Ok(buf.len()) }
766 fn flush(&mut self) -> std::io::Result<()> {
767 Err(std::io::Error::new(
768 std::io::ErrorKind::Other,
769 "flush failed",
770 ))
771 }
772 }
773
774 let writer = XmlItemWriter::<SimpleItem, FailWriter> {
775 writer: RefCell::new(Writer::new(BufWriter::new(FailWriter))),
776 item_tag: "item".to_string(),
777 root_tag: "items".to_string(),
778 _phantom: PhantomData,
779 };
780
781 let result = writer.flush();
782 assert!(
783 result.is_err(),
784 "flush should fail when underlying writer fails"
785 );
786 match result.err().unwrap() {
787 BatchError::ItemWriter(msg) => {
788 assert!(
789 msg.contains("flush"),
790 "error message should mention flush, got: {msg}"
791 )
792 }
793 e => panic!("expected ItemWriter error, got {e:?}"),
794 }
795 }
796
797 #[test]
798 fn test_error_handling_invalid_path() {
799 let invalid_path = "/nonexistent/directory/file.xml";
801 let result = XmlItemWriterBuilder::<SimpleItem>::new()
802 .root_tag("items")
803 .item_tag("item")
804 .from_path(invalid_path);
805
806 assert!(result.is_err());
808
809 if let Err(error) = result {
811 if let BatchError::ItemWriter(message) = error {
812 assert!(message.contains("Failed to create XML file"));
813 } else {
814 panic!("Expected ItemWriter error, got {:?}", error);
815 }
816 }
817 }
818}