Skip to main content

quick_m3u8/
writer.rs

1use crate::{
2    line::HlsLine,
3    tag::{IntoInnerTag, WritableCustomTag},
4};
5use std::{
6    borrow::Cow,
7    io::{self, Write},
8};
9
10/// A writer of HLS lines.
11///
12/// This structure wraps a [`Write`] with methods that make writing parsed (or user constructed) HLS
13/// lines easier. The `Writer` handles inserting new lines where necessary and formatting for tags.
14/// An important note to make, is that with every tag implementation within [`crate::tag::hls`], the
15/// reference to the original input data is used directly when writing. This means that we avoid
16/// unnecessary allocations unless the data has been mutated. The same is true of
17/// [`crate::tag::KnownTag::Custom`] tags (described in [`crate::tag::CustomTagAccess`]). Where
18/// necessary, the inner [`Write`] can be accessed in any type of ownership semantics (owned via
19/// [`Self::into_inner`], mutable borrow via [`Self::get_mut`], borrow via [`Self::get_ref`]).
20///
21/// ## Mutate data as proxy
22///
23/// A common use case for using `Writer` is when implementing a proxy service for a HLS stream that
24/// modifies the playlist. In that case, the [`crate::Reader`] is used to extract information from
25/// the upstream bytes, the various tag types can be used to modify the data where necessary, and
26/// the `Writer` is then used to write the result to data for the body of the HTTP response. Below
27/// we provide a toy example of this (for a more interesting example, the repository includes an
28/// implementation of a HLS delta update in `benches/delta_update_bench.rs`).
29/// ```
30/// # use quick_m3u8::{config::ParsingOptions, HlsLine, tag::{hls, KnownTag}, Reader, Writer};
31/// # use std::io::{self, Write};
32/// const INPUT: &str = r#"
33/// #EXTINF:4
34/// segment_100.mp4
35/// #EXTINF:4
36/// segment_101.mp4
37/// "#;
38///
39/// let mut reader = Reader::from_str(INPUT, ParsingOptions::default());
40/// let mut writer = Writer::new(Vec::new());
41///
42/// let mut added_hello = false;
43/// loop {
44///     match reader.read_line() {
45///         // In this branch we match the #EXTINF tag and update the title property to add a
46///         // message.
47///         Ok(Some(HlsLine::KnownTag(KnownTag::Hls(hls::Tag::Inf(mut tag))))) => {
48///             if added_hello {
49///                 tag.set_title("World!");
50///             } else {
51///                 tag.set_title("Hello,");
52///                 added_hello = true;
53///             }
54///             writer.write_line(HlsLine::from(tag))?;
55///         }
56///         // For all other lines we just write out what we received as input.
57///         Ok(Some(line)) => {
58///             writer.write_line(line)?;
59///         }
60///         // When we encounter `Ok(None)` it indicates that we have reached the end of the
61///         // playlist and so we break the loop.
62///         Ok(None) => break,
63///         // Even when encountering errors we can access the original problem line, then take a
64///         // mutable borrow on the inner writer, and write out the bytes. In this way we can be a
65///         // very unopinionated proxy. This is completely implementation specific, and other use
66///         // cases may require an implementation that rejects the playlist, or we may also choose
67///         // to implement tracing in such cases. We're just showing the possibility here.
68///         Err(e) => writer.get_mut().write_all(e.errored_line.as_bytes())?,
69///     };
70/// }
71///
72/// const EXPECTED: &str = r#"
73/// #EXTINF:4,Hello,
74/// segment_100.mp4
75/// #EXTINF:4,World!
76/// segment_101.mp4
77/// "#;
78/// assert_eq!(EXPECTED, String::from_utf8_lossy(&writer.into_inner()));
79/// # Ok::<(), Box<dyn std::error::Error>>(())
80/// ```
81///
82/// ## Construct a playlist output
83///
84/// It may also be the case that a user may want to write a complete playlist out without having to
85/// parse any data. This is also possible (and may be made easier in the future if we implement a
86/// playlist and playlist builder type). And of course, the user can mix and match, parsing some
87/// input, mutating where necessary, introducing new lines as needed, and writing it all out. Below
88/// is another toy example of how we may construct the [9.4. Multivariant Playlist] example provided
89/// in the HLS specification.
90///
91/// ```
92/// # use quick_m3u8::{
93/// #     HlsLine, Writer,
94/// #     tag::hls::{M3u, StreamInf},
95/// # };
96/// # use std::error::Error;
97/// const EXPECTED: &str = r#"#EXTM3U
98/// #EXT-X-STREAM-INF:BANDWIDTH=1280000,AVERAGE-BANDWIDTH=1000000
99/// http://example.com/low.m3u8
100/// #EXT-X-STREAM-INF:BANDWIDTH=2560000,AVERAGE-BANDWIDTH=2000000
101/// http://example.com/mid.m3u8
102/// #EXT-X-STREAM-INF:BANDWIDTH=7680000,AVERAGE-BANDWIDTH=6000000
103/// http://example.com/hi.m3u8
104/// #EXT-X-STREAM-INF:BANDWIDTH=65000,CODECS="mp4a.40.5"
105/// http://example.com/audio-only.m3u8
106/// "#;
107///
108/// let mut writer = Writer::new(Vec::new());
109/// writer.write_line(HlsLine::from(M3u))?;
110/// writer.write_line(HlsLine::from(
111///     StreamInf::builder()
112///         .with_bandwidth(1280000)
113///         .with_average_bandwidth(1000000)
114///         .finish(),
115/// ))?;
116/// writer.write_uri("http://example.com/low.m3u8")?;
117/// writer.write_line(HlsLine::from(
118///     StreamInf::builder()
119///         .with_bandwidth(2560000)
120///         .with_average_bandwidth(2000000)
121///         .finish(),
122/// ))?;
123/// writer.write_uri("http://example.com/mid.m3u8")?;
124/// writer.write_line(HlsLine::from(
125///     StreamInf::builder()
126///         .with_bandwidth(7680000)
127///         .with_average_bandwidth(6000000)
128///         .finish(),
129/// ))?;
130/// writer.write_uri("http://example.com/hi.m3u8")?;
131/// writer.write_line(HlsLine::from(
132///     StreamInf::builder()
133///         .with_bandwidth(65000)
134///         .with_codecs("mp4a.40.5")
135///         .finish(),
136/// ))?;
137/// writer.write_uri("http://example.com/audio-only.m3u8")?;
138///
139/// assert_eq!(EXPECTED, std::str::from_utf8(&writer.into_inner())?);
140/// # Ok::<(), Box<dyn Error>>(())
141/// ```
142///
143/// [9.4. Multivariant Playlist]: https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis-18#section-9.4
144#[derive(Debug, Clone)]
145pub struct Writer<W>
146where
147    W: Write,
148{
149    /// underlying writer
150    writer: W,
151}
152
153impl<W> Writer<W>
154where
155    W: Write,
156{
157    /// Creates a `Writer` from a generic writer.
158    pub const fn new(inner: W) -> Writer<W> {
159        Writer { writer: inner }
160    }
161
162    /// Consumes this `Writer`, returning the underlying writer.
163    pub fn into_inner(self) -> W {
164        self.writer
165    }
166
167    /// Get a mutable reference to the underlying writer.
168    pub fn get_mut(&mut self) -> &mut W {
169        &mut self.writer
170    }
171
172    /// Get a reference to the underlying writer.
173    pub const fn get_ref(&self) -> &W {
174        &self.writer
175    }
176
177    /// Write the `HlsLine` to the underlying writer. Returns the number of bytes consumed during
178    /// writing or an `io::Error` from the underlying writer.
179    ///
180    /// In this case the `CustomTag` generic is the default `NoCustomTag` struct. See [`Self`] for
181    /// more detailed documentation.
182    pub fn write_line(&mut self, line: HlsLine) -> io::Result<usize> {
183        self.write_custom_line(line)
184    }
185
186    /// Example:
187    /// ```
188    /// # use quick_m3u8::Writer;
189    /// let mut writer = Writer::new(b"#EXTM3U\n".to_vec());
190    /// writer.write_blank().unwrap();
191    /// writer.write_comment(" Note blank line above.").unwrap();
192    /// let expected = r#"#EXTM3U
193    ///
194    /// ## Note blank line above.
195    /// "#;
196    /// assert_eq!(expected.as_bytes(), writer.into_inner());
197    /// ```
198    pub fn write_blank(&mut self) -> io::Result<usize> {
199        self.write_line(HlsLine::Blank)
200    }
201
202    /// Example:
203    /// ```
204    /// # use quick_m3u8::Writer;
205    /// let mut writer = Writer::new(Vec::new());
206    /// writer.write_comment(" This is a comment.").unwrap();
207    /// assert_eq!("# This is a comment.\n".as_bytes(), writer.into_inner());
208    /// ```
209    pub fn write_comment<'a>(&mut self, comment: impl Into<Cow<'a, str>>) -> io::Result<usize> {
210        self.write_line(HlsLine::Comment(comment.into()))
211    }
212
213    /// Example:
214    /// ```
215    /// # use quick_m3u8::Writer;
216    /// let mut writer = Writer::new(Vec::new());
217    /// writer.write_uri("example.m3u8").unwrap();
218    /// assert_eq!("example.m3u8\n".as_bytes(), writer.into_inner());
219    /// ```
220    pub fn write_uri<'a>(&mut self, uri: impl Into<Cow<'a, str>>) -> io::Result<usize> {
221        self.write_line(HlsLine::Uri(uri.into()))
222    }
223
224    /// Write a custom tag implementation to the inner writer.
225    ///
226    /// Note that if the custom tag is derived from parsed data (i.e. not user constructed), then
227    /// this method should be avoided, as it will allocate data perhaps unnecessarily. In that case
228    /// use [`Self::write_custom_line`] with [`crate::tag::CustomTagAccess`], as this will use the
229    /// original parsed data if no mutation has occurred.
230    ///
231    /// Example:
232    /// ```
233    /// # use quick_m3u8::Writer;
234    /// # use quick_m3u8::tag::{CustomTag, WritableCustomTag, WritableTag, UnknownTag};
235    /// # use quick_m3u8::error::{ValidationError, ParseTagValueError};
236    /// # use std::borrow::Cow;
237    /// #[derive(Debug, PartialEq, Clone)]
238    /// struct ExampleCustomTag {
239    ///     answer: u64,
240    /// }
241    /// impl TryFrom<UnknownTag<'_>> for ExampleCustomTag {
242    ///     type Error = ValidationError;
243    ///     fn try_from(tag: UnknownTag) -> Result<Self, Self::Error> {
244    ///         if tag.name() != "-X-MEANING-OF-LIFE" {
245    ///             return Err(ValidationError::UnexpectedTagName)
246    ///         }
247    ///         Ok(Self {
248    ///             answer: tag
249    ///                 .value()
250    ///                 .ok_or(ParseTagValueError::UnexpectedEmpty)?
251    ///                 .try_as_decimal_integer()?
252    ///         })
253    ///     }
254    /// }
255    /// impl CustomTag<'_> for ExampleCustomTag {
256    ///     fn is_known_name(name: &str) -> bool {
257    ///         name == "-X-MEANING-OF-LIFE"
258    ///     }
259    /// }
260    /// impl WritableCustomTag<'_> for ExampleCustomTag {
261    ///     fn into_writable_tag(self) -> WritableTag<'static> {
262    ///         WritableTag::new("-X-MEANING-OF-LIFE", self.answer)
263    ///     }
264    /// }
265    ///
266    /// let mut writer = Writer::new(Vec::new());
267    /// let custom_tag = ExampleCustomTag { answer: 42 };
268    /// writer.write_custom_tag(custom_tag).unwrap();
269    /// assert_eq!(
270    ///     "#EXT-X-MEANING-OF-LIFE:42\n".as_bytes(),
271    ///     writer.into_inner()
272    /// );
273    /// ```
274    pub fn write_custom_tag<'a, Custom>(&mut self, tag: Custom) -> io::Result<usize>
275    where
276        Custom: WritableCustomTag<'a>,
277    {
278        let mut count = self.write(tag.into_inner().value())?;
279        count += self.write(b"\n")?;
280        Ok(count)
281    }
282
283    /// Write the `HlsLine` to the underlying writer. Returns the number of bytes consumed during
284    /// writing or an `io::Error` from the underlying writer. Ultimately, all the other write
285    /// methods are wrappers for this method.
286    ///
287    /// This method is necessary to use where the input lines carry a custom tag type (other than
288    /// [`crate::tag::NoCustomTag`]). For example, say we are parsing some data using a reader that
289    /// supports our own custom defined tag (`SomeCustomTag`).
290    /// ```
291    /// # use quick_m3u8::{
292    /// # Reader,
293    /// # config::ParsingOptions,
294    /// # tag::{CustomTag, WritableCustomTag, WritableTag, UnknownTag},
295    /// # error::ValidationError
296    /// # };
297    /// # use std::marker::PhantomData;
298    /// # #[derive(Debug, PartialEq, Clone)]
299    /// # struct SomeCustomTag;
300    /// # impl TryFrom<UnknownTag<'_>> for SomeCustomTag {
301    /// #     type Error = ValidationError;
302    /// #     fn try_from(_: UnknownTag) -> Result<Self, Self::Error> { todo!() }
303    /// # }
304    /// # impl CustomTag<'_> for SomeCustomTag {
305    /// #     fn is_known_name(_: &str) -> bool { todo!() }
306    /// # }
307    /// # impl<'a> WritableCustomTag<'a> for SomeCustomTag {
308    /// #     fn into_writable_tag(self) -> WritableTag<'a> { todo!() }
309    /// # }
310    /// # let input = "";
311    /// # let options = ParsingOptions::default();
312    /// let mut reader = Reader::with_custom_from_str(
313    ///     input,
314    ///     options,
315    ///     PhantomData::<SomeCustomTag>
316    /// );
317    /// ```
318    /// If we tried to use the [`Self::write_line`] method, it would fail to compile (as that method
319    /// expects that the generic `Custom` type is [`crate::tag::NoCustomTag`], which is a struct
320    /// provided by the library that never succeeds the [`crate::tag::CustomTag::is_known_name`]
321    /// check so is never parsed). Therefore we must use the `write_custom_line` method in this case
322    /// (even if we are not writing the custom tag itself):
323    /// ```
324    /// # use quick_m3u8::{
325    /// # Reader, Writer,
326    /// # config::ParsingOptions,
327    /// # tag::{CustomTag, WritableCustomTag, WritableTag, UnknownTag},
328    /// # error::ValidationError
329    /// # };
330    /// # use std::{error::Error, marker::PhantomData};
331    /// # #[derive(Debug, PartialEq, Clone)]
332    /// # struct SomeCustomTag;
333    /// # impl TryFrom<UnknownTag<'_>> for SomeCustomTag {
334    /// #     type Error = ValidationError;
335    /// #     fn try_from(_: UnknownTag) -> Result<Self, Self::Error> { todo!() }
336    /// # }
337    /// # impl CustomTag<'_> for SomeCustomTag {
338    /// #     fn is_known_name(_: &str) -> bool { todo!() }
339    /// # }
340    /// # impl<'a> WritableCustomTag<'a> for SomeCustomTag {
341    /// #     fn into_writable_tag(self) -> WritableTag<'a> { todo!() }
342    /// # }
343    /// # let input = "";
344    /// # let options = ParsingOptions::default();
345    /// # let mut reader = Reader::with_custom_from_str(
346    /// #     input,
347    /// #     options,
348    /// #     PhantomData::<SomeCustomTag>
349    /// # );
350    /// let mut writer = Writer::new(Vec::new());
351    /// loop {
352    ///     match reader.read_line() {
353    ///         // --snip--
354    ///         Ok(Some(line)) => {
355    ///             writer.write_custom_line(line)?;
356    ///         }
357    ///         // --snip--
358    /// #        Ok(None) => break,
359    /// #        _ => todo!(),
360    ///     };
361    /// }
362    /// # Ok::<(), Box<dyn Error>>(())
363    /// ```
364    pub fn write_custom_line<'a, Custom>(&mut self, line: HlsLine<'a, Custom>) -> io::Result<usize>
365    where
366        Custom: WritableCustomTag<'a>,
367    {
368        let mut count = 0usize;
369        match line {
370            HlsLine::Blank => (),
371            HlsLine::Comment(c) => {
372                count += self.write(b"#")?;
373                count += self.write(c.as_bytes())?;
374            }
375            HlsLine::Uri(u) => count += self.write(u.as_bytes())?,
376            HlsLine::UnknownTag(t) => count += self.write(t.as_bytes())?,
377            HlsLine::KnownTag(t) => count += self.write(t.into_inner().value())?,
378        };
379        count += self.write(b"\n")?;
380        Ok(count)
381    }
382
383    fn write(&mut self, mut buf: &[u8]) -> io::Result<usize> {
384        let mut count = 0usize;
385        while !buf.is_empty() {
386            match self.writer.write(buf) {
387                Ok(0) => {
388                    return Err(io::Error::new(
389                        std::io::ErrorKind::WriteZero,
390                        "failed to write whole buffer",
391                    ));
392                }
393                Ok(n) => {
394                    count += n;
395                    buf = &buf[n..];
396                }
397                Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => {}
398                Err(e) => return Err(e),
399            }
400        }
401        Ok(count)
402    }
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408    use crate::{
409        config::ParsingOptionsBuilder,
410        date_time,
411        error::ValidationError,
412        tag::{
413            CustomTag, DecimalResolution, UnknownTag, WritableAttributeValue, WritableTag,
414            WritableTagValue,
415            hls::{self, Inf, M3u, MediaSequence, Targetduration, Version},
416        },
417    };
418    use pretty_assertions::assert_eq;
419
420    #[derive(Debug, PartialEq, Clone)]
421    enum TestTag {
422        Empty,
423        Type,
424        Int,
425        Range,
426        Float { title: &'static str },
427        Date,
428        List,
429    }
430
431    impl TryFrom<UnknownTag<'_>> for TestTag {
432        type Error = ValidationError;
433
434        fn try_from(_: UnknownTag<'_>) -> Result<Self, Self::Error> {
435            Err(ValidationError::NotImplemented)
436        }
437    }
438
439    impl CustomTag<'_> for TestTag {
440        fn is_known_name(_: &str) -> bool {
441            true
442        }
443    }
444
445    impl WritableCustomTag<'_> for TestTag {
446        fn into_writable_tag(self) -> WritableTag<'static> {
447            let value = match self {
448                TestTag::Empty => WritableTagValue::Empty,
449                TestTag::Type => WritableTagValue::from("VOD"),
450                TestTag::Int => WritableTagValue::from(42),
451                TestTag::Range => WritableTagValue::from((1024, Some(512))),
452                TestTag::Float { title } => WritableTagValue::from((42.42, title)),
453                TestTag::Date => {
454                    WritableTagValue::from(date_time!(2025-06-17 T 01:37:15.129 -05:00))
455                }
456                TestTag::List => WritableTagValue::from([
457                    ("TEST-INT", WritableAttributeValue::DecimalInteger(42)),
458                    (
459                        "TEST-FLOAT",
460                        WritableAttributeValue::SignedDecimalFloatingPoint(-42.42),
461                    ),
462                    (
463                        "TEST-RESOLUTION",
464                        WritableAttributeValue::DecimalResolution(DecimalResolution {
465                            width: 1920,
466                            height: 1080,
467                        }),
468                    ),
469                    (
470                        "TEST-QUOTED-STRING",
471                        WritableAttributeValue::QuotedString("test".into()),
472                    ),
473                    (
474                        "TEST-ENUMERATED-STRING",
475                        WritableAttributeValue::UnquotedString("test".into()),
476                    ),
477                ]),
478            };
479            WritableTag::new("-X-TEST-TAG", value)
480        }
481    }
482
483    #[test]
484    fn to_string_on_empty_is_valid() {
485        let test = TestTag::Empty;
486        assert_eq!("#EXT-X-TEST-TAG", string_from(test).as_str());
487    }
488
489    #[test]
490    fn to_string_on_type_is_valid() {
491        let test = TestTag::Type;
492        assert_eq!("#EXT-X-TEST-TAG:VOD", string_from(test).as_str());
493    }
494
495    #[test]
496    fn to_string_on_int_is_valid() {
497        let test = TestTag::Int;
498        assert_eq!("#EXT-X-TEST-TAG:42", string_from(test).as_str());
499    }
500
501    #[test]
502    fn to_string_on_range_is_valid() {
503        let test = TestTag::Range;
504        assert_eq!("#EXT-X-TEST-TAG:1024@512", string_from(test).as_str());
505    }
506
507    #[test]
508    fn to_string_on_float_is_valid() {
509        let test = TestTag::Float { title: "" };
510        assert_eq!("#EXT-X-TEST-TAG:42.42", string_from(test).as_str());
511        let test = TestTag::Float {
512            title: " A useful comment",
513        };
514        assert_eq!(
515            "#EXT-X-TEST-TAG:42.42, A useful comment",
516            string_from(test).as_str()
517        );
518    }
519
520    #[test]
521    fn to_string_on_date_is_valid() {
522        let test = TestTag::Date;
523        assert_eq!(
524            "#EXT-X-TEST-TAG:2025-06-17T01:37:15.129-05:00",
525            string_from(test).as_str()
526        );
527    }
528
529    #[test]
530    fn to_string_on_list_is_valid() {
531        let test = TestTag::List;
532        let mut found_int = false;
533        let mut found_float = false;
534        let mut found_resolution = false;
535        let mut found_quote = false;
536        let mut found_enum = false;
537        let tag_string = string_from(test);
538        let mut name_value_split = tag_string.split(':');
539        assert_eq!("#EXT-X-TEST-TAG", name_value_split.next().unwrap());
540        let attrs = name_value_split.next().unwrap().split(',').enumerate();
541        for (index, attr) in attrs {
542            match index {
543                0..5 => match attr.split('=').next().unwrap() {
544                    "TEST-INT" => {
545                        if found_int {
546                            panic!("Unexpected duplicated attribute {attr}");
547                        }
548                        found_int = true;
549                        assert_eq!("TEST-INT=42", attr);
550                    }
551                    "TEST-FLOAT" => {
552                        if found_float {
553                            panic!("Unexpected duplicated attribute {attr}");
554                        }
555                        found_float = true;
556                        assert_eq!("TEST-FLOAT=-42.42", attr);
557                    }
558                    "TEST-RESOLUTION" => {
559                        if found_resolution {
560                            panic!("Unexpected duplicated attribute {attr}");
561                        }
562                        found_resolution = true;
563                        assert_eq!("TEST-RESOLUTION=1920x1080", attr);
564                    }
565                    "TEST-QUOTED-STRING" => {
566                        if found_quote {
567                            panic!("Unexpected duplicated attribute {attr}");
568                        }
569                        found_quote = true;
570                        assert_eq!("TEST-QUOTED-STRING=\"test\"", attr);
571                    }
572                    "TEST-ENUMERATED-STRING" => {
573                        if found_enum {
574                            panic!("Unexpected duplicated attribute {attr}");
575                        }
576                        found_enum = true;
577                        assert_eq!("TEST-ENUMERATED-STRING=test", attr);
578                    }
579                    x => panic!("Unexpected attribute {x}"),
580                },
581                _ => panic!("Unexpected index {index}"),
582            }
583        }
584        assert!(found_int);
585        assert!(found_float);
586        assert!(found_resolution);
587        assert!(found_quote);
588        assert!(found_enum);
589    }
590
591    fn string_from(test_tag: TestTag) -> String {
592        let mut writer = Writer::new(Vec::new());
593        writer
594            .write_custom_tag(test_tag)
595            .expect("should not fail to write tag");
596        String::from_utf8_lossy(&writer.into_inner())
597            .trim_end()
598            .to_string()
599    }
600
601    #[test]
602    fn writer_should_output_expected() {
603        let mut writer = Writer::new(Vec::new());
604        writer.write_line(HlsLine::from(M3u)).unwrap();
605        writer.write_line(HlsLine::from(Version::new(3))).unwrap();
606        writer
607            .write_line(HlsLine::from(Targetduration::new(8)))
608            .unwrap();
609        writer
610            .write_line(HlsLine::from(MediaSequence::new(2680)))
611            .unwrap();
612        writer.write_line(HlsLine::Blank).unwrap();
613        writer
614            .write_line(HlsLine::from(Inf::new(7.975, "".to_string())))
615            .unwrap();
616        writer
617            .write_line(HlsLine::Uri(
618                "https://priv.example.com/fileSequence2680.ts".into(),
619            ))
620            .unwrap();
621        writer
622            .write_line(HlsLine::from(Inf::new(7.941, "".to_string())))
623            .unwrap();
624        writer
625            .write_line(HlsLine::Uri(
626                "https://priv.example.com/fileSequence2681.ts".into(),
627            ))
628            .unwrap();
629        writer
630            .write_line(HlsLine::from(Inf::new(7.975, "".to_string())))
631            .unwrap();
632        writer
633            .write_line(HlsLine::Uri(
634                "https://priv.example.com/fileSequence2682.ts".into(),
635            ))
636            .unwrap();
637        assert_eq!(
638            EXPECTED_WRITE_OUTPUT,
639            std::str::from_utf8(&writer.into_inner()).unwrap()
640        );
641    }
642
643    #[test]
644    fn write_line_should_return_correct_byte_count() {
645        let mut writer = Writer::new(Vec::new());
646        assert_eq!(
647            12, // 1 (#) + 10 (str) + 1 (\n) == 12
648            writer
649                .write_line(HlsLine::Comment(" A comment".into()))
650                .unwrap()
651        );
652        assert_eq!(
653            13, // 12 (str) + 1 (\n) == 13
654            writer
655                .write_line(HlsLine::Uri("example.m3u8".into()))
656                .unwrap()
657        );
658        assert_eq!(
659            22, // 21 (#EXTINF:6.006,PTS:0.0) + 1 (\n) == 22
660            writer
661                .write_line(HlsLine::from(hls::Tag::Inf(Inf::new(
662                    6.006,
663                    "PTS:0.0".to_string()
664                ))))
665                .unwrap()
666        );
667    }
668
669    #[test]
670    fn writing_with_no_manipulation_should_leave_output_unchaged_except_for_new_lines() {
671        let mut writer = Writer::new(Vec::new());
672        let options = ParsingOptionsBuilder::new()
673            .with_parsing_for_m3u()
674            .with_parsing_for_version()
675            .build();
676        let mut remaining = Some(EXPECTED_WRITE_OUTPUT);
677        while let Some(line) = remaining {
678            let slice = crate::line::parse(line, &options).unwrap();
679            remaining = slice.remaining;
680            writer.write_line(slice.parsed).unwrap();
681        }
682        let mut expected = EXPECTED_WRITE_OUTPUT.to_string();
683        expected.push('\n');
684        assert_eq!(
685            expected.as_str(),
686            std::str::from_utf8(&writer.into_inner()).unwrap()
687        );
688    }
689}
690
691#[cfg(test)]
692const EXPECTED_WRITE_OUTPUT: &str = r#"#EXTM3U
693#EXT-X-VERSION:3
694#EXT-X-TARGETDURATION:8
695#EXT-X-MEDIA-SEQUENCE:2680
696
697#EXTINF:7.975
698https://priv.example.com/fileSequence2680.ts
699#EXTINF:7.941
700https://priv.example.com/fileSequence2681.ts
701#EXTINF:7.975
702https://priv.example.com/fileSequence2682.ts
703"#;