1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57enum Rss2Phase {
58 Header,
59 Items,
60 Footer,
61 Done,
62}
63
64pin_project! {
65 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 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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
219enum AtomPhase {
220 Header,
221 Entries,
222 Footer,
223 Done,
224}
225
226pin_project! {
227 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 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 #[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
356pin_project! {
361 pub struct FeedStreamWriter {
366 #[pin]
367 inner: BoxStream<'static, Result<Bytes, BoxError>>,
368 }
369}
370
371impl FeedStreamWriter {
372 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 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 #[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
414pub enum FeedStream {
430 Rss2(Rss2FeedStream),
431 Atom(AtomFeedStream),
432}
433
434impl FeedStream {
435 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 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 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 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 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 pub async fn from_body(body: crate::Body) -> Result<Self, FeedParseError> {
512 Self::new(body_reader(body)).await
513 }
514
515 pub async fn from_body_strict(body: crate::Body) -> Result<Self, FeedParseError> {
517 Self::new_strict(body_reader(body)).await
518 }
519
520 #[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 #[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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 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
735impl 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
754fn 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#[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 assert!(
844 xml.contains("<![CDATA[<p>hi</p>]]>"),
845 "content missing: {xml}"
846 );
847 assert!(xml.contains("</feed>"), "{xml}");
848 }
849
850 #[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}