#[cfg(not(feature = "chrono"))]
use crate::date::DateTime;
use crate::{
date,
error::{
AttributeListParsingError, DateTimeSyntaxError, DecimalResolutionParseError,
ParseDecimalFloatingPointWithTitleError, ParseDecimalIntegerRangeError, ParseFloatError,
ParseNumberError, ParsePlaylistTypeError,
},
utils::parse_u64,
};
use memchr::{memchr, memchr3_iter};
use std::{borrow::Cow, collections::HashMap, fmt::Display};
#[derive(Debug, PartialEq, Clone, Copy)]
pub struct TagValue<'a>(pub &'a [u8]);
impl<'a> TagValue<'a> {
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn try_as_decimal_integer(&self) -> Result<u64, ParseNumberError> {
parse_u64(self.0)
}
pub fn try_as_decimal_integer_range(
&self,
) -> Result<DecimalIntegerRange, ParseDecimalIntegerRangeError> {
DecimalIntegerRange::try_from(self.0)
}
pub fn try_as_playlist_type(&self) -> Result<HlsPlaylistType, ParsePlaylistTypeError> {
if self.0 == b"VOD" {
Ok(HlsPlaylistType::Vod)
} else if self.0 == b"EVENT" {
Ok(HlsPlaylistType::Event)
} else {
Err(ParsePlaylistTypeError::InvalidValue)
}
}
pub fn try_as_decimal_floating_point(&self) -> Result<f64, ParseFloatError> {
fast_float2::parse(self.0).map_err(|_| ParseFloatError)
}
pub fn try_as_decimal_floating_point_with_title(
&self,
) -> Result<(f64, &'a str), ParseDecimalFloatingPointWithTitleError> {
match memchr(b',', self.0) {
Some(n) => {
let duration = fast_float2::parse(&self.0[..n])?;
let title = std::str::from_utf8(&self.0[(n + 1)..])?;
Ok((duration, title))
}
None => {
let duration = fast_float2::parse(self.0)?;
Ok((duration, ""))
}
}
}
#[cfg(feature = "chrono")]
pub fn try_as_date_time(
&self,
) -> Result<chrono::DateTime<chrono::FixedOffset>, DateTimeSyntaxError> {
date::parse_bytes(self.0)
}
#[cfg(not(feature = "chrono"))]
pub fn try_as_date_time(&self) -> Result<DateTime, DateTimeSyntaxError> {
date::parse_bytes(self.0)
}
pub fn try_as_attribute_list(
&self,
) -> Result<HashMap<&'a str, AttributeValue<'a>>, AttributeListParsingError> {
self.try_as_ordered_attribute_list().map(HashMap::from_iter)
}
pub fn try_as_ordered_attribute_list(
&self,
) -> Result<Vec<(&'a str, AttributeValue<'a>)>, AttributeListParsingError> {
let mut attribute_list = Vec::new();
let mut list_iter = memchr3_iter(b'=', b',', b'"', self.0);
let Some(first_match_index) = list_iter.next() else {
return Err(AttributeListParsingError::EndOfLineWhileReadingAttributeName);
};
if self.0[first_match_index] != b'=' {
return Err(AttributeListParsingError::UnexpectedCharacterInAttributeName);
}
let mut previous_match_index = first_match_index;
let mut state = AttributeListParsingState::ReadingValue {
name: std::str::from_utf8(&self.0[..first_match_index])?,
};
for i in list_iter {
let byte = self.0[i];
match state {
AttributeListParsingState::ReadingName => {
if byte == b'=' {
let name = std::str::from_utf8(&self.0[(previous_match_index + 1)..i])?;
if name.is_empty() {
return Err(AttributeListParsingError::EmptyAttributeName);
}
state = AttributeListParsingState::ReadingValue { name };
} else {
return Err(AttributeListParsingError::UnexpectedCharacterInAttributeName);
}
previous_match_index = i;
}
AttributeListParsingState::ReadingQuotedValue { name } => {
if byte == b'"' {
let value = std::str::from_utf8(&self.0[(previous_match_index + 1)..i])?;
state =
AttributeListParsingState::FinishedReadingQuotedValue { name, value };
previous_match_index = i;
}
}
AttributeListParsingState::ReadingValue { name } => {
if byte == b'"' {
if previous_match_index != (i - 1) {
return Err(
AttributeListParsingError::UnexpectedCharacterInAttributeValue,
);
}
state = AttributeListParsingState::ReadingQuotedValue { name };
} else if byte == b',' {
let value = UnquotedAttributeValue(&self.0[(previous_match_index + 1)..i]);
if value.0.is_empty() {
return Err(AttributeListParsingError::EmptyUnquotedValue);
}
attribute_list.push((name, AttributeValue::Unquoted(value)));
state = AttributeListParsingState::ReadingName;
} else {
return Err(AttributeListParsingError::UnexpectedCharacterInAttributeValue);
}
previous_match_index = i;
}
AttributeListParsingState::FinishedReadingQuotedValue { name, value } => {
if byte == b',' {
attribute_list.push((name, AttributeValue::Quoted(value)));
state = AttributeListParsingState::ReadingName;
} else {
return Err(AttributeListParsingError::UnexpectedCharacterAfterQuoteEnd);
}
previous_match_index = i;
}
}
}
match state {
AttributeListParsingState::ReadingName => {
return Err(AttributeListParsingError::EndOfLineWhileReadingAttributeName);
}
AttributeListParsingState::ReadingValue { name } => {
let value = UnquotedAttributeValue(&self.0[(previous_match_index + 1)..]);
if value.0.is_empty() {
return Err(AttributeListParsingError::EmptyUnquotedValue);
}
attribute_list.push((name, AttributeValue::Unquoted(value)));
}
AttributeListParsingState::ReadingQuotedValue { name: _ } => {
return Err(AttributeListParsingError::EndOfLineWhileReadingQuotedValue);
}
AttributeListParsingState::FinishedReadingQuotedValue { name, value } => {
attribute_list.push((name, AttributeValue::Quoted(value)));
}
}
Ok(attribute_list)
}
}
enum AttributeListParsingState<'a> {
ReadingName,
ReadingValue { name: &'a str },
ReadingQuotedValue { name: &'a str },
FinishedReadingQuotedValue { name: &'a str, value: &'a str },
}
#[derive(Debug, PartialEq, Clone, Copy)]
pub enum AttributeValue<'a> {
Unquoted(UnquotedAttributeValue<'a>),
Quoted(&'a str),
}
impl<'a> AttributeValue<'a> {
pub fn unquoted(&self) -> Option<UnquotedAttributeValue<'a>> {
match self {
AttributeValue::Unquoted(v) => Some(*v),
AttributeValue::Quoted(_) => None,
}
}
pub fn quoted(&self) -> Option<&'a str> {
match self {
AttributeValue::Unquoted(_) => None,
AttributeValue::Quoted(s) => Some(*s),
}
}
}
#[derive(Debug, PartialEq, Clone, Copy)]
pub struct UnquotedAttributeValue<'a>(pub &'a [u8]);
impl<'a> UnquotedAttributeValue<'a> {
pub fn try_as_decimal_integer(&self) -> Result<u64, ParseNumberError> {
parse_u64(self.0)
}
pub fn try_as_decimal_floating_point(&self) -> Result<f64, ParseFloatError> {
fast_float2::parse(self.0).map_err(|_| ParseFloatError)
}
pub fn try_as_decimal_resolution(
&self,
) -> Result<DecimalResolution, DecimalResolutionParseError> {
DecimalResolution::try_from(self.0)
}
pub fn try_as_utf_8(&self) -> Result<&'a str, std::str::Utf8Error> {
std::str::from_utf8(self.0)
}
}
#[derive(Debug, PartialEq, Clone, Copy)]
pub enum HlsPlaylistType {
Event,
Vod,
}
#[derive(Debug, PartialEq)]
pub enum WritableTagValue<'a> {
Empty,
DecimalInteger(u64),
DecimalIntegerRange(u64, Option<u64>),
DecimalFloatingPointWithOptionalTitle(f64, Cow<'a, str>),
#[cfg(feature = "chrono")]
DateTime(chrono::DateTime<chrono::FixedOffset>),
#[cfg(not(feature = "chrono"))]
DateTime(DateTime),
AttributeList(HashMap<Cow<'a, str>, WritableAttributeValue<'a>>),
Utf8(Cow<'a, str>),
}
impl From<u64> for WritableTagValue<'_> {
fn from(value: u64) -> Self {
Self::DecimalInteger(value)
}
}
impl From<(u64, Option<u64>)> for WritableTagValue<'_> {
fn from(value: (u64, Option<u64>)) -> Self {
Self::DecimalIntegerRange(value.0, value.1)
}
}
impl<'a, T> From<(f64, T)> for WritableTagValue<'a>
where
T: Into<Cow<'a, str>>,
{
fn from(value: (f64, T)) -> Self {
Self::DecimalFloatingPointWithOptionalTitle(value.0, value.1.into())
}
}
#[cfg(feature = "chrono")]
impl From<chrono::DateTime<chrono::FixedOffset>> for WritableTagValue<'_> {
fn from(value: chrono::DateTime<chrono::FixedOffset>) -> Self {
Self::DateTime(value)
}
}
#[cfg(not(feature = "chrono"))]
impl From<DateTime> for WritableTagValue<'_> {
fn from(value: DateTime) -> Self {
Self::DateTime(value)
}
}
impl<'a, K, V> From<HashMap<K, V>> for WritableTagValue<'a>
where
K: Into<Cow<'a, str>>,
V: Into<WritableAttributeValue<'a>>,
{
fn from(mut value: HashMap<K, V>) -> Self {
let mut map = HashMap::new();
for (key, value) in value.drain() {
map.insert(key.into(), value.into());
}
Self::AttributeList(map)
}
}
impl<'a, K, V, const N: usize> From<[(K, V); N]> for WritableTagValue<'a>
where
K: Into<Cow<'a, str>>,
V: Into<WritableAttributeValue<'a>>,
{
fn from(value: [(K, V); N]) -> Self {
let mut map = HashMap::new();
for (key, value) in value {
map.insert(key.into(), value.into());
}
Self::AttributeList(map)
}
}
impl<'a> From<Cow<'a, str>> for WritableTagValue<'a> {
fn from(value: Cow<'a, str>) -> Self {
Self::Utf8(value)
}
}
impl<'a> From<&'a str> for WritableTagValue<'a> {
fn from(value: &'a str) -> Self {
Self::Utf8(Cow::Borrowed(value))
}
}
impl<'a> From<String> for WritableTagValue<'a> {
fn from(value: String) -> Self {
Self::Utf8(Cow::Owned(value))
}
}
#[derive(Debug, PartialEq, Clone)]
pub enum WritableAttributeValue<'a> {
DecimalInteger(u64),
SignedDecimalFloatingPoint(f64),
DecimalResolution(DecimalResolution),
QuotedString(Cow<'a, str>),
UnquotedString(Cow<'a, str>),
}
impl From<u64> for WritableAttributeValue<'_> {
fn from(value: u64) -> Self {
Self::DecimalInteger(value)
}
}
impl From<f64> for WritableAttributeValue<'_> {
fn from(value: f64) -> Self {
Self::SignedDecimalFloatingPoint(value)
}
}
impl From<DecimalResolution> for WritableAttributeValue<'_> {
fn from(value: DecimalResolution) -> Self {
Self::DecimalResolution(value)
}
}
#[derive(Debug, PartialEq, Clone, Copy)]
pub struct DecimalResolution {
pub width: u64,
pub height: u64,
}
impl Display for DecimalResolution {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}x{}", self.width, self.height)
}
}
impl TryFrom<&[u8]> for DecimalResolution {
type Error = DecimalResolutionParseError;
fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
let Some(i) = memchr(b'x', value) else {
return Err(DecimalResolutionParseError::MissingSeparator);
};
let width =
parse_u64(&value[..i]).map_err(|_| DecimalResolutionParseError::InvalidWidth)?;
let height =
parse_u64(&value[(i + 1)..]).map_err(|_| DecimalResolutionParseError::InvalidHeight)?;
Ok(DecimalResolution { width, height })
}
}
impl TryFrom<&str> for DecimalResolution {
type Error = DecimalResolutionParseError;
fn try_from(s: &str) -> Result<Self, Self::Error> {
Self::try_from(s.as_bytes())
}
}
#[derive(Debug, PartialEq, Clone, Copy)]
pub struct DecimalIntegerRange {
pub length: u64,
pub offset: Option<u64>,
}
impl Display for DecimalIntegerRange {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if let Some(offset) = self.offset {
write!(f, "{}@{}", self.length, offset)
} else {
write!(f, "{}", self.length)
}
}
}
impl TryFrom<&[u8]> for DecimalIntegerRange {
type Error = ParseDecimalIntegerRangeError;
fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
match memchr(b'@', value) {
Some(n) => {
let length =
parse_u64(&value[..n]).map_err(ParseDecimalIntegerRangeError::InvalidLength)?;
let offset = parse_u64(&value[(n + 1)..])
.map_err(ParseDecimalIntegerRangeError::InvalidOffset)?;
Ok(Self {
length,
offset: Some(offset),
})
}
None => parse_u64(value)
.map(|length| Self {
length,
offset: None,
})
.map_err(ParseDecimalIntegerRangeError::InvalidLength),
}
}
}
impl TryFrom<&str> for DecimalIntegerRange {
type Error = ParseDecimalIntegerRangeError;
fn try_from(s: &str) -> Result<Self, Self::Error> {
Self::try_from(s.as_bytes())
}
}
#[cfg(test)]
mod tests {
use crate::date_time;
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn type_enum() {
let value = TagValue(b"EVENT");
assert_eq!(Ok(HlsPlaylistType::Event), value.try_as_playlist_type());
let value = TagValue(b"VOD");
assert_eq!(Ok(HlsPlaylistType::Vod), value.try_as_playlist_type());
}
#[test]
fn decimal_integer() {
let value = TagValue(b"42");
assert_eq!(Ok(42), value.try_as_decimal_integer());
}
#[test]
fn decimal_integer_range() {
let value = TagValue(b"42@42");
assert_eq!(
Ok(DecimalIntegerRange {
length: 42,
offset: Some(42)
}),
value.try_as_decimal_integer_range()
);
}
#[test]
fn decimal_floating_point_with_optional_title() {
let value = TagValue(b"42.0");
assert_eq!(
Ok((42.0, "")),
value.try_as_decimal_floating_point_with_title()
);
let value = TagValue(b"42.42");
assert_eq!(
Ok((42.42, "")),
value.try_as_decimal_floating_point_with_title()
);
let value = TagValue(b"42,");
assert_eq!(
Ok((42.0, "")),
value.try_as_decimal_floating_point_with_title()
);
let value = TagValue(b"42,=ATTRIBUTE-VALUE");
assert_eq!(
Ok((42.0, "=ATTRIBUTE-VALUE")),
value.try_as_decimal_floating_point_with_title()
);
let value = TagValue(b"-42.0");
assert_eq!(
Ok((-42.0, "")),
value.try_as_decimal_floating_point_with_title()
);
let value = TagValue(b"-42.42");
assert_eq!(
Ok((-42.42, "")),
value.try_as_decimal_floating_point_with_title()
);
let value = TagValue(b"-42,");
assert_eq!(
Ok((-42.0, "")),
value.try_as_decimal_floating_point_with_title()
);
let value = TagValue(b"-42,=ATTRIBUTE-VALUE");
assert_eq!(
Ok((-42.0, "=ATTRIBUTE-VALUE")),
value.try_as_decimal_floating_point_with_title()
);
}
#[test]
fn date_time_msec() {
let value = TagValue(b"2025-06-03T17:56:42.123Z");
assert_eq!(
Ok(date_time!(2025-06-03 T 17:56:42.123)),
value.try_as_date_time(),
);
let value = TagValue(b"2025-06-03T17:56:42.123+01:00");
assert_eq!(
Ok(date_time!(2025-06-03 T 17:56:42.123 01:00)),
value.try_as_date_time(),
);
let value = TagValue(b"2025-06-03T17:56:42.123-05:00");
assert_eq!(
Ok(date_time!(2025-06-03 T 17:56:42.123 -05:00)),
value.try_as_date_time(),
);
}
mod attribute_list {
use super::*;
macro_rules! unquoted_value_test {
(TagValue is $tag_value:literal $($name_lit:literal=$val:literal expects $exp:literal from $method:ident)+) => {
let value = TagValue($tag_value);
assert_eq!(
value.try_as_attribute_list().expect("should be valid list"),
HashMap::from([
$(
($name_lit, AttributeValue::Unquoted(UnquotedAttributeValue($val))),
)+
])
);
assert_eq!(
value.try_as_ordered_attribute_list().expect("should be valid ordered list"),
vec![
$(
($name_lit, AttributeValue::Unquoted(UnquotedAttributeValue($val))),
)+
]
);
$(
assert_eq!(Ok($exp), UnquotedAttributeValue($val).$method());
)+
};
}
macro_rules! quoted_value_test {
(TagValue is $tag_value:literal $($name_lit:literal expects $exp:literal)+) => {
let value = TagValue($tag_value);
assert_eq!(
value.try_as_attribute_list().expect("should be valid list"),
HashMap::from([
$(
($name_lit, AttributeValue::Quoted($exp)),
)+
])
);
assert_eq!(
value.try_as_ordered_attribute_list().expect("should be valid list"),
vec![
$(
($name_lit, AttributeValue::Quoted($exp)),
)+
]
);
};
}
mod decimal_integer {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn single_attribute() {
unquoted_value_test!(
TagValue is b"NAME=123"
"NAME"=b"123" expects 123 from try_as_decimal_integer
);
}
#[test]
fn multi_attributes() {
unquoted_value_test!(
TagValue is b"NAME=123,NEXT-NAME=456"
"NAME"=b"123" expects 123 from try_as_decimal_integer
"NEXT-NAME"=b"456" expects 456 from try_as_decimal_integer
);
}
}
mod signed_decimal_floating_point {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn positive_float_single_attribute() {
unquoted_value_test!(
TagValue is b"NAME=42.42"
"NAME"=b"42.42" expects 42.42 from try_as_decimal_floating_point
);
}
#[test]
fn negative_integer_single_attribute() {
unquoted_value_test!(
TagValue is b"NAME=-42"
"NAME"=b"-42" expects -42.0 from try_as_decimal_floating_point
);
}
#[test]
fn negative_float_single_attribute() {
unquoted_value_test!(
TagValue is b"NAME=-42.42"
"NAME"=b"-42.42" expects -42.42 from try_as_decimal_floating_point
);
}
#[test]
fn positive_float_multi_attributes() {
unquoted_value_test!(
TagValue is b"NAME=42.42,NEXT-NAME=84.84"
"NAME"=b"42.42" expects 42.42 from try_as_decimal_floating_point
"NEXT-NAME"=b"84.84" expects 84.84 from try_as_decimal_floating_point
);
}
#[test]
fn negative_integer_multi_attributes() {
unquoted_value_test!(
TagValue is b"NAME=-42,NEXT-NAME=-84"
"NAME"=b"-42" expects -42.0 from try_as_decimal_floating_point
"NEXT-NAME"=b"-84" expects -84.0 from try_as_decimal_floating_point
);
}
#[test]
fn negative_float_multi_attributes() {
unquoted_value_test!(
TagValue is b"NAME=-42.42,NEXT-NAME=-84.84"
"NAME"=b"-42.42" expects -42.42 from try_as_decimal_floating_point
"NEXT-NAME"=b"-84.84" expects -84.84 from try_as_decimal_floating_point
);
}
}
mod quoted_string {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn single_attribute() {
quoted_value_test!(
TagValue is b"NAME=\"Hello, World!\""
"NAME" expects "Hello, World!"
);
}
#[test]
fn multi_attributes() {
quoted_value_test!(
TagValue is b"NAME=\"Hello,\",NEXT-NAME=\"World!\""
"NAME" expects "Hello,"
"NEXT-NAME" expects "World!"
);
}
}
mod unquoted_string {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn single_attribute() {
unquoted_value_test!(
TagValue is b"NAME=PQ"
"NAME"=b"PQ" expects "PQ" from try_as_utf_8
);
}
#[test]
fn multi_attributes() {
unquoted_value_test!(
TagValue is b"NAME=PQ,NEXT-NAME=HLG"
"NAME"=b"PQ" expects "PQ" from try_as_utf_8
"NEXT-NAME"=b"HLG" expects "HLG" from try_as_utf_8
);
}
}
}
}