rss_gen/
lib.rs

1// Copyright © 2024 RSS Gen. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4// src/lib.rs
5
6#![doc = include_str!("../README.md")]
7#![doc(
8    html_favicon_url = "https://kura.pro/rssgen/images/favicon.ico",
9    html_logo_url = "https://kura.pro/rssgen/images/logos/rssgen.svg",
10    html_root_url = "https://docs.rs/rss-gen"
11)]
12#![crate_name = "rss_gen"]
13#![crate_type = "lib"]
14#![warn(missing_docs)]
15#![forbid(unsafe_code)]
16#![deny(clippy::all)]
17#![deny(clippy::cargo)]
18#![deny(clippy::pedantic)]
19#![allow(clippy::module_name_repetitions)]
20
21/// Contains the main types and data structures used to represent RSS feeds.
22pub mod data;
23/// Defines error types used throughout the library.
24pub mod error;
25/// Implements RSS feed generation functionality.
26pub mod generator;
27/// Provides procedural macros for simplifying RSS operations.
28pub mod macros;
29/// Implements RSS feed parsing functionality.
30pub mod parser;
31/// Provides utilities for validating RSS feeds.
32pub mod validator;
33
34pub use data::{RssData, RssItem, RssVersion};
35pub use error::{Result, RssError};
36pub use generator::generate_rss;
37pub use parser::parse_rss;
38
39/// The current version of the rss-gen crate, set at compile-time from Cargo.toml.
40pub const VERSION: &str = env!("CARGO_PKG_VERSION");
41
42/// Maximum length for title fields in the RSS feed.
43pub const MAX_TITLE_LENGTH: usize = 256;
44/// Maximum length for link fields in the RSS feed.
45pub const MAX_LINK_LENGTH: usize = 2048;
46/// Maximum length for description fields in the RSS feed.
47pub const MAX_DESCRIPTION_LENGTH: usize = 100_000;
48/// Maximum length for general fields in the RSS feed.
49pub const MAX_GENERAL_LENGTH: usize = 1024;
50/// Maximum size for the entire RSS feed.
51pub const MAX_FEED_SIZE: usize = 1_048_576; // 1 MB
52
53/// A convenience function to generate a minimal valid RSS 2.0 feed.
54///
55/// This function creates an RSS 2.0 feed with the provided title, link, and description,
56/// and includes one example item.
57///
58/// # Arguments
59///
60/// * `title` - The title of the RSS feed.
61/// * `link` - The link to the website associated with the RSS feed.
62/// * `description` - A brief description of the RSS feed.
63///
64/// # Returns
65///
66/// A `Result` containing the generated RSS feed as a `String` if successful,
67/// or an `RssError` if generation fails.
68///
69/// # Examples
70///
71/// ```rust
72/// use rss_gen::quick_rss;
73///
74/// let rss = quick_rss(
75///     "My Rust Blog",
76///     "https://myrustblog.com",
77///     "A blog about Rust programming"
78/// );
79///
80/// match rss {
81///     Ok(feed) => println!("Generated RSS feed: {}", feed),
82///     Err(e) => eprintln!("Error: {}", e),
83/// }
84/// ```
85///
86/// # Errors
87///
88/// This function will return an error if:
89/// - Any of the input strings are empty
90/// - Any of the input strings exceed their respective maximum lengths
91/// - The `link` is not a valid URL starting with "http://" or "https://"
92/// - RSS generation fails for any reason
93///
94/// # Security
95///
96/// This function performs basic input validation, but it's recommended to sanitize
97/// the input parameters before passing them to this function, especially if they
98/// come from untrusted sources.
99#[must_use = "This function returns a Result that should be handled"]
100pub fn quick_rss(
101    title: &str,
102    link: &str,
103    description: &str,
104) -> Result<String> {
105    // Validate input
106    if title.is_empty() || link.is_empty() || description.is_empty() {
107        return Err(RssError::InvalidInput(
108            "Title, link, and description must not be empty"
109                .to_string(),
110        ));
111    }
112
113    if title.len() > MAX_TITLE_LENGTH
114        || link.len() > MAX_LINK_LENGTH
115        || description.len() > MAX_DESCRIPTION_LENGTH
116    {
117        return Err(RssError::InvalidInput(
118            "Input exceeds maximum allowed length".to_string(),
119        ));
120    }
121
122    // Basic URL validation
123    if !link.starts_with("http://") && !link.starts_with("https://") {
124        return Err(RssError::InvalidInput(
125            "Link must start with http:// or https://".to_string(),
126        ));
127    }
128
129    let mut rss_data = RssData::new(Some(RssVersion::RSS2_0))
130        .title(title)
131        .link(link)
132        .description(description);
133
134    // Add an example item
135    rss_data.add_item(
136        RssItem::new()
137            .title("Example Item")
138            .link(format!("{}/example-item", link))
139            .description("This is an example item in the RSS feed")
140            .guid(format!("{}/example-item", link)),
141    );
142
143    generate_rss(&rss_data)
144}
145
146/// Prelude module for convenient importing of common types and functions.
147pub mod prelude {
148    pub use crate::data::{RssData, RssItem, RssVersion};
149    pub use crate::error::{Result, RssError};
150    pub use crate::generate_rss;
151    pub use crate::parse_rss;
152    pub use crate::quick_rss;
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn test_quick_rss() {
161        let result = quick_rss(
162            "Test Feed",
163            "https://example.com",
164            "A test RSS feed",
165        );
166        assert!(result.is_ok());
167        let feed = result.unwrap();
168        assert!(feed.contains("<title>Test Feed</title>"));
169        assert!(feed.contains("<link>https://example.com</link>"));
170        assert!(
171            feed.contains("<description>A test RSS feed</description>")
172        );
173        assert!(feed.contains("<item>"));
174        assert!(feed.contains("<title>Example Item</title>"));
175        assert!(feed
176            .contains("<link>https://example.com/example-item</link>"));
177        assert!(feed.contains("<description>This is an example item in the RSS feed</description>"));
178    }
179
180    #[test]
181    fn test_quick_rss_invalid_input() {
182        let result =
183            quick_rss("", "https://example.com", "Description");
184        assert!(result.is_err());
185        assert!(matches!(result, Err(RssError::InvalidInput(_))));
186
187        let result = quick_rss("Title", "not-a-url", "Description");
188        assert!(result.is_err());
189        assert!(matches!(result, Err(RssError::InvalidInput(_))));
190    }
191
192    #[test]
193    fn test_version_constant() {
194        assert!(VERSION.starts_with(char::is_numeric));
195        assert!(VERSION.split('.').count() >= 2);
196    }
197
198    #[test]
199    fn test_quick_rss_max_title_length() {
200        let long_title = "a".repeat(MAX_TITLE_LENGTH + 1);
201        let result = quick_rss(
202            &long_title,
203            "https://example.com",
204            "Description",
205        );
206        assert!(result.is_err());
207        assert!(matches!(result, Err(RssError::InvalidInput(_))));
208
209        let max_title = "a".repeat(MAX_TITLE_LENGTH);
210        let result =
211            quick_rss(&max_title, "https://example.com", "Description");
212        assert!(result.is_ok());
213    }
214
215    #[test]
216    fn test_quick_rss_max_link_length() {
217        let long_link = format!(
218            "https://example.com/{}",
219            "a".repeat(MAX_LINK_LENGTH - 19)
220        );
221        let result = quick_rss("Title", &long_link, "Description");
222        assert!(result.is_err());
223        assert!(matches!(result, Err(RssError::InvalidInput(_))));
224
225        let max_link = format!(
226            "https://example.com/{}",
227            "a".repeat(MAX_LINK_LENGTH - 20)
228        );
229        let result = quick_rss("Title", &max_link, "Description");
230        assert!(result.is_ok());
231    }
232
233    #[test]
234    fn test_quick_rss_max_description_length() {
235        let long_description = "a".repeat(MAX_DESCRIPTION_LENGTH + 1);
236        let result = quick_rss(
237            "Title",
238            "https://example.com",
239            &long_description,
240        );
241        assert!(result.is_err());
242        assert!(matches!(result, Err(RssError::InvalidInput(_))));
243
244        let max_description = "a".repeat(MAX_DESCRIPTION_LENGTH);
245        let result =
246            quick_rss("Title", "https://example.com", &max_description);
247        assert!(result.is_ok());
248    }
249
250    #[test]
251    fn test_quick_rss_https() {
252        let result = quick_rss(
253            "Test Feed",
254            "https://example.com",
255            "A test RSS feed",
256        );
257        assert!(result.is_ok());
258    }
259
260    #[test]
261    fn test_quick_rss_http() {
262        let result = quick_rss(
263            "Test Feed",
264            "http://example.com",
265            "A test RSS feed",
266        );
267        assert!(result.is_ok());
268    }
269
270    // Note: The following tests depend on the implementation of RssData and its methods,
271    // which are not shown in the provided code. You may need to adjust these tests
272    // based on your actual implementation.
273
274    #[test]
275    fn test_rss_data_validate_size() {
276        let mut rss_data = RssData::new(Some(RssVersion::RSS2_0))
277            .title("Test Feed")
278            .link("https://example.com")
279            .description("A test RSS feed");
280
281        // Add items until we exceed MAX_FEED_SIZE
282        let item_content = "a".repeat(10000);
283        for _ in 0..100 {
284            rss_data.add_item(
285                RssItem::new()
286                    .title(&item_content)
287                    .link("https://example.com/item")
288                    .description(&item_content),
289            );
290        }
291
292        assert!(rss_data.validate_size().is_err());
293    }
294
295    #[test]
296    fn test_max_general_length() {
297        let mut rss_data = RssData::new(Some(RssVersion::RSS2_0))
298            .title("Test Feed")
299            .link("https://example.com")
300            .description("A test RSS feed");
301
302        let long_general_field = "a".repeat(MAX_GENERAL_LENGTH + 1);
303        rss_data.category.clone_from(&long_general_field);
304
305        assert!(rss_data.validate().is_err());
306
307        rss_data.category = "a".repeat(MAX_GENERAL_LENGTH);
308        assert!(rss_data.validate().is_ok());
309    }
310}