feedparser_rs/lib.rs
1#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used, clippy::panic))]
2// Regression guard for issue #456/#460: parser helpers were refactored to keep
3// every function under clippy's default 7-argument threshold via XmlCtx/EntryCtx/*Ctx
4// context structs. `forbid` (not `deny`) so a future `#[allow(clippy::too_many_arguments)]`
5// can't silently opt back out. A crate-level attribute (not a Cargo.toml lints table
6// entry) so `[lints] workspace = true` keeps inheriting the full workspace rust+clippy
7// lint sets unmodified — this crate has no proc-macro-generated `#[allow(clippy::all)]`
8// (unlike feedparser-rs-node's `#[napi]`), so `forbid` is safe here.
9#![forbid(clippy::too_many_arguments)]
10
11//! # feedparser-rs: High-performance RSS/Atom/JSON Feed parser
12//!
13//! A pure Rust implementation of feed parsing with API compatibility for Python's
14//! [feedparser](https://github.com/kurtmckee/feedparser) library. Designed for
15//! 10-100x faster feed parsing with identical behavior.
16//!
17//! ## Quick Start
18//!
19//! ```
20//! use feedparser_rs::parse;
21//!
22//! let xml = r#"
23//! <?xml version="1.0"?>
24//! <rss version="2.0">
25//! <channel>
26//! <title>Example Feed</title>
27//! <link>https://example.com</link>
28//! <item>
29//! <title>First Post</title>
30//! <link>https://example.com/post/1</link>
31//! </item>
32//! </channel>
33//! </rss>
34//! "#;
35//!
36//! let feed = parse(xml.as_bytes()).unwrap();
37//! assert!(!feed.bozo);
38//! assert_eq!(feed.feed.title.as_deref(), Some("Example Feed"));
39//! assert_eq!(feed.entries.len(), 1);
40//! ```
41//!
42//! ## Supported Formats
43//!
44//! | Format | Versions | Detection |
45//! |--------|----------|-----------|
46//! | RSS | 0.90, 0.91, 0.92, 2.0 | `<rss>` element |
47//! | RSS 1.0 | RDF-based | `<rdf:RDF>` with RSS namespace |
48//! | Atom | 0.3, 1.0 | `<feed>` with Atom namespace |
49//! | JSON Feed | 1.0, 1.1 | `version` field starting with `https://jsonfeed.org` |
50//!
51//! ## Namespace Extensions
52//!
53//! The parser supports common feed extensions:
54//!
55//! - **iTunes/Podcast** (`itunes:`) - Podcast metadata, categories, explicit flags
56//! - **Podcast 2.0** (`podcast:`) - Transcripts, chapters, funding, persons
57//! - **Dublin Core** (`dc:`) - Creator, date, rights, subject
58//! - **Media RSS** (`media:`) - Thumbnails, content, descriptions
59//! - **Content** (`content:encoded`) - Full HTML content
60//! - **Syndication** (`sy:`) - Update frequency hints
61//! - **`GeoRSS`** (`georss:`) - Geographic coordinates
62//! - **Creative Commons** (`cc:`, `creativeCommons:`) - License information
63//!
64//! ## Type-Safe URL and MIME Handling
65//!
66//! The library uses semantic newtypes for improved type safety:
67//!
68//! ```
69//! use feedparser_rs::{Url, MimeType, Email};
70//!
71//! // Url - wraps URL strings without validation (bozo-compatible)
72//! let url = Url::new("https://example.com/feed.xml");
73//! assert_eq!(url.as_str(), "https://example.com/feed.xml");
74//! assert!(url.starts_with("https://")); // Deref to str
75//!
76//! // MimeType - uses Arc<str> for efficient cloning
77//! let mime = MimeType::new("application/rss+xml");
78//! let clone = mime.clone(); // Cheap: just increments refcount
79//!
80//! // Email - wraps email addresses
81//! let email = Email::new("author@example.com");
82//! ```
83//!
84//! These types implement <code>[`Deref`](std::ops::Deref)<Target=str></code>, so string methods work directly:
85//!
86//! ```
87//! use feedparser_rs::Url;
88//!
89//! let url = Url::new("https://example.com/path?query=1");
90//! assert!(url.contains("example.com"));
91//! assert_eq!(url.len(), 32);
92//! ```
93//!
94//! ## The Bozo Pattern
95//!
96//! Following Python feedparser's philosophy, this library **never panics** on
97//! malformed input. Instead, it sets the `bozo` flag and continues parsing:
98//!
99//! ```
100//! use feedparser_rs::parse;
101//!
102//! // XML with undefined entity - triggers bozo
103//! let xml_with_entity = b"<rss version='2.0'><channel><title>Test </title></channel></rss>";
104//!
105//! let feed = parse(xml_with_entity).unwrap();
106//! // Parser handles invalid characters gracefully
107//! assert!(feed.feed.title.is_some());
108//! ```
109//!
110//! The bozo flag indicates the feed had issues but was still parseable.
111//!
112//! ## Resource Limits
113//!
114//! Protect against malicious feeds with [`ParserLimits`]:
115//!
116//! ```
117//! use feedparser_rs::{parse_with_limits, ParserLimits};
118//!
119//! // Customize limits for untrusted input
120//! let limits = ParserLimits {
121//! max_entries: 100,
122//! max_text_length: 50_000,
123//! ..Default::default()
124//! };
125//!
126//! let xml = b"<rss version='2.0'><channel><title>Safe</title></channel></rss>";
127//! let feed = parse_with_limits(xml, limits).unwrap();
128//! ```
129//!
130//! ## HTTP Fetching
131//!
132//! With the `http` feature (enabled by default), fetch feeds from URLs:
133//!
134//! ```no_run
135//! use feedparser_rs::parse_url;
136//!
137//! // Simple fetch
138//! let feed = parse_url("https://example.com/feed.xml", None, None, None)?;
139//!
140//! // With conditional GET for caching
141//! let feed2 = parse_url(
142//! "https://example.com/feed.xml",
143//! feed.etag.as_deref(), // ETag from previous fetch
144//! feed.modified.as_deref(), // Last-Modified from previous fetch
145//! Some("MyApp/1.0"), // Custom User-Agent
146//! )?;
147//!
148//! if feed2.status == Some(304) {
149//! println!("Feed not modified since last fetch");
150//! }
151//! # Ok::<(), feedparser_rs::FeedError>(())
152//! ```
153//!
154//! ## Core Types
155//!
156//! - [`ParsedFeed`] - Complete parsed feed with metadata and entries
157//! - [`FeedMeta`] - Feed-level metadata (title, link, author, etc.)
158//! - [`Entry`] - Individual feed entry/item
159//! - [`Link`], [`Person`], [`Tag`] - Common feed elements
160//! - [`Url`], [`MimeType`], [`Email`] - Type-safe string wrappers
161//!
162//! ## Module Structure
163//!
164//! - [`types`] - All data structures for parsed feeds
165//! - [`namespace`] - Handlers for namespace extensions (iTunes, Podcast 2.0, etc.)
166//! - [`util`] - Helper functions for dates, HTML sanitization, encoding
167//! - [`compat`] - Python feedparser API compatibility layer
168//! - [`http`] - HTTP client for fetching feeds (requires `http` feature)
169
170/// Compatibility utilities for Python feedparser API
171pub mod compat;
172mod error;
173#[cfg(feature = "http")]
174/// HTTP client module for fetching feeds from URLs
175pub mod http;
176mod limits;
177/// Namespace handlers for extended feed formats
178pub mod namespace;
179mod options;
180mod parser;
181
182/// Type definitions for feed data structures
183///
184/// This module contains all the data types used to represent parsed feeds,
185/// including the main `ParsedFeed` struct and related types.
186pub mod types;
187
188/// Utility functions for feed parsing
189///
190/// This module provides helper functions for date parsing, HTML sanitization,
191/// and encoding detection that are useful for feed processing.
192pub mod util;
193
194pub use error::{FeedError, Result};
195pub use limits::{LimitError, ParserLimits};
196pub use options::ParseOptions;
197pub use parser::{detect_format, parse, parse_with_limits, parse_with_options};
198pub use types::{
199 Cloud, Content, Email, Enclosure, Entry, FeedMeta, FeedVersion, Generator, Image, InReplyTo,
200 ItunesCategory, ItunesEntryMeta, ItunesFeedMeta, ItunesOwner, LimitedCollectionExt, Link,
201 MediaContent, MediaCopyright, MediaCredit, MediaRating, MediaThumbnail, MimeType, ParsedFeed,
202 Person, PodcastChapters, PodcastChat, PodcastEntryMeta, PodcastFunding, PodcastMeta,
203 PodcastPerson, PodcastSoundbite, PodcastTranscript, PodcastValue, PodcastValueRecipient,
204 PodcastValueTimeSplit, Source, Tag, TextConstruct, TextInput, TextType, Url, parse_explicit,
205};
206
207pub use namespace::syndication::{SyndicationMeta, UpdatePeriod};
208
209#[cfg(feature = "http")]
210pub use http::{FeedHttpClient, FeedHttpResponse};
211
212/// Parse feed from HTTP/HTTPS URL
213///
214/// Fetches the feed from the given URL and parses it. Supports conditional GET
215/// using `ETag` and `Last-Modified` headers for bandwidth-efficient caching.
216///
217/// # Arguments
218///
219/// * `url` - HTTP or HTTPS URL to fetch
220/// * `etag` - Optional `ETag` from previous fetch for conditional GET
221/// * `modified` - Optional `Last-Modified` timestamp from previous fetch
222/// * `user_agent` - Optional custom User-Agent header
223///
224/// # Returns
225///
226/// Returns a `ParsedFeed` with HTTP metadata fields populated:
227/// - `status`: HTTP status code (200, 304, etc.)
228/// - `href`: Final URL after redirects
229/// - `etag`: `ETag` header value (for next request)
230/// - `modified`: `Last-Modified` header value (for next request)
231/// - `headers`: Full HTTP response headers
232///
233/// On 304 Not Modified, returns a feed with empty entries but status=304.
234///
235/// # Errors
236///
237/// Returns `FeedError::Http` if:
238/// - Network error occurs
239/// - URL is invalid
240/// - HTTP status is 4xx or 5xx (except 304)
241///
242/// # Examples
243///
244/// ```no_run
245/// use feedparser_rs::parse_url;
246///
247/// // First fetch
248/// let feed = parse_url("https://example.com/feed.xml", None, None, None).unwrap();
249/// println!("Title: {:?}", feed.feed.title);
250/// println!("ETag: {:?}", feed.etag);
251///
252/// // Subsequent fetch with caching
253/// let feed2 = parse_url(
254/// "https://example.com/feed.xml",
255/// feed.etag.as_deref(),
256/// feed.modified.as_deref(),
257/// None
258/// ).unwrap();
259///
260/// if feed2.status == Some(304) {
261/// println!("Feed not modified, use cached version");
262/// }
263/// ```
264#[cfg(feature = "http")]
265pub fn parse_url(
266 url: &str,
267 etag: Option<&str>,
268 modified: Option<&str>,
269 user_agent: Option<&str>,
270) -> Result<ParsedFeed> {
271 parse_url_with_options(url, etag, modified, user_agent, &ParseOptions::default())
272}
273
274/// Parse feed from URL with custom parser limits
275///
276/// Like `parse_url` but allows specifying custom limits for resource control.
277/// HTML sanitization and relative URI resolution use their
278/// [`ParseOptions::default`] settings (both enabled); use
279/// [`parse_url_with_options`] to control them directly.
280///
281/// # Errors
282///
283/// Returns `FeedError::Http` if the request fails or `FeedError::Parse` if parsing fails.
284///
285/// # Examples
286///
287/// ```no_run
288/// use feedparser_rs::{parse_url_with_limits, ParserLimits};
289///
290/// let limits = ParserLimits::strict();
291/// let feed = parse_url_with_limits(
292/// "https://example.com/feed.xml",
293/// None,
294/// None,
295/// None,
296/// limits
297/// ).unwrap();
298/// ```
299#[cfg(feature = "http")]
300pub fn parse_url_with_limits(
301 url: &str,
302 etag: Option<&str>,
303 modified: Option<&str>,
304 user_agent: Option<&str>,
305 limits: ParserLimits,
306) -> Result<ParsedFeed> {
307 parse_url_with_options(
308 url,
309 etag,
310 modified,
311 user_agent,
312 &ParseOptions {
313 limits,
314 ..ParseOptions::default()
315 },
316 )
317}
318
319/// Parse feed from URL with full control over parser behavior
320///
321/// Like `parse_url` but allows specifying HTML sanitization, relative URI
322/// resolution, and resource limits via [`ParseOptions`].
323///
324/// # Errors
325///
326/// Returns `FeedError::Http` if the request fails or `FeedError::Parse` if parsing fails.
327///
328/// # Examples
329///
330/// ```no_run
331/// use feedparser_rs::{parse_url_with_options, ParseOptions};
332///
333/// let options = ParseOptions {
334/// sanitize_html: false, // Trust this feed source
335/// ..ParseOptions::default()
336/// };
337/// let feed = parse_url_with_options(
338/// "https://example.com/feed.xml",
339/// None,
340/// None,
341/// None,
342/// &options,
343/// ).unwrap();
344/// ```
345#[cfg(feature = "http")]
346pub fn parse_url_with_options(
347 url: &str,
348 etag: Option<&str>,
349 modified: Option<&str>,
350 user_agent: Option<&str>,
351 options: &ParseOptions,
352) -> Result<ParsedFeed> {
353 use http::FeedHttpClient;
354
355 let mut client = FeedHttpClient::new()?;
356 if let Some(agent) = user_agent {
357 client = client.with_user_agent(agent.to_string());
358 }
359
360 let response = client.get(url, etag, modified, None)?;
361
362 if response.status == 304 {
363 return Ok(ParsedFeed {
364 status: Some(304),
365 href: Some(response.url),
366 etag: etag.map(String::from),
367 modified: modified.map(String::from),
368 #[cfg(feature = "http")]
369 headers: Some(response.headers),
370 encoding: String::from("utf-8"),
371 ..Default::default()
372 });
373 }
374
375 if response.status >= 400 {
376 return Err(FeedError::Http {
377 message: format!("HTTP {} for URL: {}", response.status, response.url),
378 });
379 }
380
381 let mut feed = parse_with_options(&response.body, options)?;
382
383 feed.status = Some(response.status);
384 feed.href = Some(response.url);
385 feed.etag = response.etag;
386 feed.modified = response.last_modified;
387 #[cfg(feature = "http")]
388 {
389 feed.headers = Some(response.headers);
390 }
391
392 if let Some(http_encoding) = response.encoding {
393 feed.encoding = http_encoding;
394 }
395
396 Ok(feed)
397}
398
399#[cfg(test)]
400mod tests {
401 use super::*;
402
403 #[test]
404 fn test_parse_basic() {
405 let xml = r#"
406 <?xml version="1.0"?>
407 <rss version="2.0">
408 <channel>
409 <title>Test</title>
410 </channel>
411 </rss>
412 "#;
413
414 let result = parse(xml.as_bytes());
415 assert!(result.is_ok());
416 }
417
418 #[test]
419 fn test_parsed_feed_new() {
420 let feed = ParsedFeed::new();
421 assert_eq!(feed.encoding, "utf-8");
422 assert!(!feed.bozo);
423 assert_eq!(feed.version, FeedVersion::Unknown);
424 }
425
426 #[test]
427 fn test_feed_version_display() {
428 assert_eq!(FeedVersion::Rss20.to_string(), "rss20");
429 assert_eq!(FeedVersion::Atom10.to_string(), "atom10");
430 }
431}