Skip to main content

rama_http/protocols/rss/
stream.rs

1//! Streaming XML output for RSS 2.0 and Atom feeds.
2//!
3//! Three writers, all `Stream<Item = Result<Bytes, BoxError>>`:
4//!
5//! * [`Rss2StreamWriter`] — strongly typed RSS 2.0 writer over an item stream.
6//!   Build from a [`Rss2Channel`] header + `Stream<Item = Result<Rss2Item, _>>`,
7//!   or from a whole in-memory [`Rss2Feed`] via [`Rss2StreamWriter::from_feed`].
8//! * [`AtomStreamWriter`] — same, but for Atom 1.0: [`AtomHeader`] + entries.
9//! * [`FeedStreamWriter`] — format-agnostic umbrella used when the upstream is
10//!   a parsed [`Feed`] of unknown format. Internally type-erased.
11//!
12//! There is no synchronous serialization path — every writer is a `Stream`,
13//! and the in-memory entry points are thin "collect" adapters on top.
14//!
15//! The header/entry types ([`Rss2Channel`], [`AtomHeader`], [`Rss2Item`],
16//! [`AtomEntry`]) are the same ones produced by the corresponding async
17//! readers ([`super::Rss2FeedStream`] / [`super::AtomFeedStream`] /
18//! [`super::FeedStream`]), so the drain ↔ construct path round-trips through
19//! the same types.
20
21use std::pin::Pin;
22use std::task::{Context, Poll};
23
24use pin_project_lite::pin_project;
25use quick_xml::{
26    Writer,
27    events::{BytesDecl, Event},
28};
29use rama_core::bytes::{BufMut as _, Bytes, BytesMut};
30use rama_core::error::BoxError;
31use rama_core::futures::Stream;
32use rama_core::futures::stream::{self, BoxStream, StreamExt as _};
33use rama_net::uri::Uri;
34use rama_utils::octets::kib;
35
36use super::atom::{
37    AtomEntry, AtomFeed, AtomFeedStream, AtomHeader, write_atom_entry, write_atom_feed_close,
38    write_atom_feed_open,
39};
40use super::error::{CollectError, FeedCollectError, FeedParseError};
41use super::feed::{Feed, FeedItem, pick_alternate, pick_rel};
42use super::parse_util::{detect_atom, detect_rss};
43use super::rss2::{
44    Rss2Channel, Rss2Feed, Rss2FeedStream, Rss2Item, write_rss2_channel_close,
45    write_rss2_channel_open, write_rss2_item,
46};
47use super::ser::XmlWriteError;
48use jiff::Timestamp;
49use tokio::io::AsyncBufRead;
50
51// ---------------------------------------------------------------------------
52// RSS 2.0 stream writer
53// ---------------------------------------------------------------------------
54
55/// State machine for the RSS 2.0 stream writer.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57enum Rss2Phase {
58    Header,
59    Items,
60    Footer,
61    Done,
62}
63
64pin_project! {
65    /// Strongly-typed RSS 2.0 stream writer.
66    ///
67    /// Combines a [`Rss2Channel`] header (parsed once, written immediately)
68    /// with a `Stream<Item = Result<Rss2Item, _>>` and emits a complete,
69    /// well-formed `<rss>` document as [`Bytes`] chunks. Plugs straight into
70    /// [`crate::Body::from_stream`].
71    pub struct Rss2StreamWriter<S> {
72        phase: Rss2Phase,
73        channel: Rss2Channel,
74        #[pin]
75        items: S,
76        scratch: BytesMut,
77    }
78}
79
80impl<S, E> Rss2StreamWriter<S>
81where
82    S: Stream<Item = Result<Rss2Item, E>>,
83    E: Into<BoxError>,
84{
85    /// Build a writer from a channel header + item stream. Use this when items
86    /// come from an out-of-process source (database paging, async iterator,
87    /// upstream proxy) and the caller wants to avoid materialising every item
88    /// before the response starts flowing.
89    pub fn new(channel: Rss2Channel, items: S) -> Self {
90        Self {
91            phase: Rss2Phase::Header,
92            channel,
93            items,
94            scratch: BytesMut::with_capacity(4096),
95        }
96    }
97}
98
99impl Rss2StreamWriter<BoxStream<'static, Result<Rss2Item, BoxError>>> {
100    /// Build a writer from a whole in-memory [`Rss2Feed`]. Items are
101    /// re-streamed one at a time via `stream::iter`. This is the convenience
102    /// path for callers that already have the feed materialised (the
103    /// "collect" side reversed).
104    #[must_use]
105    pub fn from_feed(feed: Rss2Feed) -> Self {
106        let Rss2Feed {
107            title,
108            link,
109            description,
110            language,
111            copyright,
112            managing_editor,
113            web_master,
114            pub_date,
115            last_build_date,
116            categories,
117            generator,
118            docs,
119            ttl,
120            image,
121            atom_links,
122            items,
123            extensions,
124        } = feed;
125        let channel = Rss2Channel {
126            title,
127            link,
128            description,
129            language,
130            copyright,
131            managing_editor,
132            web_master,
133            pub_date,
134            last_build_date,
135            categories,
136            generator,
137            docs,
138            ttl,
139            image,
140            atom_links,
141            extensions,
142        };
143        let items_stream: BoxStream<'static, Result<Rss2Item, BoxError>> =
144            stream::iter(items.into_iter().map(Ok)).boxed();
145        Self::new(channel, items_stream)
146    }
147}
148
149impl<S, E> Stream for Rss2StreamWriter<S>
150where
151    S: Stream<Item = Result<Rss2Item, E>>,
152    E: Into<BoxError>,
153{
154    type Item = Result<Bytes, BoxError>;
155
156    fn poll_next(
157        self: Pin<&mut Self>,
158        cx: &mut Context<'_>,
159    ) -> Poll<Option<Result<Bytes, BoxError>>> {
160        let mut this = self.project();
161
162        loop {
163            match *this.phase {
164                Rss2Phase::Header => {
165                    this.scratch.clear();
166                    if let Err(e) = write_rss2_header_chunk(this.scratch, this.channel) {
167                        return Poll::Ready(Some(Err(e.into())));
168                    }
169                    *this.phase = Rss2Phase::Items;
170                    let chunk = this.scratch.split().freeze();
171                    return Poll::Ready(Some(Ok(chunk)));
172                }
173                Rss2Phase::Items => match this.items.as_mut().poll_next(cx) {
174                    Poll::Pending => return Poll::Pending,
175                    Poll::Ready(None) => {
176                        *this.phase = Rss2Phase::Footer;
177                    }
178                    Poll::Ready(Some(Err(e))) => {
179                        return Poll::Ready(Some(Err(e.into())));
180                    }
181                    Poll::Ready(Some(Ok(item))) => {
182                        this.scratch.clear();
183                        let mut w = Writer::new(this.scratch.writer());
184                        if let Err(e) = write_rss2_item(&mut w, &item) {
185                            return Poll::Ready(Some(Err(BoxError::from(e))));
186                        }
187                        let chunk = this.scratch.split().freeze();
188                        return Poll::Ready(Some(Ok(chunk)));
189                    }
190                },
191                Rss2Phase::Footer => {
192                    this.scratch.clear();
193                    let mut w = Writer::new(this.scratch.writer());
194                    if let Err(e) = write_rss2_channel_close(&mut w) {
195                        return Poll::Ready(Some(Err(e.into())));
196                    }
197                    *this.phase = Rss2Phase::Done;
198                    let chunk = this.scratch.split().freeze();
199                    return Poll::Ready(Some(Ok(chunk)));
200                }
201                Rss2Phase::Done => return Poll::Ready(None),
202            }
203        }
204    }
205}
206
207fn write_rss2_header_chunk(buf: &mut BytesMut, channel: &Rss2Channel) -> Result<(), XmlWriteError> {
208    let mut w = Writer::new(buf.writer());
209    w.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?;
210    write_rss2_channel_open(&mut w, channel)?;
211    Ok(())
212}
213
214// ---------------------------------------------------------------------------
215// Atom stream writer
216// ---------------------------------------------------------------------------
217
218#[derive(Debug, Clone, Copy, PartialEq, Eq)]
219enum AtomPhase {
220    Header,
221    Entries,
222    Footer,
223    Done,
224}
225
226pin_project! {
227    /// Strongly-typed Atom 1.0 stream writer. Mirror of [`Rss2StreamWriter`].
228    pub struct AtomStreamWriter<S> {
229        phase: AtomPhase,
230        header: AtomHeader,
231        #[pin]
232        entries: S,
233        scratch: BytesMut,
234    }
235}
236
237impl<S, E> AtomStreamWriter<S>
238where
239    S: Stream<Item = Result<AtomEntry, E>>,
240    E: Into<BoxError>,
241{
242    /// Build a writer from a feed header + entry stream.
243    pub fn new(header: AtomHeader, entries: S) -> Self {
244        Self {
245            phase: AtomPhase::Header,
246            header,
247            entries,
248            scratch: BytesMut::with_capacity(4096),
249        }
250    }
251}
252
253impl AtomStreamWriter<BoxStream<'static, Result<AtomEntry, BoxError>>> {
254    /// Build a writer from a whole in-memory [`AtomFeed`].
255    #[must_use]
256    pub fn from_feed(feed: AtomFeed) -> Self {
257        let AtomFeed {
258            id,
259            title,
260            updated,
261            authors,
262            links,
263            categories,
264            contributors,
265            generator,
266            icon,
267            logo,
268            rights,
269            subtitle,
270            entries,
271            extensions,
272        } = feed;
273        let header = AtomHeader {
274            id,
275            title,
276            updated,
277            authors,
278            links,
279            categories,
280            contributors,
281            generator,
282            icon,
283            logo,
284            rights,
285            subtitle,
286            extensions,
287        };
288        let entries_stream: BoxStream<'static, Result<AtomEntry, BoxError>> =
289            stream::iter(entries.into_iter().map(Ok)).boxed();
290        Self::new(header, entries_stream)
291    }
292}
293
294impl<S, E> Stream for AtomStreamWriter<S>
295where
296    S: Stream<Item = Result<AtomEntry, E>>,
297    E: Into<BoxError>,
298{
299    type Item = Result<Bytes, BoxError>;
300
301    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
302        let mut this = self.project();
303
304        loop {
305            match *this.phase {
306                AtomPhase::Header => {
307                    this.scratch.clear();
308                    if let Err(e) = write_atom_header_chunk(this.scratch, this.header) {
309                        return Poll::Ready(Some(Err(e.into())));
310                    }
311                    *this.phase = AtomPhase::Entries;
312                    let chunk = this.scratch.split().freeze();
313                    return Poll::Ready(Some(Ok(chunk)));
314                }
315                AtomPhase::Entries => match this.entries.as_mut().poll_next(cx) {
316                    Poll::Pending => return Poll::Pending,
317                    Poll::Ready(None) => {
318                        *this.phase = AtomPhase::Footer;
319                    }
320                    Poll::Ready(Some(Err(e))) => {
321                        return Poll::Ready(Some(Err(e.into())));
322                    }
323                    Poll::Ready(Some(Ok(entry))) => {
324                        this.scratch.clear();
325                        let mut w = Writer::new(this.scratch.writer());
326                        if let Err(e) = write_atom_entry(&mut w, &entry) {
327                            return Poll::Ready(Some(Err(BoxError::from(e))));
328                        }
329                        let chunk = this.scratch.split().freeze();
330                        return Poll::Ready(Some(Ok(chunk)));
331                    }
332                },
333                AtomPhase::Footer => {
334                    this.scratch.clear();
335                    let mut w = Writer::new(this.scratch.writer());
336                    if let Err(e) = write_atom_feed_close(&mut w) {
337                        return Poll::Ready(Some(Err(e.into())));
338                    }
339                    *this.phase = AtomPhase::Done;
340                    let chunk = this.scratch.split().freeze();
341                    return Poll::Ready(Some(Ok(chunk)));
342                }
343                AtomPhase::Done => return Poll::Ready(None),
344            }
345        }
346    }
347}
348
349fn write_atom_header_chunk(buf: &mut BytesMut, header: &AtomHeader) -> Result<(), XmlWriteError> {
350    let mut w = Writer::new(buf.writer());
351    w.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?;
352    write_atom_feed_open(&mut w, header)?;
353    Ok(())
354}
355
356// ---------------------------------------------------------------------------
357// Format-agnostic umbrella
358// ---------------------------------------------------------------------------
359
360pin_project! {
361    /// Format-agnostic stream writer. Use this when the caller has a [`Feed`]
362    /// (often from a [`super::FeedStream::collect`]) and wants to re-emit it
363    /// without first checking which variant it is. Internally type-erased into
364    /// a `BoxStream<Bytes>`.
365    pub struct FeedStreamWriter {
366        #[pin]
367        inner: BoxStream<'static, Result<Bytes, BoxError>>,
368    }
369}
370
371impl FeedStreamWriter {
372    /// Wrap an existing [`Rss2StreamWriter`].
373    pub fn rss2<S, E>(inner: Rss2StreamWriter<S>) -> Self
374    where
375        S: Stream<Item = Result<Rss2Item, E>> + Send + 'static,
376        E: Into<BoxError> + Send + 'static,
377    {
378        Self {
379            inner: inner.boxed(),
380        }
381    }
382
383    /// Wrap an existing [`AtomStreamWriter`].
384    pub fn atom<S, E>(inner: AtomStreamWriter<S>) -> Self
385    where
386        S: Stream<Item = Result<AtomEntry, E>> + Send + 'static,
387        E: Into<BoxError> + Send + 'static,
388    {
389        Self {
390            inner: inner.boxed(),
391        }
392    }
393
394    /// Build a writer from a whole in-memory [`Feed`]. The format of the
395    /// emitted document follows the input variant.
396    #[must_use]
397    pub fn from_feed(feed: Feed) -> Self {
398        match feed {
399            Feed::Rss2(f) => Self::rss2(Rss2StreamWriter::from_feed(f)),
400            Feed::Atom(f) => Self::atom(AtomStreamWriter::from_feed(f)),
401        }
402    }
403}
404
405impl Stream for FeedStreamWriter {
406    type Item = Result<Bytes, BoxError>;
407
408    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
409        let this = self.project();
410        this.inner.poll_next(cx)
411    }
412}
413
414// ---------------------------------------------------------------------------
415// FeedStream — format-agnostic async streaming reader.
416// ---------------------------------------------------------------------------
417
418/// One async stream per supported feed format. Use [`FeedStream::new`] when
419/// the input format isn't known ahead of time, or [`Rss2FeedStream`] /
420/// [`AtomFeedStream`] directly when it is.
421///
422/// Construction peeks the first chunk, decides the format, and builds the
423/// matching strongly-typed inner stream — the header (channel / feed-level
424/// metadata) is parsed at that point and is inspectable via [`channel`] /
425/// [`header`] or the cross-format accessors below.
426///
427/// [`channel`]: Self::channel
428/// [`header`]: Self::header
429pub enum FeedStream {
430    Rss2(Rss2FeedStream),
431    Atom(AtomFeedStream),
432}
433
434impl FeedStream {
435    /// Peek the prefix of `reader`, decide the format, and build the matching
436    /// inner stream.
437    pub async fn new<R>(reader: R) -> Result<Self, FeedParseError>
438    where
439        R: AsyncBufRead + Unpin + Send + 'static,
440    {
441        Self::new_with_mode(reader, false).await
442    }
443
444    /// Strict variant of [`Self::new`].
445    pub async fn new_strict<R>(reader: R) -> Result<Self, FeedParseError>
446    where
447        R: AsyncBufRead + Unpin + Send + 'static,
448    {
449        Self::new_with_mode(reader, true).await
450    }
451
452    async fn new_with_mode<R>(reader: R, strict: bool) -> Result<Self, FeedParseError>
453    where
454        R: AsyncBufRead + Unpin + Send + 'static,
455    {
456        use tokio::io::AsyncReadExt as _;
457
458        // Pull enough bytes to detect the format. `fill_buf` alone only
459        // returns whatever's currently buffered, which on a multi-chunk
460        // `Body` stream can be a few bytes per `fill_buf` call. We instead
461        // read into a local probe buffer until we have at least
462        // `PROBE_MIN_BYTES` (or EOF), then prepend it back to the reader
463        // via `Cursor::chain` so the inner stream sees the full document.
464        // The loop exits as soon as `probe.len() >= PROBE_MIN_BYTES`, so
465        // probe.len() is bounded by `PROBE_MIN_BYTES + CHUNK - 1` (≈ 1279).
466        const PROBE_MIN_BYTES: usize = 1024;
467        const CHUNK: usize = 256;
468        let mut reader = reader;
469        let mut probe = Vec::with_capacity(PROBE_MIN_BYTES + CHUNK);
470        let mut chunk = [0u8; CHUNK];
471        while probe.len() < PROBE_MIN_BYTES {
472            match reader.read(&mut chunk).await {
473                Ok(0) => break,
474                Ok(n) => probe.extend_from_slice(&chunk[..n]),
475                Err(e) => {
476                    return Err(FeedParseError {
477                        message: format!("read feed body: {e}"),
478                    });
479                }
480            }
481        }
482        // probe may end mid multi-byte UTF-8 char; trim back to the
483        // last valid boundary before handing to the detectors.
484        let probe_str = std::str::from_utf8(&probe)
485            .unwrap_or_else(|e| std::str::from_utf8(&probe[..e.valid_up_to()]).unwrap_or(""));
486        let is_atom = detect_atom(probe_str);
487        let is_rss = !is_atom && detect_rss(probe_str);
488
489        // Re-prepend the probe bytes so the underlying parser sees the whole
490        // document, even though we consumed those bytes for detection.
491        let prefix = std::io::Cursor::new(probe);
492        let chained = tokio::io::AsyncReadExt::chain(prefix, reader);
493        let buf_reader = tokio::io::BufReader::with_capacity(kib(8), chained);
494
495        if is_atom {
496            return Ok(Self::Atom(
497                AtomFeedStream::new_with_mode(buf_reader, strict).await?,
498            ));
499        }
500        if is_rss {
501            return Ok(Self::Rss2(
502                Rss2FeedStream::new_with_mode(buf_reader, strict).await?,
503            ));
504        }
505        Err(FeedParseError {
506            message: "document is neither RSS 2.0 nor Atom 1.0".to_owned(),
507        })
508    }
509
510    /// Build a stream directly from an HTTP body.
511    pub async fn from_body(body: crate::Body) -> Result<Self, FeedParseError> {
512        Self::new(body_reader(body)).await
513    }
514
515    /// Strict variant of [`Self::from_body`].
516    pub async fn from_body_strict(body: crate::Body) -> Result<Self, FeedParseError> {
517        Self::new_strict(body_reader(body)).await
518    }
519
520    /// Borrow the RSS channel header, if this is an RSS stream.
521    #[must_use]
522    pub fn channel(&self) -> Option<&Rss2Channel> {
523        match self {
524            Self::Rss2(s) => Some(s.channel()),
525            Self::Atom(_) => None,
526        }
527    }
528
529    /// Borrow the Atom feed header, if this is an Atom stream.
530    #[must_use]
531    pub fn header(&self) -> Option<&AtomHeader> {
532        match self {
533            Self::Atom(s) => Some(s.header()),
534            Self::Rss2(_) => None,
535        }
536    }
537
538    /// Drain into a complete in-memory [`Feed`]. On a per-item parse error,
539    /// returns a [`FeedCollectError`] whose `partial` field is a `Feed` of the
540    /// same variant carrying everything parsed so far.
541    pub async fn collect(self) -> Result<Feed, FeedCollectError> {
542        match self {
543            Self::Rss2(s) => s.collect().await.map(Feed::Rss2).map_err(|e| CollectError {
544                error: e.error,
545                partial: Feed::Rss2(e.partial),
546            }),
547            Self::Atom(s) => s.collect().await.map(Feed::Atom).map_err(|e| CollectError {
548                error: e.error,
549                partial: Feed::Atom(e.partial),
550            }),
551        }
552    }
553
554    /// Drain, silently dropping (and `tracing::debug!`-logging) items / entries
555    /// that fail to parse.
556    pub async fn collect_lossy(self) -> Feed {
557        match self {
558            Self::Rss2(s) => Feed::Rss2(s.collect_lossy().await),
559            Self::Atom(s) => Feed::Atom(s.collect_lossy().await),
560        }
561    }
562
563    /// Drain into a feed retaining only items / entries the predicate accepts.
564    /// Mirrors [`Rss2FeedStream::collect_filtered`] and
565    /// [`AtomFeedStream::collect_filtered`] for the format-agnostic case
566    /// (the predicate sees a [`FeedItem`] so the same closure works across
567    /// both formats).
568    pub async fn collect_filtered<F>(self, mut predicate: F) -> Result<Feed, FeedCollectError>
569    where
570        F: FnMut(&FeedItem) -> bool + Send,
571    {
572        match self {
573            Self::Rss2(s) => s
574                .collect_filtered(|i| predicate(&FeedItem::Rss2(i.clone())))
575                .await
576                .map(Feed::Rss2)
577                .map_err(|e| CollectError {
578                    error: e.error,
579                    partial: Feed::Rss2(e.partial),
580                }),
581            Self::Atom(s) => s
582                .collect_filtered(|e| predicate(&FeedItem::Atom(e.clone())))
583                .await
584                .map(Feed::Atom)
585                .map_err(|e| CollectError {
586                    error: e.error,
587                    partial: Feed::Atom(e.partial),
588                }),
589        }
590    }
591
592    // -----------------------------------------------------------------
593    // Cross-format accessors over the header.
594    //
595    // These mirror the ones on [`Feed`] / [`FeedItem`] so a caller that's
596    // streaming a feed of unknown format can inspect the header (parsed at
597    // stream construction time) without having to match on the variant.
598    // -----------------------------------------------------------------
599
600    /// See [`Feed::title`].
601    #[must_use]
602    pub fn title(&self) -> &str {
603        match self {
604            Self::Rss2(s) => &s.channel().title,
605            Self::Atom(s) => s.header().title.value.as_str(),
606        }
607    }
608
609    /// See [`Feed::description`].
610    #[must_use]
611    pub fn description(&self) -> Option<&str> {
612        match self {
613            Self::Rss2(s) => Some(&s.channel().description),
614            Self::Atom(s) => s.header().subtitle.as_ref().map(|t| t.value.as_str()),
615        }
616    }
617
618    /// See [`Feed::link`].
619    #[must_use]
620    pub fn link(&self) -> Option<&Uri> {
621        match self {
622            Self::Rss2(s) => Some(&s.channel().link),
623            Self::Atom(s) => pick_alternate(&s.header().links).map(|l| &l.href),
624        }
625    }
626
627    /// See [`Feed::self_link`].
628    #[must_use]
629    pub fn self_link(&self) -> Option<&Uri> {
630        match self {
631            Self::Rss2(s) => pick_rel(&s.channel().atom_links, "self").map(|l| &l.href),
632            Self::Atom(s) => pick_rel(&s.header().links, "self").map(|l| &l.href),
633        }
634    }
635
636    /// See [`Feed::id`].
637    #[must_use]
638    pub fn id(&self) -> Option<&Uri> {
639        match self {
640            Self::Rss2(_) => None,
641            Self::Atom(s) => Some(&s.header().id),
642        }
643    }
644
645    /// See [`Feed::language`].
646    #[must_use]
647    pub fn language(&self) -> Option<&str> {
648        match self {
649            Self::Rss2(s) => s.channel().language.as_deref(),
650            Self::Atom(_) => None,
651        }
652    }
653
654    /// See [`Feed::copyright`].
655    #[must_use]
656    pub fn copyright(&self) -> Option<&str> {
657        match self {
658            Self::Rss2(s) => s.channel().copyright.as_deref(),
659            Self::Atom(s) => s.header().rights.as_ref().map(|t| t.value.as_str()),
660        }
661    }
662
663    /// See [`Feed::generator`].
664    #[must_use]
665    pub fn generator(&self) -> Option<&str> {
666        match self {
667            Self::Rss2(s) => s.channel().generator.as_deref(),
668            Self::Atom(s) => s.header().generator.as_ref().map(|g| g.value.as_str()),
669        }
670    }
671
672    /// See [`Feed::image_url`].
673    #[must_use]
674    pub fn image_url(&self) -> Option<&Uri> {
675        match self {
676            Self::Rss2(s) => s.channel().image.as_ref().map(|i| &i.url),
677            Self::Atom(s) => s.header().logo.as_ref(),
678        }
679    }
680
681    /// See [`Feed::icon_url`].
682    #[must_use]
683    pub fn icon_url(&self) -> Option<&Uri> {
684        match self {
685            Self::Rss2(_) => None,
686            Self::Atom(s) => s.header().icon.as_ref(),
687        }
688    }
689
690    /// See [`Feed::published`].
691    #[must_use]
692    pub fn published(&self) -> Option<Timestamp> {
693        match self {
694            Self::Rss2(s) => s.channel().pub_date,
695            Self::Atom(_) => None,
696        }
697    }
698
699    /// See [`Feed::updated`].
700    #[must_use]
701    pub fn updated(&self) -> Option<Timestamp> {
702        match self {
703            Self::Rss2(s) => s.channel().last_build_date,
704            Self::Atom(s) => Some(s.header().updated),
705        }
706    }
707
708    /// See [`Feed::authors`].
709    pub fn authors(&self) -> impl Iterator<Item = &str> {
710        use rama_core::combinators::Either;
711        match self {
712            Self::Rss2(s) => {
713                let c = s.channel();
714                Either::A(
715                    [c.managing_editor.as_deref(), c.web_master.as_deref()]
716                        .into_iter()
717                        .flatten()
718                        .filter(|v| !v.is_empty()),
719                )
720            }
721            Self::Atom(s) => Either::B(s.header().authors.iter().map(|p| p.name.as_str())),
722        }
723    }
724
725    /// See [`Feed::categories`].
726    pub fn categories(&self) -> impl Iterator<Item = &str> {
727        use rama_core::combinators::Either;
728        match self {
729            Self::Rss2(s) => Either::A(s.channel().categories.iter().map(|c| c.name.as_str())),
730            Self::Atom(s) => Either::B(s.header().categories.iter().map(|c| c.term.as_str())),
731        }
732    }
733}
734
735/// `FeedStream` is itself a `Stream` of [`FeedItem`]s: each inner stream
736/// yields its strongly-typed item, and the dispatch here wraps it in the
737/// umbrella enum so a caller can iterate format-agnostically.
738impl Stream for FeedStream {
739    type Item = Result<FeedItem, FeedParseError>;
740
741    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
742        let this = self.get_mut();
743        match this {
744            Self::Rss2(s) => Pin::new(s)
745                .poll_next(cx)
746                .map(|opt| opt.map(|r| r.map(FeedItem::Rss2))),
747            Self::Atom(s) => Pin::new(s)
748                .poll_next(cx)
749                .map(|opt| opt.map(|r| r.map(FeedItem::Atom))),
750        }
751    }
752}
753
754/// Wrap an HTTP body in an [`AsyncBufRead`] for the streaming readers.
755fn body_reader(
756    body: crate::Body,
757) -> tokio::io::BufReader<
758    rama_core::stream::io::StreamReader<BodyDataStream, rama_core::bytes::Bytes>,
759> {
760    use rama_core::stream::io::StreamReader;
761
762    let stream: BodyDataStream = body
763        .into_data_stream()
764        .map(|r| r.map_err(std::io::Error::other))
765        .boxed();
766    let inner = StreamReader::new(stream);
767    tokio::io::BufReader::with_capacity(kib(8), inner)
768}
769
770type BodyDataStream = BoxStream<'static, std::io::Result<rama_core::bytes::Bytes>>;
771
772// ---------------------------------------------------------------------------
773// Tests
774// ---------------------------------------------------------------------------
775
776#[cfg(test)]
777mod tests {
778    use super::*;
779    use crate::protocols::rss::feed_ext::{ITunes, ItemExtensions};
780
781    async fn drain<S>(mut s: S) -> String
782    where
783        S: Stream<Item = Result<Bytes, BoxError>> + Unpin,
784    {
785        let mut out = Vec::new();
786        while let Some(chunk) = s.next().await {
787            out.extend_from_slice(&chunk.unwrap());
788        }
789        String::from_utf8(out).unwrap()
790    }
791
792    #[tokio::test]
793    async fn rss2_stream_declares_extension_namespaces() {
794        let channel = Rss2Channel {
795            title: "T".into(),
796            link: Uri::from_static("https://e.com"),
797            description: "D".into(),
798            ..Default::default()
799        };
800        let item = Rss2Item::new()
801            .with_title("Ep1")
802            .with_extensions(ItemExtensions {
803                itunes: Some(Box::new(ITunes {
804                    author: Some("A".into()),
805                    ..Default::default()
806                })),
807                ..Default::default()
808            });
809        let items = rama_core::futures::stream::iter(vec![Ok::<_, std::convert::Infallible>(item)]);
810        let xml = drain(Rss2StreamWriter::new(channel, items)).await;
811        assert!(
812            xml.contains("xmlns:itunes="),
813            "namespace not declared: {xml}"
814        );
815        assert!(xml.contains("<itunes:author>A</itunes:author>"), "{xml}");
816        assert!(
817            xml.contains("</channel>") && xml.contains("</rss>"),
818            "{xml}"
819        );
820    }
821
822    #[tokio::test]
823    #[cfg(feature = "html")]
824    async fn atom_stream_keeps_content_and_declares_namespaces() {
825        use crate::protocols::html::p;
826        use crate::protocols::rss::AtomContent;
827        use jiff::Timestamp;
828
829        let header = AtomHeader {
830            id: Uri::from_static("urn:x"),
831            ..Default::default()
832        };
833        let entry = AtomEntry::new(Uri::from_static("urn:1"), "E1", Timestamp::UNIX_EPOCH)
834            .with_content(AtomContent::html(p!("hi")));
835        let entries =
836            rama_core::futures::stream::iter(vec![Ok::<_, std::convert::Infallible>(entry)]);
837        let xml = drain(AtomStreamWriter::new(header, entries)).await;
838        assert!(
839            xml.contains("xmlns:itunes="),
840            "namespace not declared: {xml}"
841        );
842        // content used to be dropped by the streaming writer
843        assert!(
844            xml.contains("<![CDATA[<p>hi</p>]]>"),
845            "content missing: {xml}"
846        );
847        assert!(xml.contains("</feed>"), "{xml}");
848    }
849
850    /// Writer fed from a "database-style" async stream: each item is yielded
851    /// only when the previous one has been polled. Asserts the writer doesn't
852    /// pre-buffer items and that the resulting document is still well-formed.
853    #[tokio::test]
854    async fn rss2_stream_writer_pulls_items_lazily() {
855        use std::sync::Arc;
856        use std::sync::atomic::{AtomicUsize, Ordering};
857
858        let pulled = Arc::new(AtomicUsize::new(0));
859        let pulled_clone = pulled.clone();
860        let items = rama_core::futures::stream::unfold(0u32, move |n| {
861            let pulled = pulled_clone.clone();
862            async move {
863                if n >= 3 {
864                    return None;
865                }
866                pulled.fetch_add(1, Ordering::SeqCst);
867                let item = Rss2Item::new()
868                    .with_title(format!("Episode {n}"))
869                    .with_link(
870                        Uri::from_static("https://example.com").with_additional_path_segment(n),
871                    );
872                Some((Ok::<_, std::convert::Infallible>(item), n + 1))
873            }
874        })
875        .boxed();
876
877        let channel = Rss2Channel {
878            title: "Podcast".into(),
879            link: Uri::from_static("https://example.com"),
880            description: "Streamed".into(),
881            ..Default::default()
882        };
883        let xml = drain(Rss2StreamWriter::new(channel, items)).await;
884        assert_eq!(pulled.load(Ordering::SeqCst), 3, "all items pulled once");
885        for n in 0..3 {
886            assert!(xml.contains(&format!("Episode {n}")), "{xml}");
887        }
888    }
889
890    #[tokio::test]
891    async fn from_feed_round_trips_through_the_stream_writer() {
892        use crate::protocols::rss::Rss2Feed;
893
894        let feed = Rss2Feed::builder()
895            .title("Round")
896            .link(Uri::from_static("https://example.com"))
897            .description("desc")
898            .with_item(Rss2Item::new().with_title("Item A"))
899            .with_item(Rss2Item::new().with_title("Item B"))
900            .build();
901        let xml = drain(Rss2StreamWriter::from_feed(feed)).await;
902        assert!(xml.contains("<title>Round</title>"), "{xml}");
903        assert!(xml.contains("<title>Item A</title>"), "{xml}");
904        assert!(xml.contains("<title>Item B</title>"), "{xml}");
905    }
906}