From 753967b8efcc4c9c066864effc695c1811c412f3 Mon Sep 17 00:00:00 2001 From: Sebastien Rousseau Date: Tue, 24 Sep 2024 23:04:15 +0100 Subject: [PATCH] =?UTF-8?q?refactor(ssg):=20=F0=9F=8E=A8=20handle=20multip?= =?UTF-8?q?le=20RSS=20version=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 1 + ssg-rss/Cargo.toml | 1 + ssg-rss/README.md | 111 +++++++++++ ssg-rss/src/data.rs | 411 +++++++++++++---------------------------- ssg-rss/src/error.rs | 20 ++ ssg-rss/src/lib.rs | 27 ++- ssg-rss/src/macros.rs | 5 +- ssg-rss/src/parser.rs | 270 +++++++++++++++++++++++++++ ssg-rss/src/version.rs | 124 +++++++++++++ 9 files changed, 678 insertions(+), 292 deletions(-) create mode 100644 ssg-rss/src/parser.rs create mode 100644 ssg-rss/src/version.rs diff --git a/Cargo.lock b/Cargo.lock index 6bf9eba1..9c072a86 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3246,6 +3246,7 @@ dependencies = [ "pretty_assertions", "quick-xml 0.36.2", "serde", + "serde_json", "thiserror", "version_check", ] diff --git a/ssg-rss/Cargo.toml b/ssg-rss/Cargo.toml index d3351086..207e2b26 100644 --- a/ssg-rss/Cargo.toml +++ b/ssg-rss/Cargo.toml @@ -28,6 +28,7 @@ readme = "README.md" dtt = "0.0.8" quick-xml = "0.36" serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" thiserror = "1.0" [build-dependencies] diff --git a/ssg-rss/README.md b/ssg-rss/README.md index e69de29b..2a8e2e26 100644 --- a/ssg-rss/README.md +++ b/ssg-rss/README.md @@ -0,0 +1,111 @@ + + + + +# SSG RSS + +A Rust library for generating, serializing, and deserializing RSS feeds for various RSS versions. + +[![Made With Love][made-with-rust]][14] [![Crates.io][crates-badge]][08] [![lib.rs][libs-badge]][10] [![Docs.rs][docs-badge]][09] [![License][license-badge]][02] [![Build Status][build-badge]][16] + + +
+ + +• [Website][01] • [Documentation][09] • [Report Bug][04] • [Request Feature][04] • [Contributing Guidelines][05] + + +
+ + +## Overview + +`ssg-rss` is a Rust library for generating RSS feeds and serializing and deserializing RSS web content syndication formats. It supports the following RSS versions: RSS 0.90, RSS 0.91, RSS 0.92, RSS 1.0, and RSS 2.0. + +## Features + +- Generate RSS feeds for multiple RSS versions +- Serialize RSS data to XML format +- Deserialize XML content into RSS data structures +- Support for RSS 0.90, 0.91, 0.92, 1.0, and 2.0 +- Flexible API for creating and manipulating RSS feed data +- Comprehensive error handling + +## Installation + +Add this to your `Cargo.toml`: + +```toml +[dependencies] +ssg-rss = "0.1.0" +``` + +## Usage + +Here's a basic example of how to use the `ssg-rss` library: + +```rust +use ssg_rss::{RssData, generate_rss, RssVersion}; + +fn main() -> Result<(), Box> { + let rss_data = RssData::new() + .title("My Blog") + .link("https://example.com") + .description("A blog about Rust programming"); + + let rss_feed = generate_rss(&rss_data, RssVersion::RSS2_0)?; + println!("{}", rss_feed); + + Ok(()) +} +``` + +## Documentation + +For full API documentation, please visit [docs.rs/ssg-rss][09]. + +## Supported RSS Versions + +- RSS 0.90 +- RSS 0.91 +- RSS 0.92 +- RSS 1.0 +- RSS 2.0 + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +## License + +This project is licensed under either of + +- Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or [http://www.apache.org/licenses/LICENSE-2.0][02]) +- MIT license ([LICENSE-MIT](LICENSE-MIT) or [http://opensource.org/licenses/MIT][03]) + +at your option. + +## Acknowledgments + +This crate wouldn't be possible without the valuable open-source work of others, especially: + +- [quick-xml](https://crates.io/crates/quick-xml) for fast XML serialization and deserialization. + +[01]: https://ssg.com "SSG RSS Website" +[02]: https://opensource.org/license/apache-2-0/ "Apache License, Version 2.0" +[03]: https://opensource.org/licenses/MIT "MIT license" +[04]: https://github.com/sebastienrousseau/ssg-rss/issues "Issues" +[05]: https://github.com/sebastienrousseau/ssg-rss/blob/main/CONTRIBUTING.md "Contributing Guidelines" +[08]: https://crates.io/crates/ssg-rss "Crates.io" +[09]: https://docs.rs/ssg-rss "Docs.rs" +[10]: https://lib.rs/crates/ssg-rss "Lib.rs" +[14]: https://www.rust-lang.org "The Rust Programming Language" +[16]: https://github.com/sebastienrousseau/ssg-rss/actions?query=branch%3Amain "Build Status" + +[build-badge]: https://img.shields.io/github/actions/workflow/status/sebastienrousseau/ssg-rss/release.yml?branch=main&style=for-the-badge&logo=github "Build Status" +[crates-badge]: https://img.shields.io/crates/v/ssg-rss.svg?style=for-the-badge 'Crates.io badge' +[docs-badge]: https://img.shields.io/docsrs/ssg-rss.svg?style=for-the-badge 'Docs.rs badge' +[libs-badge]: https://img.shields.io/badge/lib.rs-v0.1.0-orange.svg?style=for-the-badge 'Lib.rs badge' +[license-badge]: https://img.shields.io/crates/l/ssg-rss.svg?style=for-the-badge 'License badge' +[made-with-rust]: https://img.shields.io/badge/rust-f04041?style=for-the-badge&labelColor=c0282d&logo=rust 'Made With Rust badge' diff --git a/ssg-rss/src/data.rs b/ssg-rss/src/data.rs index a1ce7540..a1c67dd7 100644 --- a/ssg-rss/src/data.rs +++ b/ssg-rss/src/data.rs @@ -1,6 +1,7 @@ // Copyright © 2024 Shokunin Static Site Generator. All rights reserved. // SPDX-License-Identifier: Apache-2.0 OR MIT +use crate::version::RssVersion; use dtt::datetime::DateTime; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -56,9 +57,11 @@ pub struct RssData { pub webmaster: String, /// A collection of additional items in the RSS feed. pub items: Vec, + /// The version of the RSS feed. + pub version: RssVersion, } -/// Represents an additional item in the RSS feed. +/// Represents an item in the RSS feed. #[derive( Debug, Default, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, )] @@ -80,7 +83,10 @@ pub struct RssItem { impl RssData { /// Creates a new `RssData` instance with default values. pub fn new() -> Self { - RssData::default() + RssData { + version: RssVersion::RSS2_0, + ..Default::default() + } } /// Sorts the RSS items by their publication date in descending order. @@ -129,7 +135,7 @@ impl RssData { self } - /// Adds an additional item to the RSS feed. + /// Adds an item to the RSS feed. pub fn add_item(&mut self, item: RssItem) { self.items.push(item); } @@ -143,12 +149,12 @@ impl RssData { self.items.len() < initial_len } - /// Returns the number of additional items in the RSS feed. + /// Returns the number of items in the RSS feed. pub fn item_count(&self) -> usize { self.items.len() } - /// Clears all additional items from the RSS feed. + /// Clears all items from the RSS feed. pub fn clear_items(&mut self) { self.items.clear(); } @@ -230,6 +236,14 @@ impl RssData { map } + // Field setter methods + + /// The `version` field setter method. + pub fn version(mut self, version: RssVersion) -> Self { + self.version = version; + self + } + /// The `atom_link` field setter method. pub fn atom_link>(self, value: T) -> Self { self.set("atom_link", value) @@ -318,16 +332,6 @@ impl RssData { impl RssItem { /// Creates a new `RssItem` with default values. - /// - /// # Examples - /// - /// ``` - /// use ssg_rss::data::RssItem; - /// - /// let item = RssItem::new(); - /// assert_eq!(item.title, ""); - /// assert_eq!(item.description, ""); - /// ``` pub fn new() -> Self { RssItem::default() } @@ -338,19 +342,6 @@ impl RssItem { /// /// * `key` - The field to set. /// * `value` - The value to assign to the field. - /// - /// # Examples - /// - /// ``` - /// use ssg_rss::data::RssItem; - /// - /// let item = RssItem::new() - /// .set("title", "New Item") - /// .set("description", "A new item in the feed"); - /// - /// assert_eq!(item.title, "New Item"); - /// assert_eq!(item.description, "A new item in the feed"); - /// ``` pub fn set>(mut self, key: &str, value: T) -> Self { let value = value.into(); match key { @@ -368,51 +359,7 @@ impl RssItem { self } - /// The `guid` field setter method. - pub fn guid>(self, value: T) -> Self { - self.set("guid", value) - } - - /// The `description` field setter method. - pub fn description>(self, value: T) -> Self { - self.set("description", value) - } - - /// The `link` field setter method. - pub fn link>(self, value: T) -> Self { - self.set("link", value) - } - - /// The `pub_date` field setter method. - pub fn pub_date>(self, value: T) -> Self { - self.set("pub_date", value) - } - - /// The `title` field setter method. - pub fn title>(self, value: T) -> Self { - self.set("title", value) - } - - /// The `author` field setter method. - pub fn author>(self, value: T) -> Self { - self.set("author", value) - } - /// Validates the `RssItem` to ensure all required fields are set and valid. - /// - /// # Examples - /// - /// ``` - /// use ssg_rss::data::RssItem; - /// - /// let item = RssItem::new() - /// .title("New Item") - /// .link("https://example.com/item") - /// .description("A new item") - /// .guid("unique-id"); - /// - /// assert!(item.validate().is_ok()); - /// ``` pub fn validate(&self) -> Result<(), Vec> { let mut errors = Vec::new(); @@ -438,6 +385,33 @@ impl RssItem { Err(errors) } } + + // Field setter methods + + /// The `guid` field setter method. + pub fn guid>(self, value: T) -> Self { + self.set("guid", value) + } + /// The `description` field setter method. + pub fn description>(self, value: T) -> Self { + self.set("description", value) + } + /// The `link` field setter method. + pub fn link>(self, value: T) -> Self { + self.set("link", value) + } + /// The `pub_date` field setter method. + pub fn pub_date>(self, value: T) -> Self { + self.set("pub_date", value) + } + /// The `title` field setter method. + pub fn title>(self, value: T) -> Self { + self.set("title", value) + } + /// The `author` field setter method. + pub fn author>(self, value: T) -> Self { + self.set("author", value) + } } #[cfg(test)] @@ -450,16 +424,12 @@ mod tests { .title("Test RSS Feed") .link("https://example.com") .description("A test RSS feed") - .item_title("Test Item") - .item_link("https://example.com/item") - .item_description("A test item"); + .version(RssVersion::RSS2_0); assert_eq!(rss_data.title, "Test RSS Feed"); assert_eq!(rss_data.link, "https://example.com"); assert_eq!(rss_data.description, "A test RSS feed"); - assert_eq!(rss_data.item_title, "Test Item"); - assert_eq!(rss_data.item_link, "https://example.com/item"); - assert_eq!(rss_data.item_description, "A test item"); + assert_eq!(rss_data.version, RssVersion::RSS2_0); } #[test] @@ -467,10 +437,7 @@ mod tests { let valid_rss_data = RssData::new() .title("Test RSS Feed") .link("https://example.com") - .description("A test RSS feed") - .item_title("Test Item") - .item_link("https://example.com/item") - .item_description("A test item"); + .description("A test RSS feed"); assert!(valid_rss_data.validate().is_ok()); @@ -486,216 +453,23 @@ mod tests { let mut rss_data = RssData::new() .title("Test RSS Feed") .link("https://example.com") - .description("A test RSS feed") - .item_title("Main Item") - .item_link("https://example.com/main-item") - .item_description("The main item in the feed"); - - let additional_item = RssItem::new() - .title("Additional Item") - .link("https://example.com/additional-item") - .description("An additional item in the feed") - .guid("unique-id-1") - .pub_date("2024-03-21"); - - rss_data.add_item(additional_item); - - assert_eq!(rss_data.items.len(), 1); - assert_eq!(rss_data.items[0].title, "Additional Item"); - assert_eq!( - rss_data.items[0].link, - "https://example.com/additional-item" - ); - assert_eq!( - rss_data.items[0].description, - "An additional item in the feed" - ); - assert_eq!(rss_data.items[0].guid, "unique-id-1"); - assert_eq!(rss_data.items[0].pub_date, "2024-03-21"); - } - - #[test] - fn test_to_hash_map() { - let rss_data = RssData::new() - .title("Test RSS Feed") - .link("https://example.com") - .description("A test RSS feed") - .item_title("Test Item") - .item_link("https://example.com/item") - .item_description("A test item") - .language("en-US") - .pub_date("2024-03-21") - .last_build_date("2024-03-21") - .ttl("60"); - - let hash_map = rss_data.to_hash_map(); - - assert_eq!( - hash_map.get("title"), - Some(&"Test RSS Feed".to_string()) - ); - assert_eq!( - hash_map.get("link"), - Some(&"https://example.com".to_string()) - ); - assert_eq!( - hash_map.get("description"), - Some(&"A test RSS feed".to_string()) - ); - assert_eq!( - hash_map.get("item_title"), - Some(&"Test Item".to_string()) - ); - assert_eq!( - hash_map.get("item_link"), - Some(&"https://example.com/item".to_string()) - ); - assert_eq!( - hash_map.get("item_description"), - Some(&"A test item".to_string()) - ); - assert_eq!( - hash_map.get("language"), - Some(&"en-US".to_string()) - ); - assert_eq!( - hash_map.get("pub_date"), - Some(&"2024-03-21".to_string()) - ); - assert_eq!( - hash_map.get("last_build_date"), - Some(&"2024-03-21".to_string()) - ); - assert_eq!(hash_map.get("ttl"), Some(&"60".to_string())); - } + .description("A test RSS feed"); - #[test] - fn test_rss_item() { let item = RssItem::new() .title("Test Item") .link("https://example.com/item") .description("A test item") - .guid("unique-id") - .pub_date("2024-03-21"); - - assert_eq!(item.title, "Test Item"); - assert_eq!(item.link, "https://example.com/item"); - assert_eq!(item.description, "A test item"); - assert_eq!(item.guid, "unique-id"); - assert_eq!(item.pub_date, "2024-03-21"); - } - - #[test] - fn test_rss_data_all_fields() { - let rss_data = RssData::new() - .atom_link("https://example.com/feed.atom") - .author("John Doe") - .category("Technology") - .copyright("© 2024 Example Inc.") - .description("A comprehensive RSS feed") - .docs("https://example.com/rss-docs") - .generator("Example RSS Generator") - .image("https://example.com/logo.png") - .item_guid("unique-item-id") - .item_description("The main item description") - .item_link("https://example.com/main-item") - .item_pub_date("2024-03-21T12:00:00Z") - .item_title("Main RSS Item") - .language("en-US") - .last_build_date("2024-03-21T12:00:00Z") - .link("https://example.com") - .managing_editor("editor@example.com") - .pub_date("2024-03-21T12:00:00Z") - .title("Example RSS Feed") - .ttl("60") - .webmaster("webmaster@example.com"); - - assert_eq!(rss_data.atom_link, "https://example.com/feed.atom"); - assert_eq!(rss_data.author, "John Doe"); - assert_eq!(rss_data.category, "Technology"); - assert_eq!(rss_data.copyright, "© 2024 Example Inc."); - assert_eq!(rss_data.description, "A comprehensive RSS feed"); - assert_eq!(rss_data.docs, "https://example.com/rss-docs"); - assert_eq!(rss_data.generator, "Example RSS Generator"); - assert_eq!(rss_data.image, "https://example.com/logo.png"); - assert_eq!(rss_data.item_guid, "unique-item-id"); - assert_eq!( - rss_data.item_description, - "The main item description" - ); - assert_eq!(rss_data.item_link, "https://example.com/main-item"); - assert_eq!(rss_data.item_pub_date, "2024-03-21T12:00:00Z"); - assert_eq!(rss_data.item_title, "Main RSS Item"); - assert_eq!(rss_data.language, "en-US"); - assert_eq!(rss_data.last_build_date, "2024-03-21T12:00:00Z"); - assert_eq!(rss_data.link, "https://example.com"); - assert_eq!(rss_data.managing_editor, "editor@example.com"); - assert_eq!(rss_data.pub_date, "2024-03-21T12:00:00Z"); - assert_eq!(rss_data.title, "Example RSS Feed"); - assert_eq!(rss_data.ttl, "60"); - assert_eq!(rss_data.webmaster, "webmaster@example.com"); - } - - #[test] - fn test_invalid_field_set() { - let rss_data = - RssData::new().set("invalid_field", "Some value"); - assert_eq!(rss_data, RssData::default()); - - let rss_item = - RssItem::new().set("invalid_field", "Some value"); - assert_eq!(rss_item, RssItem::default()); - } - - #[test] - fn test_multiple_additional_items() { - let mut rss_data = RssData::new() - .title("Test RSS Feed") - .link("https://example.com") - .description("A test RSS feed") - .item_title("Main Item") - .item_link("https://example.com/main-item") - .item_description("The main item in the feed"); - - let additional_item1 = RssItem::new() - .title("Additional Item 1") - .link("https://example.com/additional-item-1") - .description("The first additional item") .guid("unique-id-1") .pub_date("2024-03-21"); - let additional_item2 = RssItem::new() - .title("Additional Item 2") - .link("https://example.com/additional-item-2") - .description("The second additional item") - .guid("unique-id-2") - .pub_date("2024-03-22"); - - rss_data.add_item(additional_item1); - rss_data.add_item(additional_item2); - - assert_eq!(rss_data.items.len(), 2); - assert_eq!(rss_data.items[0].title, "Additional Item 1"); - assert_eq!(rss_data.items[1].title, "Additional Item 2"); - } - - #[test] - fn test_rss_data_default() { - let default_rss_data = RssData::default(); - assert_eq!(default_rss_data.title, ""); - assert_eq!(default_rss_data.link, ""); - assert_eq!(default_rss_data.description, ""); - assert!(default_rss_data.items.is_empty()); - } + rss_data.add_item(item); - #[test] - fn test_rss_item_default() { - let default_rss_item = RssItem::default(); - assert_eq!(default_rss_item.title, ""); - assert_eq!(default_rss_item.link, ""); - assert_eq!(default_rss_item.description, ""); - assert_eq!(default_rss_item.guid, ""); - assert_eq!(default_rss_item.pub_date, ""); + assert_eq!(rss_data.items.len(), 1); + assert_eq!(rss_data.items[0].title, "Test Item"); + assert_eq!(rss_data.items[0].link, "https://example.com/item"); + assert_eq!(rss_data.items[0].description, "A test item"); + assert_eq!(rss_data.items[0].guid, "unique-id-1"); + assert_eq!(rss_data.items[0].pub_date, "2024-03-21"); } #[test] @@ -794,7 +568,6 @@ mod tests { assert!(errors[0].contains("Errors in item 1")); // The second item (index 1) is invalid } - // Additional test for sorting items by publication date #[test] fn test_sort_items_by_pub_date() { let mut rss_data = RssData::new() @@ -833,4 +606,74 @@ mod tests { assert_eq!(rss_data.items[1].title, "Item 3"); assert_eq!(rss_data.items[2].title, "Item 1"); } + + #[test] + fn test_to_hash_map() { + let rss_data = RssData::new() + .title("Test RSS Feed") + .link("https://example.com") + .description("A test RSS feed") + .language("en-US") + .pub_date("2024-03-21") + .last_build_date("2024-03-21") + .ttl("60"); + + let hash_map = rss_data.to_hash_map(); + + assert_eq!( + hash_map.get("title"), + Some(&"Test RSS Feed".to_string()) + ); + assert_eq!( + hash_map.get("link"), + Some(&"https://example.com".to_string()) + ); + assert_eq!( + hash_map.get("description"), + Some(&"A test RSS feed".to_string()) + ); + assert_eq!( + hash_map.get("language"), + Some(&"en-US".to_string()) + ); + assert_eq!( + hash_map.get("pub_date"), + Some(&"2024-03-21".to_string()) + ); + assert_eq!( + hash_map.get("last_build_date"), + Some(&"2024-03-21".to_string()) + ); + assert_eq!(hash_map.get("ttl"), Some(&"60".to_string())); + } + + #[test] + fn test_rss_data_version() { + let rss_data = RssData::new().version(RssVersion::RSS1_0); + assert_eq!(rss_data.version, RssVersion::RSS1_0); + } + + #[test] + fn test_rss_data_default_version() { + let rss_data = RssData::new(); + assert_eq!(rss_data.version, RssVersion::RSS2_0); + } + + #[test] + fn test_rss_item_new_and_set() { + let item = RssItem::new() + .title("Test Item") + .link("https://example.com/item") + .description("A test item") + .guid("unique-id") + .pub_date("2024-03-21") + .author("John Doe"); + + assert_eq!(item.title, "Test Item"); + assert_eq!(item.link, "https://example.com/item"); + assert_eq!(item.description, "A test item"); + assert_eq!(item.guid, "unique-id"); + assert_eq!(item.pub_date, "2024-03-21"); + assert_eq!(item.author, "John Doe"); + } } diff --git a/ssg-rss/src/error.rs b/ssg-rss/src/error.rs index 3ccedbca..7165758b 100644 --- a/ssg-rss/src/error.rs +++ b/ssg-rss/src/error.rs @@ -27,6 +27,12 @@ pub enum RssError { /// Error for invalid input data. InvalidInput, + + /// Error parsing XML content. + XmlParseError(quick_xml::Error), + + /// Error for unknown XML elements. + UnknownElement(String), } /// Custom implementation to avoid leaking sensitive information in error messages @@ -46,6 +52,12 @@ impl fmt::Display for RssError { RssError::InvalidInput => { write!(f, "Invalid input data provided") } + RssError::XmlParseError(_) => { + write!(f, "XML parsing error occurred") + } + RssError::UnknownElement(_) => { + write!(f, "Unknown XML element found") + } } } } @@ -57,6 +69,8 @@ impl Error for RssError { RssError::Utf8Error(e) => Some(e), RssError::IoError(_) => None, RssError::MissingField(_) | RssError::InvalidInput => None, + RssError::XmlParseError(e) => Some(e), + RssError::UnknownElement(_) => None, } } } @@ -73,6 +87,12 @@ impl Clone for RssError { } RssError::IoError(s) => RssError::IoError(s.clone()), RssError::InvalidInput => RssError::InvalidInput, + RssError::XmlParseError(e) => { + RssError::XmlParseError(e.clone()) + } + RssError::UnknownElement(s) => { + RssError::UnknownElement(s.clone()) + } } } } diff --git a/ssg-rss/src/lib.rs b/ssg-rss/src/lib.rs index 7b8f7119..dd494a1a 100644 --- a/ssg-rss/src/lib.rs +++ b/ssg-rss/src/lib.rs @@ -3,12 +3,15 @@ //! # ssg-rss //! -//! `ssg-rss` is a Rust library for generating RSS feeds for the Shokunin Static Site Generator. -//! It provides functionality to create RSS 2.0 feeds with support for various RSS elements and attributes. +//! `ssg-rss` is a Rust library for generating, serializing, and deserializing RSS feeds. +//! It supports multiple RSS versions and provides functionality to create and parse +//! RSS feeds with various elements and attributes. //! //! ## Features //! -//! - Generate RSS 2.0 feeds +//! - Generate RSS feeds for versions 0.90, 0.91, 0.92, 1.0, and 2.0 +//! - Serialize RSS data to XML format +//! - Deserialize XML content into RSS data structures //! - Support for standard RSS elements (title, link, description, etc.) //! - Support for optional elements (language, pubDate, category, etc.) //! - Atom link support @@ -17,7 +20,7 @@ //! ## Usage //! //! ```rust -//! use ssg_rss::{RssData, generate_rss}; +//! use ssg_rss::{RssData, RssVersion, generate_rss, parse_rss}; //! //! let rss_data = RssData::new() //! .title("My Blog") @@ -25,7 +28,15 @@ //! .description("A blog about Rust programming"); //! //! match generate_rss(&rss_data) { -//! Ok(rss_feed) => println!("{}", rss_feed), +//! Ok(rss_feed) => { +//! println!("Generated RSS feed: {}", rss_feed); +//! +//! // Parse the generated RSS feed +//! match parse_rss(&rss_feed) { +//! Ok(parsed_data) => println!("Parsed RSS data: {:?}", parsed_data), +//! Err(e) => eprintln!("Error parsing RSS: {}", e), +//! } +//! }, //! Err(e) => eprintln!("Error generating RSS: {}", e), //! } //! ``` @@ -38,10 +49,16 @@ pub mod error; pub mod generator; /// The `macros` module contains procedural macros used by the library. pub mod macros; +/// The `parser` module contains the logic for parsing RSS feeds. +pub mod parser; +/// The `version` module contains definitions for different RSS versions. +pub mod version; pub use data::RssData; pub use error::RssError; pub use generator::generate_rss; +pub use parser::parse_rss; +pub use version::RssVersion; // Re-export main types for ease of use pub use error::Result; diff --git a/ssg-rss/src/macros.rs b/ssg-rss/src/macros.rs index 5fe70de4..72d3cc16 100644 --- a/ssg-rss/src/macros.rs +++ b/ssg-rss/src/macros.rs @@ -23,7 +23,7 @@ /// /// # Example /// -/// ``` +/// ```text /// use ssg_rss::{RssData, macro_generate_rss, macro_write_element}; /// use quick_xml::Writer; /// use std::io::Cursor; @@ -39,8 +39,7 @@ /// assert!(result.is_ok()); /// Ok(()) /// } -/// ``` -/// +/// ```text /// generate_rss().unwrap(); /// ``` #[macro_export] diff --git a/ssg-rss/src/parser.rs b/ssg-rss/src/parser.rs new file mode 100644 index 00000000..96340509 --- /dev/null +++ b/ssg-rss/src/parser.rs @@ -0,0 +1,270 @@ +// Copyright © 2024 Shokunin Static Site Generator. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use crate::data::{RssData, RssItem}; +use crate::error::{Result, RssError}; +use crate::version::RssVersion; +use quick_xml::events::Event; +use quick_xml::name::QName; +use quick_xml::Reader; +use std::str::FromStr; + +/// Parses an RSS feed from XML content. +/// +/// This function takes XML content as input and parses it into an `RssData` struct. +/// It supports parsing RSS versions 0.90, 0.91, 0.92, 1.0, and 2.0. +/// +/// # Arguments +/// +/// * `content` - A string slice containing the XML content of the RSS feed. +/// +/// # Returns +/// +/// * `Ok(RssData)` - The parsed RSS data if successful. +/// * `Err(RssError)` - An error if parsing fails. +/// +pub fn parse_rss(content: &str) -> Result { + let mut reader = Reader::from_str(content); + + let mut rss_data = RssData::new(); + let mut buf: Vec = Vec::new(); + let mut in_channel = false; + let mut in_item = false; + let mut current_item = RssItem::new(); + let mut current_element = String::new(); + + loop { + match reader.read_event() { + Ok(Event::Start(ref e)) => { + let name = e.name(); + if name == QName(b"rss") { + if let Some(version_attr) = + e.attributes().find(|attr| { + attr.as_ref().unwrap().key + == QName(b"version") + }) + { + // Store the value in a variable with a longer lifetime + let version_value = + version_attr.unwrap().value.clone(); + + // Handling UTF-8 error when parsing the version attribute + let version_str = + std::str::from_utf8(&version_value) + .map_err(|_e| RssError::InvalidInput)?; // Corrected the error handling + + // Handling version string parsing errors + rss_data.version = + RssVersion::from_str(version_str) + .map_err(|_e| RssError::InvalidInput)?; // Using InvalidInput to handle version parse errors + } + } else if name == QName(b"channel") { + in_channel = true; + } else if name == QName(b"item") { + in_item = true; + current_item = RssItem::new(); + } + current_element = + String::from_utf8_lossy(name.as_ref()).into_owned(); + } + Ok(Event::End(ref e)) => { + let name = e.name(); + if name == QName(b"channel") { + in_channel = false; + } else if name == QName(b"item") { + in_item = false; + rss_data.add_item(current_item.clone()); + } + current_element.clear(); + } + Ok(Event::Text(e)) => { + let text = e + .unescape() + .map_err(RssError::XmlParseError)? + .into_owned(); + if in_channel && !in_item { + parse_channel_element( + &mut rss_data, + ¤t_element, + &text, + )?; + } else if in_item { + parse_item_element( + &mut current_item, + ¤t_element, + &text, + )?; + } + } + Ok(Event::Eof) => break, + Err(e) => return Err(RssError::XmlParseError(e)), + _ => {} + } + buf.clear(); + } + + Ok(rss_data) +} + +fn parse_channel_element( + rss_data: &mut RssData, + element: &str, + text: &str, +) -> Result<()> { + match element { + "title" => rss_data.title = text.to_string(), + "link" => rss_data.link = text.to_string(), + "description" => rss_data.description = text.to_string(), + "language" => rss_data.language = text.to_string(), + "copyright" => rss_data.copyright = text.to_string(), + "managingEditor" => rss_data.managing_editor = text.to_string(), + "webMaster" => rss_data.webmaster = text.to_string(), + "pubDate" => rss_data.pub_date = text.to_string(), + "lastBuildDate" => rss_data.last_build_date = text.to_string(), + "category" => rss_data.category = text.to_string(), + "generator" => rss_data.generator = text.to_string(), + "docs" => rss_data.docs = text.to_string(), + "ttl" => rss_data.ttl = text.to_string(), + _ => { + // return Err(RssError::UnknownElement(format!( + // "Unknown channel element: {}", + // element + // ))) + } + } + Ok(()) +} + +fn parse_item_element( + item: &mut RssItem, + element: &str, + text: &str, +) -> Result<()> { + match element { + "title" => item.title = text.to_string(), + "link" => item.link = text.to_string(), + "description" => item.description = text.to_string(), + "author" => item.author = text.to_string(), + "guid" => item.guid = text.to_string(), + "pubDate" => item.pub_date = text.to_string(), + _ => { + // return Err(RssError::UnknownElement(format!( + // "Unknown item element: {}", + // element + // ))) + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_rss_2_0() { + let xml_content = r#" + + + + My RSS Feed + https://example.com + A sample RSS feed + en-us + + First Post + https://example.com/first-post + This is my first post + https://example.com/first-post + Mon, 01 Jan 2023 00:00:00 GMT + + + + "#; + + let rss_data = parse_rss(xml_content).unwrap(); + assert_eq!(rss_data.version, RssVersion::RSS2_0); + assert_eq!(rss_data.title, "My RSS Feed"); + assert_eq!(rss_data.link, "https://example.com"); + assert_eq!(rss_data.description, "A sample RSS feed"); + assert_eq!(rss_data.language, "en-us"); + assert_eq!(rss_data.items.len(), 1); + + let item = &rss_data.items[0]; + assert_eq!(item.title, "First Post"); + assert_eq!(item.link, "https://example.com/first-post"); + assert_eq!(item.description, "This is my first post"); + assert_eq!(item.guid, "https://example.com/first-post"); + assert_eq!(item.pub_date, "Mon, 01 Jan 2023 00:00:00 GMT"); + } + + #[test] + fn test_parse_rss_1_0() { + let xml_content = r#" + + + + My RSS 1.0 Feed + https://example.com + A sample RSS 1.0 feed + + + + + + + + First Post + https://example.com/first-post + This is my first post in RSS 1.0 + + + "#; + + let rss_data = parse_rss(xml_content).unwrap(); + assert_eq!(rss_data.title, "My RSS 1.0 Feed"); + assert_eq!(rss_data.link, "https://example.com"); + assert_eq!(rss_data.description, "A sample RSS 1.0 feed"); + assert_eq!(rss_data.items.len(), 1); + + let item = &rss_data.items[0]; + assert_eq!(item.title, "First Post"); + assert_eq!(item.link, "https://example.com/first-post"); + assert_eq!( + item.description, + "This is my first post in RSS 1.0" + ); + } + + #[test] + fn test_parse_invalid_xml() { + let invalid_xml = r#" + + + + Invalid Feed + https://example.com + This XML is invalid + + + "#; + + assert!(parse_rss(invalid_xml).is_err()); + } + + #[test] + fn test_parse_unknown_version() { + let unknown_version_xml = r#" + + + + Unknown Version Feed + https://example.com + This feed has an unknown version + + + "#; + + assert!(parse_rss(unknown_version_xml).is_err()); + } +} diff --git a/ssg-rss/src/version.rs b/ssg-rss/src/version.rs new file mode 100644 index 00000000..2bcd0d45 --- /dev/null +++ b/ssg-rss/src/version.rs @@ -0,0 +1,124 @@ +// Copyright © 2024 Shokunin Static Site Generator. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::str::FromStr; + +/// Represents the different versions of RSS. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, +)] +pub enum RssVersion { + /// RSS version 0.90 + RSS0_90, + /// RSS version 0.91 + RSS0_91, + /// RSS version 0.92 + RSS0_92, + /// RSS version 1.0 + RSS1_0, + /// RSS version 2.0 + RSS2_0, +} + +impl RssVersion { + /// Returns the string representation of the RSS version. + pub fn as_str(&self) -> &'static str { + match self { + RssVersion::RSS0_90 => "0.90", + RssVersion::RSS0_91 => "0.91", + RssVersion::RSS0_92 => "0.92", + RssVersion::RSS1_0 => "1.0", + RssVersion::RSS2_0 => "2.0", + } + } +} + +impl Default for RssVersion { + fn default() -> Self { + RssVersion::RSS2_0 + } +} + +impl fmt::Display for RssVersion { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl FromStr for RssVersion { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "0.90" => Ok(RssVersion::RSS0_90), + "0.91" => Ok(RssVersion::RSS0_91), + "0.92" => Ok(RssVersion::RSS0_92), + "1.0" => Ok(RssVersion::RSS1_0), + "2.0" => Ok(RssVersion::RSS2_0), + _ => Err(format!("Invalid RSS version: {}", s)), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_rss_version_as_str() { + assert_eq!(RssVersion::RSS0_90.as_str(), "0.90"); + assert_eq!(RssVersion::RSS0_91.as_str(), "0.91"); + assert_eq!(RssVersion::RSS0_92.as_str(), "0.92"); + assert_eq!(RssVersion::RSS1_0.as_str(), "1.0"); + assert_eq!(RssVersion::RSS2_0.as_str(), "2.0"); + } + + #[test] + fn test_rss_version_default() { + assert_eq!(RssVersion::default(), RssVersion::RSS2_0); + } + + #[test] + fn test_rss_version_display() { + assert_eq!(format!("{}", RssVersion::RSS0_90), "0.90"); + assert_eq!(format!("{}", RssVersion::RSS0_91), "0.91"); + assert_eq!(format!("{}", RssVersion::RSS0_92), "0.92"); + assert_eq!(format!("{}", RssVersion::RSS1_0), "1.0"); + assert_eq!(format!("{}", RssVersion::RSS2_0), "2.0"); + } + + #[test] + fn test_rss_version_from_str() { + assert_eq!( + "0.90".parse::(), + Ok(RssVersion::RSS0_90) + ); + assert_eq!( + "0.91".parse::(), + Ok(RssVersion::RSS0_91) + ); + assert_eq!( + "0.92".parse::(), + Ok(RssVersion::RSS0_92) + ); + assert_eq!("1.0".parse::(), Ok(RssVersion::RSS1_0)); + assert_eq!("2.0".parse::(), Ok(RssVersion::RSS2_0)); + assert!("3.0".parse::().is_err()); + } + + #[test] + fn test_rss_version_serialization() { + let version = RssVersion::RSS2_0; + let serialized = serde_json::to_string(&version).unwrap(); + assert_eq!(serialized, "\"RSS2_0\""); + } + + #[test] + fn test_rss_version_deserialization() { + let deserialized: RssVersion = + serde_json::from_str("\"RSS1_0\"").unwrap(); + assert_eq!(deserialized, RssVersion::RSS1_0); + } +}