Skip to main content

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