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 content
29 .chars()
30 .filter(|&c| {
31 !(c.is_control() && c != '\n' && c != '\r' && c != '\t') })
33 .collect::<String>()
34 .replace('&', "&")
35 .replace('<', "<")
36 .replace('>', ">")
37 .replace('"', """)
38 .replace('\'', "'")
39}
40
41pub 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
67pub 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
129fn 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
140fn 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
160fn 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
180fn 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
200fn 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
224fn 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
248fn 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
277fn 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
292fn 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
303fn 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
329fn 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 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 & 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 <special> & "characters"",
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}')); }
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}