1use 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#[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') })
33 .collect();
34
35 let unescaped = filtered
38 .replace("'", "'")
39 .replace(""", "\"")
40 .replace(">", ">")
41 .replace("<", "<")
42 .replace("&", "&");
43
44 unescaped
45 .replace('&', "&")
46 .replace('<', "<")
47 .replace('>', ">")
48 .replace('"', """)
49 .replace('\'', "'")
50}
51
52pub 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
79pub 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
141fn 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
152fn 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
172fn 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
192fn 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
212fn 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
236fn 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
260fn 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
289fn 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
304fn 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
315fn 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
341fn 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 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 & 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 <special> & "characters"",
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}')); }
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 let input = "& < > " '";
606 let result = sanitize_content(input);
607 assert_eq!(result, "& < > " '");
608
609 let result2 = sanitize_content(&result);
611 assert_eq!(result2, result);
612 }
613
614 #[test]
615 fn test_sanitize_content_mixed_escaped_and_raw() {
616 let input = "Hello & <world> "test"";
618 let result = sanitize_content(input);
619 assert_eq!(
620 result,
621 "Hello & <world> "test""
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}