Skip to content

Commit 26daddf

Browse files
committed
feat(compat): add compatibility module and feed-level GeoRSS support
Phase 3 (final) of namespace completion: - Add compat module with Python feedparser compatibility helpers: - normalize_version() converts FeedVersion to Python format - format_duration() converts seconds to HH:MM:SS - is_valid_version() validates version identifiers - Add geo field to FeedMeta for feed-level geographic data - Add handle_feed_element() to GeoRSS namespace handler - Add is_georss_tag() helper to parser common module - Integrate GeoRSS parsing at channel level in RSS and RSS 1.0 parsers - Add comprehensive tests for all new functionality
1 parent 2f8ccec commit 26daddf

File tree

7 files changed

+342
-14
lines changed

7 files changed

+342
-14
lines changed
Lines changed: 179 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,180 @@
1-
// Compatibility utilities for feedparser API
2-
//
3-
// This module provides utilities to ensure API compatibility with
4-
// Python's feedparser library.
1+
//! Compatibility utilities for feedparser API
2+
//!
3+
//! This module provides utilities to ensure API compatibility with
4+
//! Python's feedparser library.
55
6-
// TODO: Implement in later phases as needed
6+
use crate::types::FeedVersion;
7+
8+
/// Normalize feed type string to Python feedparser format
9+
///
10+
/// Converts version enum to Python feedparser-compatible string format:
11+
/// - "RSS 2.0" -> "rss20"
12+
/// - "Atom 1.0" -> "atom10"
13+
/// - etc.
14+
///
15+
/// # Arguments
16+
///
17+
/// * `version` - Feed version to normalize
18+
///
19+
/// # Returns
20+
///
21+
/// Normalized version string compatible with Python feedparser
22+
///
23+
/// # Examples
24+
///
25+
/// ```
26+
/// use feedparser_rs::{compat::normalize_version, FeedVersion};
27+
///
28+
/// assert_eq!(normalize_version(FeedVersion::Rss20), "rss20");
29+
/// assert_eq!(normalize_version(FeedVersion::Atom10), "atom10");
30+
/// assert_eq!(normalize_version(FeedVersion::Unknown), "");
31+
/// ```
32+
#[must_use]
33+
pub fn normalize_version(version: FeedVersion) -> String {
34+
version.as_str().to_string()
35+
}
36+
37+
/// Convert duration in seconds to HH:MM:SS format
38+
///
39+
/// Formats duration for display in podcast feeds and other contexts
40+
/// where human-readable time format is needed.
41+
///
42+
/// # Arguments
43+
///
44+
/// * `seconds` - Duration in seconds
45+
///
46+
/// # Returns
47+
///
48+
/// Duration string in HH:MM:SS format
49+
///
50+
/// # Examples
51+
///
52+
/// ```
53+
/// use feedparser_rs::compat::format_duration;
54+
///
55+
/// assert_eq!(format_duration(0), "0:00:00");
56+
/// assert_eq!(format_duration(90), "0:01:30");
57+
/// assert_eq!(format_duration(3661), "1:01:01");
58+
/// assert_eq!(format_duration(36000), "10:00:00");
59+
/// ```
60+
#[must_use]
61+
pub fn format_duration(seconds: u32) -> String {
62+
let hours = seconds / 3600;
63+
let minutes = (seconds % 3600) / 60;
64+
let secs = seconds % 60;
65+
format!("{hours}:{minutes:02}:{secs:02}")
66+
}
67+
68+
/// Check if a string is a valid feed version identifier
69+
///
70+
/// Validates whether a version string matches one of the known
71+
/// feed format versions supported by feedparser.
72+
///
73+
/// # Arguments
74+
///
75+
/// * `version` - Version string to validate
76+
///
77+
/// # Returns
78+
///
79+
/// `true` if version is valid, `false` otherwise
80+
///
81+
/// # Examples
82+
///
83+
/// ```
84+
/// use feedparser_rs::compat::is_valid_version;
85+
///
86+
/// assert!(is_valid_version("rss20"));
87+
/// assert!(is_valid_version("atom10"));
88+
/// assert!(is_valid_version("json11"));
89+
/// assert!(!is_valid_version("invalid"));
90+
/// assert!(!is_valid_version(""));
91+
/// ```
92+
#[must_use]
93+
pub fn is_valid_version(version: &str) -> bool {
94+
matches!(
95+
version,
96+
"rss090" | "rss091" | "rss092" | "rss10" | "rss20" | "atom03" | "atom10" | "json10"
97+
| "json11"
98+
)
99+
}
100+
101+
#[cfg(test)]
102+
mod tests {
103+
use super::*;
104+
105+
#[test]
106+
fn test_normalize_version() {
107+
assert_eq!(normalize_version(FeedVersion::Rss20), "rss20");
108+
assert_eq!(normalize_version(FeedVersion::Rss10), "rss10");
109+
assert_eq!(normalize_version(FeedVersion::Atom10), "atom10");
110+
assert_eq!(normalize_version(FeedVersion::Atom03), "atom03");
111+
assert_eq!(normalize_version(FeedVersion::JsonFeed10), "json10");
112+
assert_eq!(normalize_version(FeedVersion::JsonFeed11), "json11");
113+
assert_eq!(normalize_version(FeedVersion::Unknown), "");
114+
}
115+
116+
#[test]
117+
fn test_format_duration_zero() {
118+
assert_eq!(format_duration(0), "0:00:00");
119+
}
120+
121+
#[test]
122+
fn test_format_duration_seconds_only() {
123+
assert_eq!(format_duration(30), "0:00:30");
124+
assert_eq!(format_duration(59), "0:00:59");
125+
}
126+
127+
#[test]
128+
fn test_format_duration_minutes() {
129+
assert_eq!(format_duration(60), "0:01:00");
130+
assert_eq!(format_duration(90), "0:01:30");
131+
assert_eq!(format_duration(150), "0:02:30");
132+
assert_eq!(format_duration(3599), "0:59:59");
133+
}
134+
135+
#[test]
136+
fn test_format_duration_hours() {
137+
assert_eq!(format_duration(3600), "1:00:00");
138+
assert_eq!(format_duration(3661), "1:01:01");
139+
assert_eq!(format_duration(7200), "2:00:00");
140+
assert_eq!(format_duration(36000), "10:00:00");
141+
}
142+
143+
#[test]
144+
fn test_format_duration_large() {
145+
assert_eq!(format_duration(86399), "23:59:59");
146+
assert_eq!(format_duration(86400), "24:00:00");
147+
assert_eq!(format_duration(90061), "25:01:01");
148+
}
149+
150+
#[test]
151+
fn test_is_valid_version_valid() {
152+
assert!(is_valid_version("rss090"));
153+
assert!(is_valid_version("rss091"));
154+
assert!(is_valid_version("rss092"));
155+
assert!(is_valid_version("rss10"));
156+
assert!(is_valid_version("rss20"));
157+
assert!(is_valid_version("atom03"));
158+
assert!(is_valid_version("atom10"));
159+
assert!(is_valid_version("json10"));
160+
assert!(is_valid_version("json11"));
161+
}
162+
163+
#[test]
164+
fn test_is_valid_version_invalid() {
165+
assert!(!is_valid_version(""));
166+
assert!(!is_valid_version("invalid"));
167+
assert!(!is_valid_version("rss30"));
168+
assert!(!is_valid_version("atom20"));
169+
assert!(!is_valid_version("RSS20")); // Case sensitive
170+
assert!(!is_valid_version("json12"));
171+
assert!(!is_valid_version("rdf"));
172+
}
173+
174+
#[test]
175+
fn test_is_valid_version_edge_cases() {
176+
assert!(!is_valid_version(" rss20"));
177+
assert!(!is_valid_version("rss20 "));
178+
assert!(!is_valid_version("rss 20"));
179+
}
180+
}

crates/feedparser-rs-core/src/lib.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@
3838
//! for representing parsed feed data. The main entry point is the [`parse`] function which
3939
//! automatically detects feed format and returns parsed results.
4040
41-
mod compat;
41+
/// Compatibility utilities for Python feedparser API
42+
pub mod compat;
4243
mod error;
4344
#[cfg(feature = "http")]
4445
/// HTTP client module for fetching feeds from URLs

crates/feedparser-rs-core/src/namespace/georss.rs

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
//! GeoRSS Simple: <http://www.georss.org/simple>
1717
1818
use crate::limits::ParserLimits;
19-
use crate::types::Entry;
19+
use crate::types::{Entry, FeedMeta};
2020

2121
/// `GeoRSS` namespace URI
2222
pub const GEORSS: &str = "http://www.georss.org/georss";
@@ -204,6 +204,53 @@ pub fn handle_entry_element(
204204
}
205205
}
206206

207+
/// Parse `GeoRSS` element and update feed metadata
208+
///
209+
/// # Arguments
210+
///
211+
/// * `tag` - Element local name (e.g., "point", "line", "polygon", "box")
212+
/// * `text` - Element text content
213+
/// * `feed` - Feed metadata to update
214+
/// * `_limits` - Parser limits (unused but kept for API consistency)
215+
///
216+
/// # Returns
217+
///
218+
/// `true` if element was recognized and handled, `false` otherwise
219+
pub fn handle_feed_element(
220+
tag: &[u8],
221+
text: &str,
222+
feed: &mut FeedMeta,
223+
_limits: &ParserLimits,
224+
) -> bool {
225+
match tag {
226+
b"point" => {
227+
if let Some(loc) = parse_point(text) {
228+
feed.geo = Some(loc);
229+
}
230+
true
231+
}
232+
b"line" => {
233+
if let Some(loc) = parse_line(text) {
234+
feed.geo = Some(loc);
235+
}
236+
true
237+
}
238+
b"polygon" => {
239+
if let Some(loc) = parse_polygon(text) {
240+
feed.geo = Some(loc);
241+
}
242+
true
243+
}
244+
b"box" => {
245+
if let Some(loc) = parse_box(text) {
246+
feed.geo = Some(loc);
247+
}
248+
true
249+
}
250+
_ => false,
251+
}
252+
}
253+
207254
/// Parse georss:point element
208255
///
209256
/// Format: "lat lon" (space-separated)
@@ -432,4 +479,77 @@ mod tests {
432479
let loc = parse_point(" 45.256 -71.92 ").unwrap();
433480
assert_eq!(loc.coordinates[0], (45.256, -71.92));
434481
}
482+
483+
#[test]
484+
fn test_handle_feed_element_point() {
485+
let mut feed = FeedMeta::default();
486+
let limits = ParserLimits::default();
487+
488+
let handled = handle_feed_element(b"point", "45.256 -71.92", &mut feed, &limits);
489+
assert!(handled);
490+
assert!(feed.geo.is_some());
491+
492+
let geo = feed.geo.as_ref().unwrap();
493+
assert_eq!(geo.geo_type, GeoType::Point);
494+
assert_eq!(geo.coordinates[0], (45.256, -71.92));
495+
}
496+
497+
#[test]
498+
fn test_handle_feed_element_line() {
499+
let mut feed = FeedMeta::default();
500+
let limits = ParserLimits::default();
501+
502+
let handled =
503+
handle_feed_element(b"line", "45.256 -71.92 46.0 -72.0", &mut feed, &limits);
504+
assert!(handled);
505+
assert!(feed.geo.is_some());
506+
assert_eq!(feed.geo.as_ref().unwrap().geo_type, GeoType::Line);
507+
}
508+
509+
#[test]
510+
fn test_handle_feed_element_polygon() {
511+
let mut feed = FeedMeta::default();
512+
let limits = ParserLimits::default();
513+
514+
let handled = handle_feed_element(
515+
b"polygon",
516+
"45.0 -71.0 46.0 -71.0 46.0 -72.0 45.0 -71.0",
517+
&mut feed,
518+
&limits,
519+
);
520+
assert!(handled);
521+
assert!(feed.geo.is_some());
522+
assert_eq!(feed.geo.as_ref().unwrap().geo_type, GeoType::Polygon);
523+
}
524+
525+
#[test]
526+
fn test_handle_feed_element_box() {
527+
let mut feed = FeedMeta::default();
528+
let limits = ParserLimits::default();
529+
530+
let handled = handle_feed_element(b"box", "45.0 -72.0 46.0 -71.0", &mut feed, &limits);
531+
assert!(handled);
532+
assert!(feed.geo.is_some());
533+
assert_eq!(feed.geo.as_ref().unwrap().geo_type, GeoType::Box);
534+
}
535+
536+
#[test]
537+
fn test_handle_feed_element_unknown() {
538+
let mut feed = FeedMeta::default();
539+
let limits = ParserLimits::default();
540+
541+
let handled = handle_feed_element(b"unknown", "data", &mut feed, &limits);
542+
assert!(!handled);
543+
assert!(feed.geo.is_none());
544+
}
545+
546+
#[test]
547+
fn test_handle_feed_element_invalid_data() {
548+
let mut feed = FeedMeta::default();
549+
let limits = ParserLimits::default();
550+
551+
let handled = handle_feed_element(b"point", "invalid data", &mut feed, &limits);
552+
assert!(handled);
553+
assert!(feed.geo.is_none());
554+
}
435555
}

crates/feedparser-rs-core/src/parser/common.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,20 @@ pub fn is_media_tag(name: &[u8]) -> Option<&str> {
237237
extract_ns_local_name(name, b"media:")
238238
}
239239

240+
/// Check if element is a `GeoRSS` namespaced tag
241+
///
242+
/// # Examples
243+
///
244+
/// ```ignore
245+
/// assert_eq!(is_georss_tag(b"georss:point"), Some("point"));
246+
/// assert_eq!(is_georss_tag(b"georss:line"), Some("line"));
247+
/// assert_eq!(is_georss_tag(b"dc:creator"), None);
248+
/// ```
249+
#[inline]
250+
pub fn is_georss_tag(name: &[u8]) -> Option<&str> {
251+
extract_ns_local_name(name, b"georss:")
252+
}
253+
240254
/// Check if element matches an iTunes namespace tag
241255
///
242256
/// Supports both prefixed (itunes:author) and unprefixed (author) forms

0 commit comments

Comments
 (0)