rss_gen/
generator.rs

1// Copyright © 2024 RSS Generator. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4// src/generator.rs
5
6use crate::data::{RssData, RssItem, RssVersion};
7use crate::error::{Result, RssError};
8use quick_xml::events::{
9    BytesDecl, BytesEnd, BytesStart, BytesText, Event,
10};
11use quick_xml::Writer;
12use std::io::Cursor;
13
14const XML_VERSION: &str = "1.0";
15const XML_ENCODING: &str = "utf-8";
16
17/// Sanitizes the content by removing invalid XML characters and escaping special characters.
18///
19/// # Arguments
20///
21/// * `content` - A string slice containing the content to be sanitized.
22///
23/// # Returns
24///
25/// A `String` with invalid XML characters removed and special characters escaped.
26#[must_use]
27pub fn sanitize_content(content: &str) -> String {
28    content
29        .chars()
30        .filter(|&c| {
31            !(c.is_control() && c != '\n' && c != '\r' && c != '\t') // Keep valid control characters like newlines and tabs
32        })
33        .collect::<String>()
34        .replace('&', "&amp;")
35        .replace('<', "&lt;")
36        .replace('>', "&gt;")
37        .replace('"', "&quot;")
38        .replace('\'', "&#x27;")
39}
40
41/// Writes an XML element with the given name and content.
42///
43/// # Arguments
44///
45/// * `writer` - A mutable reference to the XML writer.
46/// * `name` - The name of the XML element.
47/// * `content` - The content of the XML element.
48///
49/// # Returns
50///
51/// A `Result` indicating success or failure of the write operation.
52///
53/// # Errors
54///
55/// This function returns an `Err` if there is an issue with writing XML content.
56pub fn write_element<W: std::io::Write>(
57    writer: &mut Writer<W>,
58    name: &str,
59    content: &str,
60) -> Result<()> {
61    writer.write_event(Event::Start(BytesStart::new(name)))?;
62    writer.write_event(Event::Text(BytesText::new(content)))?;
63    writer.write_event(Event::End(BytesEnd::new(name)))?;
64    Ok(())
65}
66
67/// Generates an RSS feed from the given `RssData` struct.
68///
69/// This function creates a complete RSS feed in XML format based on the data contained in the provided `RssData`.
70/// It generates the feed according to the RSS version set in the `RssData`.
71///
72/// # Arguments
73///
74/// * `options` - A reference to a `RssData` struct containing the RSS feed data.
75///
76/// # Returns
77///
78/// * `Ok(String)` - The generated RSS feed as a string if successful.
79/// * `Err(RssError)` - An error if RSS generation fails.
80///
81/// # Errors
82///
83/// This function returns an error if there are issues in validating the RSS data or writing the RSS feed.
84///
85/// # Example
86///
87/// ```
88/// use rss_gen::{RssData, generate_rss, RssVersion};
89///
90/// let rss_data = RssData::new(Some(RssVersion::RSS2_0))
91///     .title("My Blog")
92///     .link("https://myblog.com")
93///     .description("A blog about Rust programming");
94///
95/// match generate_rss(&rss_data) {
96///     Ok(rss_feed) => println!("{}", rss_feed),
97///     Err(e) => eprintln!("Error generating RSS: {}", e),
98/// }
99/// ```
100pub fn generate_rss(options: &RssData) -> Result<String> {
101    options.validate()?;
102
103    let mut writer = Writer::new(Cursor::new(Vec::new()));
104
105    write_xml_declaration(&mut writer)?;
106
107    match options.version {
108        RssVersion::RSS0_90 => {
109            write_rss_channel_0_90(&mut writer, options)?;
110        }
111        RssVersion::RSS0_91 => {
112            write_rss_channel_0_91(&mut writer, options)?;
113        }
114        RssVersion::RSS0_92 => {
115            write_rss_channel_0_92(&mut writer, options)?;
116        }
117        RssVersion::RSS1_0 => {
118            write_rss_channel_1_0(&mut writer, options)?;
119        }
120        RssVersion::RSS2_0 => {
121            write_rss_channel_2_0(&mut writer, options)?;
122        }
123    }
124
125    let xml = writer.into_inner().into_inner();
126    String::from_utf8(xml).map_err(RssError::from)
127}
128
129/// Writes the XML declaration to the writer.
130fn write_xml_declaration<W: std::io::Write>(
131    writer: &mut Writer<W>,
132) -> Result<()> {
133    Ok(writer.write_event(Event::Decl(BytesDecl::new(
134        XML_VERSION,
135        Some(XML_ENCODING),
136        None,
137    )))?)
138}
139
140/// Writes the RSS 0.90 channel element and its contents.
141fn write_rss_channel_0_90<W: std::io::Write>(
142    writer: &mut Writer<W>,
143    options: &RssData,
144) -> Result<()> {
145    let mut rss_start = BytesStart::new("rss");
146    rss_start.push_attribute(("version", "0.90"));
147    writer.write_event(Event::Start(rss_start))?;
148
149    writer.write_event(Event::Start(BytesStart::new("channel")))?;
150
151    write_channel_elements(writer, options)?;
152    write_items(writer, options)?;
153
154    writer.write_event(Event::End(BytesEnd::new("channel")))?;
155    writer.write_event(Event::End(BytesEnd::new("rss")))?;
156
157    Ok(())
158}
159
160/// Writes the RSS 0.91 channel element and its contents.
161fn write_rss_channel_0_91<W: std::io::Write>(
162    writer: &mut Writer<W>,
163    options: &RssData,
164) -> Result<()> {
165    let mut rss_start = BytesStart::new("rss");
166    rss_start.push_attribute(("version", "0.91"));
167    writer.write_event(Event::Start(rss_start))?;
168
169    writer.write_event(Event::Start(BytesStart::new("channel")))?;
170
171    write_channel_elements(writer, options)?;
172    write_items(writer, options)?;
173
174    writer.write_event(Event::End(BytesEnd::new("channel")))?;
175    writer.write_event(Event::End(BytesEnd::new("rss")))?;
176
177    Ok(())
178}
179
180/// Writes the RSS 0.92 channel element and its contents.
181fn write_rss_channel_0_92<W: std::io::Write>(
182    writer: &mut Writer<W>,
183    options: &RssData,
184) -> Result<()> {
185    let mut rss_start = BytesStart::new("rss");
186    rss_start.push_attribute(("version", "0.92"));
187    writer.write_event(Event::Start(rss_start))?;
188
189    writer.write_event(Event::Start(BytesStart::new("channel")))?;
190
191    write_channel_elements(writer, options)?;
192    write_items(writer, options)?;
193
194    writer.write_event(Event::End(BytesEnd::new("channel")))?;
195    writer.write_event(Event::End(BytesEnd::new("rss")))?;
196
197    Ok(())
198}
199
200/// Writes the RSS 1.0 channel element and its contents.
201fn write_rss_channel_1_0<W: std::io::Write>(
202    writer: &mut Writer<W>,
203    options: &RssData,
204) -> Result<()> {
205    let mut rdf_start = BytesStart::new("rdf:RDF");
206    rdf_start.push_attribute((
207        "xmlns:rdf",
208        "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
209    ));
210    rdf_start.push_attribute(("xmlns", "http://purl.org/rss/1.0/"));
211    writer.write_event(Event::Start(rdf_start))?;
212
213    writer.write_event(Event::Start(BytesStart::new("channel")))?;
214
215    write_channel_elements(writer, options)?;
216    write_items(writer, options)?;
217
218    writer.write_event(Event::End(BytesEnd::new("channel")))?;
219    writer.write_event(Event::End(BytesEnd::new("rdf:RDF")))?;
220
221    Ok(())
222}
223
224/// Writes the RSS 2.0 channel element and its contents.
225fn write_rss_channel_2_0<W: std::io::Write>(
226    writer: &mut Writer<W>,
227    options: &RssData,
228) -> Result<()> {
229    let mut rss_start = BytesStart::new("rss");
230    rss_start.push_attribute(("version", "2.0"));
231    rss_start
232        .push_attribute(("xmlns:atom", "http://www.w3.org/2005/Atom"));
233    writer.write_event(Event::Start(rss_start))?;
234
235    writer.write_event(Event::Start(BytesStart::new("channel")))?;
236
237    write_channel_elements(writer, options)?;
238    write_image_element(writer, options)?;
239    write_atom_link_element(writer, options)?;
240    write_items(writer, options)?;
241
242    writer.write_event(Event::End(BytesEnd::new("channel")))?;
243    writer.write_event(Event::End(BytesEnd::new("rss")))?;
244
245    Ok(())
246}
247
248/// Writes the channel elements to the writer.
249fn write_channel_elements<W: std::io::Write>(
250    writer: &mut Writer<W>,
251    options: &RssData,
252) -> Result<()> {
253    let elements = [
254        ("title", &options.title),
255        ("link", &options.link),
256        ("description", &options.description),
257        ("language", &options.language),
258        ("pubDate", &options.pub_date),
259        ("lastBuildDate", &options.last_build_date),
260        ("docs", &options.docs),
261        ("generator", &options.generator),
262        ("managingEditor", &options.managing_editor),
263        ("webMaster", &options.webmaster),
264        ("category", &options.category),
265        ("ttl", &options.ttl),
266    ];
267
268    for (name, content) in &elements {
269        if !content.is_empty() {
270            write_element(writer, name, content)?;
271        }
272    }
273
274    Ok(())
275}
276
277/// Writes the image element to the writer.
278fn write_image_element<W: std::io::Write>(
279    writer: &mut Writer<W>,
280    options: &RssData,
281) -> Result<()> {
282    if !options.image_url.is_empty() {
283        writer.write_event(Event::Start(BytesStart::new("image")))?;
284        write_element(writer, "url", &options.image_url)?;
285        write_element(writer, "title", &options.title)?;
286        write_element(writer, "link", &options.link)?;
287        writer.write_event(Event::End(BytesEnd::new("image")))?;
288    }
289    Ok(())
290}
291
292/// Writes the item elements to the RSS feed.
293fn write_items<W: std::io::Write>(
294    writer: &mut Writer<W>,
295    options: &RssData,
296) -> Result<()> {
297    for item in &options.items {
298        write_item(writer, item)?;
299    }
300    Ok(())
301}
302
303/// Writes a single item element to the RSS feed.
304fn write_item<W: std::io::Write>(
305    writer: &mut Writer<W>,
306    item: &RssItem,
307) -> Result<()> {
308    writer.write_event(Event::Start(BytesStart::new("item")))?;
309
310    let item_elements = [
311        ("title", &item.title),
312        ("link", &item.link),
313        ("description", &item.description),
314        ("guid", &item.guid),
315        ("pubDate", &item.pub_date),
316        ("author", &item.author),
317    ];
318
319    for (name, content) in &item_elements {
320        if !content.is_empty() {
321            write_element(writer, name, content)?;
322        }
323    }
324
325    writer.write_event(Event::End(BytesEnd::new("item")))?;
326    Ok(())
327}
328
329/// Writes the Atom link element to the writer.
330fn write_atom_link_element<W: std::io::Write>(
331    writer: &mut Writer<W>,
332    options: &RssData,
333) -> Result<()> {
334    if !options.atom_link.is_empty() {
335        let mut atom_link_start = BytesStart::new("atom:link");
336        atom_link_start
337            .push_attribute(("href", options.atom_link.as_str()));
338        atom_link_start.push_attribute(("rel", "self"));
339        atom_link_start.push_attribute(("type", "application/rss+xml"));
340        writer.write_event(Event::Empty(atom_link_start))?;
341    }
342    Ok(())
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348    use quick_xml::events::Event;
349    use quick_xml::Reader;
350
351    fn assert_xml_element(xml: &str, element: &str, expected: &str) {
352        let mut reader = Reader::from_str(xml);
353        let mut found = false;
354
355        loop {
356            match reader.read_event() {
357                Ok(Event::Start(ref e))
358                    if e.name().as_ref() == element.as_bytes() =>
359                {
360                    match reader.read_event() {
361                        Ok(Event::Text(e)) => {
362                            let unescaped = e.unescape().unwrap();
363                            assert_eq!(unescaped, expected);
364                            found = true;
365                            break;
366                        }
367                        _ => continue,
368                    }
369                }
370                Ok(Event::Eof) => break,
371                Err(e) => panic!(
372                    "Error at position {}: {:?}",
373                    reader.buffer_position(),
374                    e
375                ),
376                _ => (),
377            }
378        }
379        assert!(
380            found,
381            "Element '{}' not found or doesn't match expected content",
382            element
383        );
384    }
385
386    #[test]
387    fn test_generate_rss_minimal() {
388        let rss_data = RssData::new(None)
389            .title("Minimal Feed")
390            .link("https://example.com")
391            .description("A minimal RSS feed");
392
393        let result = generate_rss(&rss_data);
394        assert!(result.is_ok());
395
396        let rss_feed = result.unwrap();
397        assert!(rss_feed.contains(r#"<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">"#));
398        assert_xml_element(&rss_feed, "title", "Minimal Feed");
399        assert_xml_element(&rss_feed, "link", "https://example.com");
400        assert_xml_element(
401            &rss_feed,
402            "description",
403            "A minimal RSS feed",
404        );
405    }
406
407    #[test]
408    fn test_generate_rss_full() {
409        let mut rss_data = RssData::new(None)
410            .title("Full Feed")
411            .link("https://example.com")
412            .description("A full RSS feed")
413            .language("en-US")
414            .pub_date("Mon, 01 Jan 2023 00:00:00 GMT")
415            .last_build_date("Mon, 01 Jan 2023 00:00:00 GMT")
416            .docs("https://example.com/rss/docs")
417            .generator("rss-gen")
418            .managing_editor("editor@example.com")
419            .webmaster("webmaster@example.com")
420            .category("Technology")
421            .ttl("60")
422            .image_url("https://example.com/image.png")
423            .atom_link("https://example.com/feed.xml");
424
425        rss_data.add_item(
426            RssItem::new()
427                .title("Test Item")
428                .link("https://example.com/item1")
429                .description("A test item")
430                .guid("https://example.com/item1")
431                .pub_date("Mon, 01 Jan 2023 00:00:00 GMT")
432                .author("John Doe"),
433        );
434
435        let result = generate_rss(&rss_data);
436
437        // Add this to print the error if the result is not ok
438        if let Err(ref e) = result {
439            eprintln!("Error generating RSS: {:?}", e);
440        }
441
442        assert!(result.is_ok());
443
444        let rss_feed = result.unwrap();
445        assert!(rss_feed.contains(r#"<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">"#));
446        assert_xml_element(&rss_feed, "title", "Full Feed");
447        assert_xml_element(&rss_feed, "link", "https://example.com");
448        assert_xml_element(&rss_feed, "description", "A full RSS feed");
449        assert_xml_element(&rss_feed, "language", "en-US");
450        assert_xml_element(
451            &rss_feed,
452            "pubDate",
453            "Mon, 01 Jan 2023 00:00:00 GMT",
454        );
455        assert!(rss_feed.contains("<item>"));
456        assert_xml_element(&rss_feed, "author", "John Doe");
457        assert_xml_element(
458            &rss_feed,
459            "guid",
460            "https://example.com/item1",
461        );
462    }
463
464    #[test]
465    fn test_generate_rss_empty_fields() {
466        let rss_data = RssData::new(None)
467            .title("Empty Fields Feed")
468            .link("https://example.com")
469            .description("An RSS feed with some empty fields")
470            .language("")
471            .pub_date("")
472            .last_build_date("");
473
474        let result = generate_rss(&rss_data);
475        assert!(result.is_ok());
476
477        let rss_feed = result.unwrap();
478        assert_xml_element(&rss_feed, "title", "Empty Fields Feed");
479        assert_xml_element(&rss_feed, "link", "https://example.com");
480        assert_xml_element(
481            &rss_feed,
482            "description",
483            "An RSS feed with some empty fields",
484        );
485        assert!(!rss_feed.contains("<language>"));
486        assert!(!rss_feed.contains("<pubDate>"));
487        assert!(!rss_feed.contains("<lastBuildDate>"));
488    }
489
490    #[test]
491    fn test_generate_rss_special_characters() {
492        let rss_data = RssData::new(None)
493            .title("Special & Characters")
494            .link("https://example.com/special?param=value")
495            .description("Feed with <special> & \"characters\"");
496
497        let result = generate_rss(&rss_data);
498        assert!(result.is_ok());
499
500        let rss_feed = result.unwrap();
501        assert_xml_element(
502            &rss_feed,
503            "title",
504            "Special &amp; Characters",
505        );
506        assert_xml_element(
507            &rss_feed,
508            "link",
509            "https://example.com/special?param=value",
510        );
511        assert_xml_element(
512            &rss_feed,
513            "description",
514            "Feed with &lt;special&gt; &amp; &quot;characters&quot;",
515        );
516    }
517
518    #[test]
519    fn test_generate_rss_multiple_items() {
520        let mut rss_data = RssData::new(None)
521            .title("Multiple Items Feed")
522            .link("https://example.com")
523            .description("An RSS feed with multiple items");
524
525        for i in 1..=3 {
526            rss_data.add_item(
527                RssItem::new()
528                    .title(format!("Item {}", i))
529                    .link(format!("https://example.com/item{}", i))
530                    .description(format!("Description for item {}", i))
531                    .guid(format!("https://example.com/item{}", i))
532                    .pub_date(format!(
533                        "Mon, 0{} Jan 2023 00:00:00 GMT",
534                        i
535                    )),
536            );
537        }
538
539        let result = generate_rss(&rss_data);
540        assert!(result.is_ok());
541
542        let rss_feed = result.unwrap();
543        assert_xml_element(&rss_feed, "title", "Multiple Items Feed");
544
545        for i in 1..=3 {
546            assert!(rss_feed
547                .contains(&format!("<title>Item {}</title>", i)));
548            assert!(rss_feed.contains(&format!(
549                "<link>https://example.com/item{}</link>",
550                i
551            )));
552            assert!(rss_feed.contains(&format!(
553                "<description>Description for item {}</description>",
554                i
555            )));
556            assert!(rss_feed.contains(&format!(
557                "<guid>https://example.com/item{}</guid>",
558                i
559            )));
560            assert!(rss_feed.contains(&format!(
561                "<pubDate>Mon, 0{} Jan 2023 00:00:00 GMT</pubDate>",
562                i
563            )));
564        }
565    }
566
567    #[test]
568    fn test_generate_rss_invalid_xml_characters() {
569        let rss_data = RssData::new(None)
570            .title(sanitize_content("Invalid XML \u{0000} Characters"))
571            .link("https://example.com")
572            .description(sanitize_content(
573                "Description with invalid \u{0000} characters",
574            ));
575
576        let result = generate_rss(&rss_data);
577        assert!(result.is_ok());
578
579        let rss_feed = result.unwrap();
580        assert!(!rss_feed.contains('\u{0000}')); // Ensure \u{0000} is not present in the feed
581    }
582
583    #[test]
584    fn test_generate_rss_long_content() {
585        let long_description = "a".repeat(10000);
586        let rss_data = RssData::new(None)
587            .title("Long Content Feed")
588            .link("https://example.com")
589            .description(&long_description);
590
591        let result = generate_rss(&rss_data);
592        assert!(result.is_ok());
593
594        let rss_feed = result.unwrap();
595        assert_xml_element(&rss_feed, "title", "Long Content Feed");
596        assert_xml_element(&rss_feed, "description", &long_description);
597    }
598
599    #[test]
600    fn test_sanitize_content() {
601        let input =
602            "Text with \u{0000}null\u{0001} and \u{0008}backspace";
603        let sanitized = sanitize_content(input);
604        assert_eq!(sanitized, "Text with null and backspace");
605
606        let input_with_newlines = "Text with \nnewlines\r\nand\ttabs";
607        let sanitized_newlines = sanitize_content(input_with_newlines);
608        assert_eq!(sanitized_newlines, input_with_newlines);
609    }
610
611    #[test]
612    fn test_generate_rss_with_author() {
613        let mut rss_data = RssData::new(None)
614            .title("Feed with Author")
615            .link("https://example.com")
616            .description("An RSS feed with author information");
617
618        rss_data.add_item(
619            RssItem::new()
620                .title("Authored Item")
621                .link("https://example.com/item")
622                .description("An item with an author")
623                .author("John Doe"),
624        );
625
626        let result = generate_rss(&rss_data);
627        assert!(result.is_ok());
628
629        let rss_feed = result.unwrap();
630        assert!(rss_feed.contains("<author>John Doe</author>"));
631    }
632
633    #[test]
634    fn test_generate_rss_different_versions() {
635        let versions = vec![
636            RssVersion::RSS0_90,
637            RssVersion::RSS0_91,
638            RssVersion::RSS0_92,
639            RssVersion::RSS1_0,
640            RssVersion::RSS2_0,
641        ];
642
643        for version in versions {
644            let rss_data = RssData::new(Some(version))
645                .title(format!("RSS {} Feed", version))
646                .link("https://example.com")
647                .description(format!(
648                    "RSS {} feed description",
649                    version
650                ));
651
652            let result = generate_rss(&rss_data);
653            assert!(result.is_ok());
654
655            let rss_feed = result.unwrap();
656            match version {
657                RssVersion::RSS0_90 => assert!(rss_feed.contains(r#"<rss version="0.90">"#)),
658                RssVersion::RSS0_91 => assert!(rss_feed.contains(r#"<rss version="0.91">"#)),
659                RssVersion::RSS0_92 => assert!(rss_feed.contains(r#"<rss version="0.92">"#)),
660                RssVersion::RSS1_0 => assert!(rss_feed.contains(r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://purl.org/rss/1.0/">"#)),
661                RssVersion::RSS2_0 => assert!(rss_feed.contains(r#"<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">"#)),
662            }
663            assert_xml_element(
664                &rss_feed,
665                "title",
666                &format!("RSS {} Feed", version),
667            );
668            assert_xml_element(
669                &rss_feed,
670                "link",
671                "https://example.com",
672            );
673            assert_xml_element(
674                &rss_feed,
675                "description",
676                &format!("RSS {} feed description", version),
677            );
678        }
679    }
680}