xmltv 2.1.1

XMLTV for electronic program guide (EPG) parser and generator using serde.
Documentation
use std::io::Write;

use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, Event};

use crate::{Channel, Programme, Tv};

/// Error returned by [`TvWriter`] operations.
#[derive(Debug)]
pub enum WriteError {
    /// A serialization error (encoding a value to XML).
    Serialize(String),
    /// An I/O error writing to the underlying writer.
    Io(std::io::Error),
}

impl std::fmt::Display for WriteError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            WriteError::Serialize(msg) => write!(f, "serialization error: {msg}"),
            WriteError::Io(e) => write!(f, "I/O error: {e}"),
        }
    }
}

impl std::error::Error for WriteError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            WriteError::Io(e) => Some(e),
            WriteError::Serialize(_) => None,
        }
    }
}

impl From<std::io::Error> for WriteError {
    fn from(e: std::io::Error) -> Self {
        Self::Io(e)
    }
}

impl From<quick_xml::SeError> for WriteError {
    fn from(e: quick_xml::SeError) -> Self {
        Self::Serialize(e.to_string())
    }
}

impl From<quick_xml::Error> for WriteError {
    fn from(e: quick_xml::Error) -> Self {
        Self::Serialize(e.to_string())
    }
}

/// A streaming XMLTV writer that serializes [`Channel`] and [`Programme`] elements one at a
/// time, so large EPGs can be written without building a complete [`Tv`] in memory.
///
/// # Example
///
/// ```rust
/// use xmltv::{Channel, NameAndLang, Programme, Tv, TvWriter, ValueAndLang};
///
/// let mut buf: Vec<u8> = Vec::new();
/// let meta = Tv { generator_info_name: Some("my-app/1.0".into()), ..Default::default() };
/// let mut w = TvWriter::new(&mut buf, &meta).unwrap();
///
/// w.write_channel(&Channel { id: "bbc1.uk".into(), ..Default::default() }).unwrap();
///
/// w.write_programme(&Programme {
///     channel: "bbc1.uk".into(),
///     start: "20240101120000 +0000".into(),
///     ..Default::default()
/// }).unwrap();
///
/// w.finish().unwrap();
/// ```
pub struct TvWriter<W: Write> {
    inner: W,
}

impl<W: Write> TvWriter<W> {
    /// Create a new writer.
    ///
    /// Writes `<?xml version="1.0" encoding="UTF-8"?>` and the opening `<tv>` element with any
    /// attributes present on `tv` (source/generator info). Channels and programmes are NOT written
    /// from `tv`; use [`write_channel`](Self::write_channel) and
    /// [`write_programme`](Self::write_programme) for that.
    pub fn new(mut inner: W, tv: &Tv) -> Result<Self, WriteError> {
        {
            let mut w = quick_xml::Writer::new(&mut inner);
            w.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?;
            let mut elem = BytesStart::new("tv");
            if let Some(ref v) = tv.source_info_url {
                elem.push_attribute(("source-info-url", v.as_str()));
            }
            if let Some(ref v) = tv.source_info_name {
                elem.push_attribute(("source-info-name", v.as_str()));
            }
            if let Some(ref v) = tv.source_data_url {
                elem.push_attribute(("source-data-url", v.as_str()));
            }
            if let Some(ref v) = tv.generator_info_name {
                elem.push_attribute(("generator-info-name", v.as_str()));
            }
            if let Some(ref v) = tv.generator_info_url {
                elem.push_attribute(("generator-info-url", v.as_str()));
            }
            w.write_event(Event::Start(elem))?;
        }
        Ok(Self { inner })
    }

    /// Write a single `<channel>` element.
    pub fn write_channel(&mut self, channel: &Channel) -> Result<(), WriteError> {
        let xml = quick_xml::se::to_string_with_root("channel", channel)?;
        self.inner.write_all(xml.as_bytes())?;
        Ok(())
    }

    /// Write a single `<programme>` element.
    pub fn write_programme(&mut self, programme: &Programme) -> Result<(), WriteError> {
        let xml = quick_xml::se::to_string_with_root("programme", programme)?;
        self.inner.write_all(xml.as_bytes())?;
        Ok(())
    }

    /// Close the `<tv>` element and return the underlying writer.
    pub fn finish(mut self) -> Result<W, WriteError> {
        {
            let mut w = quick_xml::Writer::new(&mut self.inner);
            w.write_event(Event::End(BytesEnd::new("tv")))?;
        }
        Ok(self.inner)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{NameAndLang, ValueAndLang};

    fn write_empty(tv: &Tv) -> String {
        let mut buf: Vec<u8> = Vec::new();
        let w = TvWriter::new(&mut buf, tv).unwrap();
        w.finish().unwrap();
        String::from_utf8(buf).unwrap()
    }

    #[test]
    fn test_tv_writer_empty_contains_declaration_and_tags() {
        let xml = write_empty(&Tv::default());
        assert!(xml.contains("<?xml"), "missing declaration: {xml}");
        assert!(
            xml.contains("<tv>") || xml.contains("<tv "),
            "missing <tv>: {xml}"
        );
        assert!(xml.contains("</tv>"), "missing </tv>: {xml}");
    }

    #[test]
    fn test_tv_writer_attributes_written() {
        let tv = Tv {
            generator_info_name: Some("test/1.0".into()),
            source_info_name: Some("My Source".into()),
            ..Default::default()
        };
        let xml = write_empty(&tv);
        assert!(
            xml.contains("generator-info-name=\"test/1.0\""),
            "xml: {xml}"
        );
        assert!(xml.contains("source-info-name=\"My Source\""), "xml: {xml}");
    }

    #[test]
    fn test_tv_writer_write_channel() {
        let mut buf: Vec<u8> = Vec::new();
        let mut w = TvWriter::new(&mut buf, &Tv::default()).unwrap();
        w.write_channel(&Channel {
            id: "bbc1.uk".into(),
            display_names: vec![NameAndLang {
                name: "BBC One".into(),
                lang: None,
            }],
            ..Default::default()
        })
        .unwrap();
        w.finish().unwrap();
        let xml = String::from_utf8(buf).unwrap();
        assert!(xml.contains("<channel"), "xml: {xml}");
        assert!(xml.contains("bbc1.uk"), "xml: {xml}");
        assert!(xml.contains("BBC One"), "xml: {xml}");
    }

    #[test]
    fn test_tv_writer_write_programme() {
        let mut buf: Vec<u8> = Vec::new();
        let mut w = TvWriter::new(&mut buf, &Tv::default()).unwrap();
        w.write_programme(&Programme {
            channel: "arte.tv".into(),
            start: "20240101200000 +0000".into(),
            titles: vec![ValueAndLang {
                value: "Le Journal".into(),
                lang: Some("fr".into()),
            }],
            ..Default::default()
        })
        .unwrap();
        w.finish().unwrap();
        let xml = String::from_utf8(buf).unwrap();
        assert!(xml.contains("<programme"), "xml: {xml}");
        assert!(xml.contains("arte.tv"), "xml: {xml}");
        assert!(xml.contains("Le Journal"), "xml: {xml}");
    }

    #[test]
    fn test_tv_writer_output_is_valid_xml() {
        let mut buf: Vec<u8> = Vec::new();
        let mut w = TvWriter::new(&mut buf, &Tv::default()).unwrap();
        w.write_channel(&Channel {
            id: "ch1".into(),
            ..Default::default()
        })
        .unwrap();
        w.write_programme(&Programme {
            channel: "ch1".into(),
            start: "20240101120000 +0000".into(),
            ..Default::default()
        })
        .unwrap();
        w.finish().unwrap();

        // The output should parse back as valid XML (well-formed check via quick_xml reader)
        let xml = String::from_utf8(buf).unwrap();
        let mut reader = quick_xml::Reader::from_str(&xml);
        let mut buf2 = Vec::new();
        loop {
            match reader.read_event_into(&mut buf2) {
                Ok(quick_xml::events::Event::Eof) => break,
                Err(e) => panic!("XML parse error: {e}"),
                _ => {}
            }
            buf2.clear();
        }
    }

    #[test]
    fn test_tv_writer_multiple_channels_and_programmes() {
        let mut buf: Vec<u8> = Vec::new();
        let mut w = TvWriter::new(&mut buf, &Tv::default()).unwrap();
        for i in 1..=3 {
            w.write_channel(&Channel {
                id: format!("ch{i}"),
                ..Default::default()
            })
            .unwrap();
        }
        for i in 1..=5 {
            w.write_programme(&Programme {
                channel: format!("ch{}", (i % 3) + 1),
                start: format!("202401011{i}0000 +0000"),
                ..Default::default()
            })
            .unwrap();
        }
        w.finish().unwrap();
        let xml = String::from_utf8(buf).unwrap();
        assert_eq!(xml.matches("<channel").count(), 3);
        assert_eq!(xml.matches("<programme").count(), 5);
    }
}