use crate::{
line::HlsLine,
tag::{IntoInnerTag, WritableCustomTag},
};
use std::{
borrow::Cow,
io::{self, Write},
};
#[derive(Debug, Clone)]
pub struct Writer<W>
where
W: Write,
{
writer: W,
}
impl<W> Writer<W>
where
W: Write,
{
pub const fn new(inner: W) -> Writer<W> {
Writer { writer: inner }
}
pub fn into_inner(self) -> W {
self.writer
}
pub fn get_mut(&mut self) -> &mut W {
&mut self.writer
}
pub const fn get_ref(&self) -> &W {
&self.writer
}
pub fn write_line(&mut self, line: HlsLine) -> io::Result<usize> {
self.write_custom_line(line)
}
pub fn write_blank(&mut self) -> io::Result<usize> {
self.write_line(HlsLine::Blank)
}
pub fn write_comment<'a>(&mut self, comment: impl Into<Cow<'a, str>>) -> io::Result<usize> {
self.write_line(HlsLine::Comment(comment.into()))
}
pub fn write_uri<'a>(&mut self, uri: impl Into<Cow<'a, str>>) -> io::Result<usize> {
self.write_line(HlsLine::Uri(uri.into()))
}
pub fn write_custom_tag<'a, Custom>(&mut self, tag: Custom) -> io::Result<usize>
where
Custom: WritableCustomTag<'a>,
{
let mut count = self.write(tag.into_inner().value())?;
count += self.write(b"\n")?;
Ok(count)
}
pub fn write_custom_line<'a, Custom>(&mut self, line: HlsLine<'a, Custom>) -> io::Result<usize>
where
Custom: WritableCustomTag<'a>,
{
let mut count = 0usize;
match line {
HlsLine::Blank => (),
HlsLine::Comment(c) => {
count += self.write(b"#")?;
count += self.write(c.as_bytes())?;
}
HlsLine::Uri(u) => count += self.write(u.as_bytes())?,
HlsLine::UnknownTag(t) => count += self.write(t.as_bytes())?,
HlsLine::KnownTag(t) => count += self.write(t.into_inner().value())?,
};
count += self.write(b"\n")?;
Ok(count)
}
fn write(&mut self, mut buf: &[u8]) -> io::Result<usize> {
let mut count = 0usize;
while !buf.is_empty() {
match self.writer.write(buf) {
Ok(0) => {
return Err(io::Error::new(
std::io::ErrorKind::WriteZero,
"failed to write whole buffer",
));
}
Ok(n) => {
count += n;
buf = &buf[n..];
}
Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => {}
Err(e) => return Err(e),
}
}
Ok(count)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
config::ParsingOptionsBuilder,
date_time,
error::ValidationError,
tag::{
CustomTag, DecimalResolution, UnknownTag, WritableAttributeValue, WritableTag,
WritableTagValue,
hls::{self, Inf, M3u, MediaSequence, Targetduration, Version},
},
};
use pretty_assertions::assert_eq;
#[derive(Debug, PartialEq, Clone)]
enum TestTag {
Empty,
Type,
Int,
Range,
Float { title: &'static str },
Date,
List,
}
impl TryFrom<UnknownTag<'_>> for TestTag {
type Error = ValidationError;
fn try_from(_: UnknownTag<'_>) -> Result<Self, Self::Error> {
Err(ValidationError::NotImplemented)
}
}
impl CustomTag<'_> for TestTag {
fn is_known_name(_: &str) -> bool {
true
}
}
impl WritableCustomTag<'_> for TestTag {
fn into_writable_tag(self) -> WritableTag<'static> {
let value = match self {
TestTag::Empty => WritableTagValue::Empty,
TestTag::Type => WritableTagValue::from("VOD"),
TestTag::Int => WritableTagValue::from(42),
TestTag::Range => WritableTagValue::from((1024, Some(512))),
TestTag::Float { title } => WritableTagValue::from((42.42, title)),
TestTag::Date => {
WritableTagValue::from(date_time!(2025-06-17 T 01:37:15.129 -05:00))
}
TestTag::List => WritableTagValue::from([
("TEST-INT", WritableAttributeValue::DecimalInteger(42)),
(
"TEST-FLOAT",
WritableAttributeValue::SignedDecimalFloatingPoint(-42.42),
),
(
"TEST-RESOLUTION",
WritableAttributeValue::DecimalResolution(DecimalResolution {
width: 1920,
height: 1080,
}),
),
(
"TEST-QUOTED-STRING",
WritableAttributeValue::QuotedString("test".into()),
),
(
"TEST-ENUMERATED-STRING",
WritableAttributeValue::UnquotedString("test".into()),
),
]),
};
WritableTag::new("-X-TEST-TAG", value)
}
}
#[test]
fn to_string_on_empty_is_valid() {
let test = TestTag::Empty;
assert_eq!("#EXT-X-TEST-TAG", string_from(test).as_str());
}
#[test]
fn to_string_on_type_is_valid() {
let test = TestTag::Type;
assert_eq!("#EXT-X-TEST-TAG:VOD", string_from(test).as_str());
}
#[test]
fn to_string_on_int_is_valid() {
let test = TestTag::Int;
assert_eq!("#EXT-X-TEST-TAG:42", string_from(test).as_str());
}
#[test]
fn to_string_on_range_is_valid() {
let test = TestTag::Range;
assert_eq!("#EXT-X-TEST-TAG:1024@512", string_from(test).as_str());
}
#[test]
fn to_string_on_float_is_valid() {
let test = TestTag::Float { title: "" };
assert_eq!("#EXT-X-TEST-TAG:42.42", string_from(test).as_str());
let test = TestTag::Float {
title: " A useful comment",
};
assert_eq!(
"#EXT-X-TEST-TAG:42.42, A useful comment",
string_from(test).as_str()
);
}
#[test]
fn to_string_on_date_is_valid() {
let test = TestTag::Date;
assert_eq!(
"#EXT-X-TEST-TAG:2025-06-17T01:37:15.129-05:00",
string_from(test).as_str()
);
}
#[test]
fn to_string_on_list_is_valid() {
let test = TestTag::List;
let mut found_int = false;
let mut found_float = false;
let mut found_resolution = false;
let mut found_quote = false;
let mut found_enum = false;
let tag_string = string_from(test);
let mut name_value_split = tag_string.split(':');
assert_eq!("#EXT-X-TEST-TAG", name_value_split.next().unwrap());
let attrs = name_value_split.next().unwrap().split(',').enumerate();
for (index, attr) in attrs {
match index {
0..5 => match attr.split('=').next().unwrap() {
"TEST-INT" => {
if found_int {
panic!("Unexpected duplicated attribute {attr}");
}
found_int = true;
assert_eq!("TEST-INT=42", attr);
}
"TEST-FLOAT" => {
if found_float {
panic!("Unexpected duplicated attribute {attr}");
}
found_float = true;
assert_eq!("TEST-FLOAT=-42.42", attr);
}
"TEST-RESOLUTION" => {
if found_resolution {
panic!("Unexpected duplicated attribute {attr}");
}
found_resolution = true;
assert_eq!("TEST-RESOLUTION=1920x1080", attr);
}
"TEST-QUOTED-STRING" => {
if found_quote {
panic!("Unexpected duplicated attribute {attr}");
}
found_quote = true;
assert_eq!("TEST-QUOTED-STRING=\"test\"", attr);
}
"TEST-ENUMERATED-STRING" => {
if found_enum {
panic!("Unexpected duplicated attribute {attr}");
}
found_enum = true;
assert_eq!("TEST-ENUMERATED-STRING=test", attr);
}
x => panic!("Unexpected attribute {x}"),
},
_ => panic!("Unexpected index {index}"),
}
}
assert!(found_int);
assert!(found_float);
assert!(found_resolution);
assert!(found_quote);
assert!(found_enum);
}
fn string_from(test_tag: TestTag) -> String {
let mut writer = Writer::new(Vec::new());
writer
.write_custom_tag(test_tag)
.expect("should not fail to write tag");
String::from_utf8_lossy(&writer.into_inner())
.trim_end()
.to_string()
}
#[test]
fn writer_should_output_expected() {
let mut writer = Writer::new(Vec::new());
writer.write_line(HlsLine::from(M3u)).unwrap();
writer.write_line(HlsLine::from(Version::new(3))).unwrap();
writer
.write_line(HlsLine::from(Targetduration::new(8)))
.unwrap();
writer
.write_line(HlsLine::from(MediaSequence::new(2680)))
.unwrap();
writer.write_line(HlsLine::Blank).unwrap();
writer
.write_line(HlsLine::from(Inf::new(7.975, "".to_string())))
.unwrap();
writer
.write_line(HlsLine::Uri(
"https://priv.example.com/fileSequence2680.ts".into(),
))
.unwrap();
writer
.write_line(HlsLine::from(Inf::new(7.941, "".to_string())))
.unwrap();
writer
.write_line(HlsLine::Uri(
"https://priv.example.com/fileSequence2681.ts".into(),
))
.unwrap();
writer
.write_line(HlsLine::from(Inf::new(7.975, "".to_string())))
.unwrap();
writer
.write_line(HlsLine::Uri(
"https://priv.example.com/fileSequence2682.ts".into(),
))
.unwrap();
assert_eq!(
EXPECTED_WRITE_OUTPUT,
std::str::from_utf8(&writer.into_inner()).unwrap()
);
}
#[test]
fn write_line_should_return_correct_byte_count() {
let mut writer = Writer::new(Vec::new());
assert_eq!(
12, writer
.write_line(HlsLine::Comment(" A comment".into()))
.unwrap()
);
assert_eq!(
13, writer
.write_line(HlsLine::Uri("example.m3u8".into()))
.unwrap()
);
assert_eq!(
22, writer
.write_line(HlsLine::from(hls::Tag::Inf(Inf::new(
6.006,
"PTS:0.0".to_string()
))))
.unwrap()
);
}
#[test]
fn writing_with_no_manipulation_should_leave_output_unchaged_except_for_new_lines() {
let mut writer = Writer::new(Vec::new());
let options = ParsingOptionsBuilder::new()
.with_parsing_for_m3u()
.with_parsing_for_version()
.build();
let mut remaining = Some(EXPECTED_WRITE_OUTPUT);
while let Some(line) = remaining {
let slice = crate::line::parse(line, &options).unwrap();
remaining = slice.remaining;
writer.write_line(slice.parsed).unwrap();
}
let mut expected = EXPECTED_WRITE_OUTPUT.to_string();
expected.push('\n');
assert_eq!(
expected.as_str(),
std::str::from_utf8(&writer.into_inner()).unwrap()
);
}
}
#[cfg(test)]
const EXPECTED_WRITE_OUTPUT: &str = r#"#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:8
#EXT-X-MEDIA-SEQUENCE:2680
#EXTINF:7.975
https://priv.example.com/fileSequence2680.ts
#EXTINF:7.941
https://priv.example.com/fileSequence2681.ts
#EXTINF:7.975
https://priv.example.com/fileSequence2682.ts
"#;