feedparser-rs 0.4.7__tar.gz → 0.4.8__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/Cargo.lock +3 -3
  2. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/Cargo.toml +1 -1
  3. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/PKG-INFO +1 -1
  4. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/src/lib.rs +3 -3
  5. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/src/namespace/mod.rs +21 -0
  6. feedparser_rs-0.4.8/crates/feedparser-rs-core/src/namespace/slash.rs +117 -0
  7. feedparser_rs-0.4.8/crates/feedparser-rs-core/src/namespace/threading.rs +264 -0
  8. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/src/parser/atom.rs +323 -9
  9. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/src/parser/common.rs +40 -0
  10. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/src/parser/json.rs +182 -0
  11. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/src/parser/mod.rs +36 -20
  12. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/src/parser/namespace_detection.rs +6 -0
  13. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/src/parser/rss.rs +134 -13
  14. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/src/parser/rss10.rs +34 -3
  15. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/src/types/common.rs +32 -1
  16. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/src/types/entry.rs +35 -0
  17. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/src/types/feed.rs +2 -0
  18. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/src/types/mod.rs +2 -0
  19. feedparser_rs-0.4.8/crates/feedparser-rs-core/src/types/thread.rs +113 -0
  20. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/src/util/date.rs +45 -8
  21. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/src/util/encoding.rs +25 -14
  22. feedparser_rs-0.4.8/crates/feedparser-rs-core/tests/encoding_integration.rs +97 -0
  23. feedparser_rs-0.4.8/crates/feedparser-rs-core/tests/guid_entity_tests.rs +113 -0
  24. feedparser_rs-0.4.8/crates/feedparser-rs-core/tests/integration_tests.rs +592 -0
  25. feedparser_rs-0.4.8/crates/feedparser-rs-core/tests/test_adversarial_input.rs +120 -0
  26. feedparser_rs-0.4.8/crates/feedparser-rs-core/tests/test_georss_cc_namespaces.rs +261 -0
  27. feedparser_rs-0.4.8/crates/feedparser-rs-core/tests/test_parser_limits.rs +527 -0
  28. feedparser_rs-0.4.8/crates/feedparser-rs-core/tests/test_slash_wfw_namespaces.rs +153 -0
  29. feedparser_rs-0.4.8/crates/feedparser-rs-core/tests/test_thr_namespace.rs +59 -0
  30. feedparser_rs-0.4.8/crates/feedparser-rs-core/tests/test_threading_namespace.rs +191 -0
  31. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-py/src/lib.rs +1 -0
  32. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-py/src/types/common.rs +16 -0
  33. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-py/src/types/entry.rs +89 -0
  34. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-py/src/types/feed_meta.rs +12 -0
  35. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-py/src/types/mod.rs +1 -0
  36. feedparser_rs-0.4.8/crates/feedparser-rs-py/src/types/thread.rs +103 -0
  37. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-py/tests/test_basic.py +120 -0
  38. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/pyproject.toml +1 -1
  39. feedparser_rs-0.4.7/crates/feedparser-rs-core/tests/integration_tests.rs +0 -296
  40. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/README.md +0 -0
  41. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/Cargo.toml +0 -0
  42. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/README.md +0 -0
  43. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/benches/parsing.rs +0 -0
  44. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/benches/types.rs +0 -0
  45. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/examples/error_handling.rs +0 -0
  46. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/examples/feeds/malformed_feed.xml +0 -0
  47. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/examples/feeds/sample_atom.xml +0 -0
  48. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/examples/feeds/sample_podcast.xml +0 -0
  49. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/examples/feeds/sample_rss.xml +0 -0
  50. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/examples/parse_file.rs +0 -0
  51. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/examples/parse_url.rs +0 -0
  52. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/examples/podcast_feed.rs +0 -0
  53. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/examples/profile_memory.rs +0 -0
  54. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/src/compat/mod.rs +0 -0
  55. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/src/error.rs +0 -0
  56. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/src/http/client.rs +0 -0
  57. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/src/http/mod.rs +0 -0
  58. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/src/http/response.rs +0 -0
  59. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/src/http/validation.rs +0 -0
  60. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/src/limits.rs +0 -0
  61. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/src/namespace/cc.rs +0 -0
  62. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/src/namespace/content.rs +0 -0
  63. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/src/namespace/dublin_core.rs +0 -0
  64. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/src/namespace/georss.rs +0 -0
  65. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/src/namespace/media_rss.rs +0 -0
  66. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/src/namespace/syndication.rs +0 -0
  67. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/src/options.rs +0 -0
  68. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/src/parser/detect.rs +0 -0
  69. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/src/types/generics.rs +0 -0
  70. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/src/types/podcast.rs +0 -0
  71. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/src/types/version.rs +0 -0
  72. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/src/util/base_url.rs +0 -0
  73. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/src/util/mod.rs +0 -0
  74. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/src/util/sanitize.rs +0 -0
  75. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/src/util/text.rs +0 -0
  76. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/tests/http_integration.rs +0 -0
  77. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/tests/issue45_test.rs +0 -0
  78. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/tests/namespace_edge_cases.rs +0 -0
  79. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/tests/namespace_integration.rs +0 -0
  80. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/tests/test_podcast_namespace.rs +0 -0
  81. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/tests/test_rss10.rs +0 -0
  82. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/tests/test_url_resolution.rs +0 -0
  83. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-core/tests/test_url_security.rs +0 -0
  84. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-py/Cargo.toml +0 -0
  85. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-py/README.md +0 -0
  86. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-py/src/error.rs +0 -0
  87. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-py/src/limits.rs +0 -0
  88. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-py/src/types/compat.rs +0 -0
  89. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-py/src/types/datetime.rs +0 -0
  90. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-py/src/types/geo.rs +0 -0
  91. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-py/src/types/media.rs +0 -0
  92. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-py/src/types/parsed_feed.rs +0 -0
  93. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-py/src/types/podcast.rs +0 -0
  94. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-py/src/types/syndication.rs +0 -0
  95. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-py/tests/test_bindings.py +0 -0
  96. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-py/tests/test_compat.py +0 -0
  97. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-py/tests/test_guid_entities.py +0 -0
  98. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-py/tests/test_integration.py +0 -0
  99. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-py/tests/test_syndication.py +0 -0
  100. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/crates/feedparser-rs-py/uv.lock +0 -0
  101. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/python/feedparser_rs/__init__.py +0 -0
  102. {feedparser_rs-0.4.7 → feedparser_rs-0.4.8}/python/feedparser_rs/py.typed +0 -0
@@ -600,7 +600,7 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
600
600
 
601
601
  [[package]]
602
602
  name = "feedparser-rs"
603
- version = "0.4.7"
603
+ version = "0.4.8"
604
604
  dependencies = [
605
605
  "ammonia",
606
606
  "chrono",
@@ -623,7 +623,7 @@ dependencies = [
623
623
 
624
624
  [[package]]
625
625
  name = "feedparser-rs-node"
626
- version = "0.4.7"
626
+ version = "0.4.8"
627
627
  dependencies = [
628
628
  "feedparser-rs",
629
629
  "napi",
@@ -633,7 +633,7 @@ dependencies = [
633
633
 
634
634
  [[package]]
635
635
  name = "feedparser-rs-py"
636
- version = "0.4.7"
636
+ version = "0.4.8"
637
637
  dependencies = [
638
638
  "chrono",
639
639
  "feedparser-rs",
@@ -3,7 +3,7 @@ members = ["crates/feedparser-rs-core", "crates/feedparser-rs-py"]
3
3
  resolver = "2"
4
4
 
5
5
  [workspace.package]
6
- version = "0.4.7"
6
+ version = "0.4.8"
7
7
  edition = "2024"
8
8
  rust-version = "1.88.0"
9
9
  authors = ["bug-ops"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: feedparser-rs
3
- Version: 0.4.7
3
+ Version: 0.4.8
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -188,9 +188,9 @@ pub use limits::{LimitError, ParserLimits};
188
188
  pub use options::ParseOptions;
189
189
  pub use parser::{detect_format, parse, parse_with_limits};
190
190
  pub use types::{
191
- Content, Email, Enclosure, Entry, FeedMeta, FeedVersion, Generator, Image, ItunesCategory,
192
- ItunesEntryMeta, ItunesFeedMeta, ItunesOwner, LimitedCollectionExt, Link, MediaContent,
193
- MediaThumbnail, MimeType, ParsedFeed, Person, PodcastChapters, PodcastEntryMeta,
191
+ Content, Email, Enclosure, Entry, FeedMeta, FeedVersion, Generator, Image, InReplyTo,
192
+ ItunesCategory, ItunesEntryMeta, ItunesFeedMeta, ItunesOwner, LimitedCollectionExt, Link,
193
+ MediaContent, MediaThumbnail, MimeType, ParsedFeed, Person, PodcastChapters, PodcastEntryMeta,
194
194
  PodcastFunding, PodcastMeta, PodcastPerson, PodcastSoundbite, PodcastTranscript, PodcastValue,
195
195
  PodcastValueRecipient, Source, Tag, TextConstruct, TextType, Url, parse_duration,
196
196
  parse_explicit,
@@ -8,6 +8,8 @@
8
8
  /// - **Media RSS** (`media:`) - Multimedia content
9
9
  /// - **GeoRSS** (`georss:`) - Geographic location data
10
10
  /// - **Creative Commons** (`cc:`) - License information
11
+ /// - **Slash** (`slash:`) - Comment count
12
+ /// - **WFW** (`wfw:`) - Comment RSS URL
11
13
  ///
12
14
  /// # Usage
13
15
  ///
@@ -35,8 +37,12 @@ pub mod dublin_core;
35
37
  pub mod georss;
36
38
  /// Media RSS specification
37
39
  pub mod media_rss;
40
+ /// Slash and WFW namespace (comment count + comment RSS URL)
41
+ pub mod slash;
38
42
  /// Syndication Module for RSS 1.0
39
43
  pub mod syndication;
44
+ /// Atom Threading Extensions (RFC 4685)
45
+ pub mod threading;
40
46
 
41
47
  /// Common namespace URIs used in feeds
42
48
  pub mod namespaces {
@@ -75,6 +81,15 @@ pub mod namespaces {
75
81
 
76
82
  /// Creative Commons (legacy Userland)
77
83
  pub const CREATIVE_COMMONS: &str = "http://backend.userland.com/creativeCommonsRssModule";
84
+
85
+ /// Atom Threading Extensions (RFC 4685)
86
+ pub const THREADING: &str = "http://purl.org/syndication/thread/1.0";
87
+
88
+ /// Slash namespace
89
+ pub const SLASH: &str = "http://purl.org/rss/1.0/modules/slash/";
90
+
91
+ /// WFW (Well-Formed Web) namespace
92
+ pub const WFW: &str = "http://wellformedweb.org/CommentAPI/";
78
93
  }
79
94
 
80
95
  /// Get namespace URI for a common prefix
@@ -99,6 +114,9 @@ pub fn get_namespace_uri(prefix: &str) -> Option<&'static str> {
99
114
  "georss" => Some(namespaces::GEORSS),
100
115
  "cc" => Some(namespaces::CC),
101
116
  "creativeCommons" => Some(namespaces::CREATIVE_COMMONS),
117
+ "thr" => Some(namespaces::THREADING),
118
+ "slash" => Some(namespaces::SLASH),
119
+ "wfw" => Some(namespaces::WFW),
102
120
  _ => None,
103
121
  }
104
122
  }
@@ -125,6 +143,9 @@ pub fn get_namespace_prefix(uri: &str) -> Option<&'static str> {
125
143
  namespaces::GEORSS => Some("georss"),
126
144
  namespaces::CC => Some("cc"),
127
145
  namespaces::CREATIVE_COMMONS => Some("creativeCommons"),
146
+ namespaces::THREADING => Some("thr"),
147
+ namespaces::SLASH => Some("slash"),
148
+ namespaces::WFW => Some("wfw"),
128
149
  _ => None,
129
150
  }
130
151
  }
@@ -0,0 +1,117 @@
1
+ /// Slash namespace — `http://purl.org/rss/1.0/modules/slash/`
2
+ /// WFW (Well-Formed Web) namespace — `http://wellformedweb.org/CommentAPI/`
3
+ ///
4
+ /// These namespaces are commonly used together in `WordPress`, `Blogger`, and
5
+ /// `Movable Type` feeds to expose comment metadata.
6
+ ///
7
+ /// - `slash:comments` — integer count of comments on an entry
8
+ /// - `wfw:commentRss` — URL of the entry's comment RSS feed
9
+ use crate::types::Entry;
10
+
11
+ /// Slash namespace URI
12
+ pub const SLASH_NAMESPACE: &str = "http://purl.org/rss/1.0/modules/slash/";
13
+
14
+ /// WFW namespace URI
15
+ pub const WFW_NAMESPACE: &str = "http://wellformedweb.org/CommentAPI/";
16
+
17
+ /// Handle a Slash namespace element at entry level
18
+ ///
19
+ /// # Arguments
20
+ ///
21
+ /// * `element` - Local name of the element (without namespace prefix)
22
+ /// * `text` - Text content of the element
23
+ /// * `entry` - Entry to update
24
+ pub fn handle_slash_entry_element(element: &str, text: &str, entry: &mut Entry) {
25
+ if element == "comments" {
26
+ // Ignore non-integer values silently (bozo pattern: keep parsing)
27
+ entry.slash_comments = text.trim().parse::<u32>().ok();
28
+ }
29
+ }
30
+
31
+ /// Handle a WFW namespace element at entry level
32
+ ///
33
+ /// # Arguments
34
+ ///
35
+ /// * `element` - Local name of the element (without namespace prefix)
36
+ /// * `text` - Text content of the element
37
+ /// * `entry` - Entry to update
38
+ pub fn handle_wfw_entry_element(element: &str, text: &str, entry: &mut Entry) {
39
+ if element == "commentRss" || element == "commentrss" {
40
+ entry.wfw_comment_rss = Some(text.trim().to_string());
41
+ }
42
+ }
43
+
44
+ #[cfg(test)]
45
+ mod tests {
46
+ use super::*;
47
+
48
+ #[test]
49
+ fn test_slash_comments_integer() {
50
+ let mut entry = Entry::default();
51
+ handle_slash_entry_element("comments", "42", &mut entry);
52
+ assert_eq!(entry.slash_comments, Some(42));
53
+ }
54
+
55
+ #[test]
56
+ fn test_slash_comments_with_whitespace() {
57
+ let mut entry = Entry::default();
58
+ handle_slash_entry_element("comments", " 7 ", &mut entry);
59
+ assert_eq!(entry.slash_comments, Some(7));
60
+ }
61
+
62
+ #[test]
63
+ fn test_slash_comments_zero() {
64
+ let mut entry = Entry::default();
65
+ handle_slash_entry_element("comments", "0", &mut entry);
66
+ assert_eq!(entry.slash_comments, Some(0));
67
+ }
68
+
69
+ #[test]
70
+ fn test_slash_comments_invalid_is_ignored() {
71
+ let mut entry = Entry::default();
72
+ handle_slash_entry_element("comments", "not-a-number", &mut entry);
73
+ assert_eq!(entry.slash_comments, None);
74
+ }
75
+
76
+ #[test]
77
+ fn test_slash_unknown_element_ignored() {
78
+ let mut entry = Entry::default();
79
+ handle_slash_entry_element("section", "tech", &mut entry);
80
+ assert_eq!(entry.slash_comments, None);
81
+ }
82
+
83
+ #[test]
84
+ fn test_wfw_comment_rss() {
85
+ let mut entry = Entry::default();
86
+ handle_wfw_entry_element(
87
+ "commentRss",
88
+ "https://example.com/post/comments/feed/",
89
+ &mut entry,
90
+ );
91
+ assert_eq!(
92
+ entry.wfw_comment_rss.as_deref(),
93
+ Some("https://example.com/post/comments/feed/")
94
+ );
95
+ }
96
+
97
+ #[test]
98
+ fn test_wfw_comment_rss_lowercase_variant() {
99
+ let mut entry = Entry::default();
100
+ handle_wfw_entry_element(
101
+ "commentrss",
102
+ "https://example.com/post/comments/feed/",
103
+ &mut entry,
104
+ );
105
+ assert_eq!(
106
+ entry.wfw_comment_rss.as_deref(),
107
+ Some("https://example.com/post/comments/feed/")
108
+ );
109
+ }
110
+
111
+ #[test]
112
+ fn test_wfw_unknown_element_ignored() {
113
+ let mut entry = Entry::default();
114
+ handle_wfw_entry_element("other", "value", &mut entry);
115
+ assert_eq!(entry.wfw_comment_rss, None);
116
+ }
117
+ }
@@ -0,0 +1,264 @@
1
+ /// Atom Threading Extensions (RFC 4685) namespace handler
2
+ ///
3
+ /// Namespace URI: `http://purl.org/syndication/thread/1.0`
4
+ /// Prefix: `thr`
5
+ ///
6
+ /// Handles two elements:
7
+ /// - `thr:in-reply-to` — attribute-only element referencing the parent entry
8
+ /// - `thr:total` — text element with total response count
9
+ use crate::types::{Entry, InReplyTo, MimeType, SmallString, Url};
10
+
11
+ /// Atom Threading Extensions namespace URI
12
+ pub const THREADING_NAMESPACE: &str = "http://purl.org/syndication/thread/1.0";
13
+
14
+ /// Normalize attribute value: empty string after trim becomes None
15
+ #[inline]
16
+ fn non_empty(s: &str) -> Option<&str> {
17
+ let trimmed = s.trim();
18
+ if trimmed.is_empty() {
19
+ None
20
+ } else {
21
+ Some(trimmed)
22
+ }
23
+ }
24
+
25
+ /// Build an `InReplyTo` from its four optional fields.
26
+ ///
27
+ /// Returns `None` only if ALL fields are `None` (fully empty element).
28
+ #[inline]
29
+ fn build_in_reply_to(
30
+ ref_: Option<SmallString>,
31
+ href: Option<Url>,
32
+ type_: Option<MimeType>,
33
+ source: Option<Url>,
34
+ ) -> Option<InReplyTo> {
35
+ if ref_.is_none() && href.is_none() && type_.is_none() && source.is_none() {
36
+ return None;
37
+ }
38
+ Some(InReplyTo {
39
+ ref_,
40
+ href,
41
+ type_,
42
+ source,
43
+ })
44
+ }
45
+
46
+ /// Parse `thr:in-reply-to` from a quick-xml attribute iterator (Atom and RSS 1.0 parser paths)
47
+ ///
48
+ /// Returns `None` only if ALL fields are `None` after normalization (fully empty element).
49
+ /// Returns `Some(InReplyTo)` even if `ref` is missing, to tolerate malformed feeds.
50
+ ///
51
+ /// # Arguments
52
+ ///
53
+ /// * `attrs` - Iterator over quick-xml attributes (from `element.attributes().flatten()`)
54
+ /// * `max_attr_len` - Maximum attribute value length for `DoS` protection
55
+ pub fn parse_in_reply_to_from_attrs<'a>(
56
+ attrs: impl Iterator<Item = quick_xml::events::attributes::Attribute<'a>>,
57
+ max_attr_len: usize,
58
+ ) -> Option<InReplyTo> {
59
+ let mut ref_ = None;
60
+ let mut href = None;
61
+ let mut type_ = None;
62
+ let mut source = None;
63
+
64
+ for attr in attrs {
65
+ if attr.value.len() > max_attr_len {
66
+ continue;
67
+ }
68
+ let Ok(value) = attr.unescape_value() else {
69
+ continue;
70
+ };
71
+ match attr.key.as_ref() {
72
+ b"ref" => ref_ = non_empty(&value).map(|s| s.to_string().into()),
73
+ b"href" => href = non_empty(&value).map(|s| s.to_string().into()),
74
+ b"type" => type_ = non_empty(&value).map(|s| s.to_string().into()),
75
+ b"source" => source = non_empty(&value).map(|s| s.to_string().into()),
76
+ _ => {}
77
+ }
78
+ }
79
+
80
+ build_in_reply_to(ref_, href, type_, source)
81
+ }
82
+
83
+ /// Parse `thr:in-reply-to` from collected attributes (RSS 2.0 parser path)
84
+ ///
85
+ /// Returns `None` only if ALL fields are `None` after normalization.
86
+ ///
87
+ /// # Arguments
88
+ ///
89
+ /// * `attrs` - Slice of collected `(key_bytes, value_string)` attribute pairs
90
+ /// * `max_attr_len` - Maximum attribute value length for `DoS` protection
91
+ pub fn parse_in_reply_to_from_collected(
92
+ attrs: &[(Vec<u8>, String)],
93
+ max_attr_len: usize,
94
+ ) -> Option<InReplyTo> {
95
+ let mut ref_ = None;
96
+ let mut href = None;
97
+ let mut type_ = None;
98
+ let mut source = None;
99
+
100
+ for (key, value) in attrs {
101
+ if value.len() > max_attr_len {
102
+ continue;
103
+ }
104
+ match key.as_slice() {
105
+ b"ref" => ref_ = non_empty(value).map(|s| s.to_string().into()),
106
+ b"href" => href = non_empty(value).map(|s| s.to_string().into()),
107
+ b"type" => type_ = non_empty(value).map(|s| s.to_string().into()),
108
+ b"source" => source = non_empty(value).map(|s| s.to_string().into()),
109
+ _ => {}
110
+ }
111
+ }
112
+
113
+ build_in_reply_to(ref_, href, type_, source)
114
+ }
115
+
116
+ /// Handle `thr:total` text content
117
+ ///
118
+ /// Parses non-negative integer from text. Silently ignores non-numeric, negative,
119
+ /// overflow, empty, and whitespace-only values (consistent with how `parse_duration`
120
+ /// handles invalid input throughout the codebase).
121
+ ///
122
+ /// # Arguments
123
+ ///
124
+ /// * `text` - Text content of the `thr:total` element
125
+ /// * `entry` - Entry to update with the parsed count
126
+ pub fn handle_total(text: &str, entry: &mut Entry) {
127
+ entry.thr_total = text.trim().parse::<u32>().ok();
128
+ }
129
+
130
+ #[cfg(test)]
131
+ mod tests {
132
+ use super::*;
133
+
134
+ #[test]
135
+ fn test_handle_total_valid() {
136
+ let mut entry = Entry::default();
137
+ handle_total("15", &mut entry);
138
+ assert_eq!(entry.thr_total, Some(15));
139
+ }
140
+
141
+ #[test]
142
+ fn test_handle_total_with_whitespace() {
143
+ let mut entry = Entry::default();
144
+ handle_total(" 42 ", &mut entry);
145
+ assert_eq!(entry.thr_total, Some(42));
146
+ }
147
+
148
+ #[test]
149
+ fn test_handle_total_zero() {
150
+ let mut entry = Entry::default();
151
+ handle_total("0", &mut entry);
152
+ assert_eq!(entry.thr_total, Some(0));
153
+ }
154
+
155
+ #[test]
156
+ fn test_handle_total_non_numeric() {
157
+ let mut entry = Entry::default();
158
+ handle_total("abc", &mut entry);
159
+ assert_eq!(entry.thr_total, None);
160
+ }
161
+
162
+ #[test]
163
+ fn test_handle_total_negative() {
164
+ let mut entry = Entry::default();
165
+ handle_total("-5", &mut entry);
166
+ assert_eq!(entry.thr_total, None);
167
+ }
168
+
169
+ #[test]
170
+ fn test_handle_total_overflow() {
171
+ let mut entry = Entry::default();
172
+ handle_total("99999999999999", &mut entry);
173
+ assert_eq!(entry.thr_total, None);
174
+ }
175
+
176
+ #[test]
177
+ fn test_handle_total_empty() {
178
+ let mut entry = Entry::default();
179
+ handle_total("", &mut entry);
180
+ assert_eq!(entry.thr_total, None);
181
+ }
182
+
183
+ #[test]
184
+ fn test_handle_total_whitespace_only() {
185
+ let mut entry = Entry::default();
186
+ handle_total(" ", &mut entry);
187
+ assert_eq!(entry.thr_total, None);
188
+ }
189
+
190
+ #[test]
191
+ fn test_parse_in_reply_to_from_collected_full() {
192
+ let attrs = vec![
193
+ (b"ref".to_vec(), "tag:example.com,2024:post/1".to_string()),
194
+ (b"href".to_vec(), "https://example.com/post/1".to_string()),
195
+ (b"type".to_vec(), "text/html".to_string()),
196
+ (
197
+ b"source".to_vec(),
198
+ "https://example.com/feed.xml".to_string(),
199
+ ),
200
+ ];
201
+ let result = parse_in_reply_to_from_collected(&attrs, 1024);
202
+ assert!(result.is_some());
203
+ let irt = result.unwrap();
204
+ assert_eq!(irt.ref_.as_deref(), Some("tag:example.com,2024:post/1"));
205
+ assert_eq!(irt.href.as_deref(), Some("https://example.com/post/1"));
206
+ assert_eq!(irt.type_.as_deref(), Some("text/html"));
207
+ assert_eq!(irt.source.as_deref(), Some("https://example.com/feed.xml"));
208
+ }
209
+
210
+ #[test]
211
+ fn test_parse_in_reply_to_from_collected_empty_ref() {
212
+ let attrs = vec![
213
+ (b"ref".to_vec(), String::new()),
214
+ (b"href".to_vec(), "https://example.com/post/1".to_string()),
215
+ ];
216
+ let result = parse_in_reply_to_from_collected(&attrs, 1024);
217
+ assert!(result.is_some());
218
+ let irt = result.unwrap();
219
+ // Empty ref should be normalized to None
220
+ assert!(irt.ref_.is_none());
221
+ assert_eq!(irt.href.as_deref(), Some("https://example.com/post/1"));
222
+ }
223
+
224
+ #[test]
225
+ fn test_parse_in_reply_to_from_collected_all_empty() {
226
+ let attrs = vec![
227
+ (b"ref".to_vec(), String::new()),
228
+ (b"href".to_vec(), String::new()),
229
+ (b"type".to_vec(), " ".to_string()),
230
+ (b"source".to_vec(), String::new()),
231
+ ];
232
+ let result = parse_in_reply_to_from_collected(&attrs, 1024);
233
+ // All fields are None -> return None
234
+ assert!(result.is_none());
235
+ }
236
+
237
+ #[test]
238
+ fn test_parse_in_reply_to_from_collected_truncated_by_limit() {
239
+ let attrs = vec![(b"ref".to_vec(), "tag:example.com,2024:post/1".to_string())];
240
+ let result = parse_in_reply_to_from_collected(&attrs, 5);
241
+ // Value exceeds max_attr_len, should be skipped -> all None -> None
242
+ assert!(result.is_none());
243
+ }
244
+
245
+ #[test]
246
+ fn test_parse_in_reply_to_from_collected_only_ref() {
247
+ let attrs = vec![(b"ref".to_vec(), "tag:example.com,2024:post/1".to_string())];
248
+ let result = parse_in_reply_to_from_collected(&attrs, 1024);
249
+ assert!(result.is_some());
250
+ let irt = result.unwrap();
251
+ assert_eq!(irt.ref_.as_deref(), Some("tag:example.com,2024:post/1"));
252
+ assert!(irt.href.is_none());
253
+ assert!(irt.type_.is_none());
254
+ assert!(irt.source.is_none());
255
+ }
256
+
257
+ #[test]
258
+ fn test_non_empty_normalization() {
259
+ assert_eq!(non_empty(""), None);
260
+ assert_eq!(non_empty(" "), None);
261
+ assert_eq!(non_empty("hello"), Some("hello"));
262
+ assert_eq!(non_empty(" hello "), Some("hello"));
263
+ }
264
+ }