694 lines
21 KiB
Rust
694 lines
21 KiB
Rust
//! [`Filter`]s for querying the database and playlists.
|
|
//!
|
|
//! The filter syntax uses a context-free grammar, and has its own
|
|
//! set of parsers and parsing errors.
|
|
|
|
use std::fmt;
|
|
|
|
use chrono::{DateTime, Utc};
|
|
use lalrpop_util::lalrpop_mod;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::{
|
|
commands::RequestParserError,
|
|
types::{Priority, Tag},
|
|
};
|
|
|
|
/// Represents the case sensitivity of a string comparison,
|
|
/// used in multiple filter variants.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub enum CaseSensitivity {
|
|
CaseSensitive,
|
|
CaseInsensitive,
|
|
CommandDependent,
|
|
}
|
|
|
|
/// Represents a comparison operator for priority comparisons.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub enum ComparisonOperator {
|
|
Equal,
|
|
NotEqual,
|
|
GreaterThan,
|
|
GreaterThanOrEqual,
|
|
LessThan,
|
|
LessThanOrEqual,
|
|
}
|
|
|
|
/// Represents a filter expression for querying the music database.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub enum Filter {
|
|
/// Logical NOT of a filter. e.g.
|
|
///
|
|
/// `(!(Artist == "The Beatles"))`
|
|
Not(Box<Filter>),
|
|
|
|
/// Logical AND of multiple filters. e.g.
|
|
///
|
|
/// `((Artist == "The Beatles") AND (Album == "Abbey Road") AND (Title == "Come Together"))`
|
|
And(Vec<Filter>),
|
|
|
|
/// Equality comparison on a tag. e.g.
|
|
///
|
|
/// `(Artist == "The Beatles")` or `(Album != "Greatest Hits")`
|
|
///
|
|
/// The bool indicates whether the comparison is negated (true for !=, false for ==)
|
|
EqTag(Tag, CaseSensitivity, bool),
|
|
|
|
/// Substring containment on a tag. e.g.
|
|
///
|
|
/// `(Title contains "Symphony")` or `(Album !contains "Live")`
|
|
///
|
|
/// The bool indicates whether the comparison is negated (true for !contains, false for contains)
|
|
Contains(Tag, CaseSensitivity, bool),
|
|
|
|
/// Prefix matching on a tag. e.g.
|
|
///
|
|
/// `(Title starts_with "Symphony")` or `(Album !starts_with "Live")`
|
|
////
|
|
/// The bool indicates whether the comparison is negated (true for !starts_with, false for starts_with)
|
|
StartsWith(Tag, CaseSensitivity, bool),
|
|
|
|
/// Perl-compatible regular expression matching on a tag. e.g.
|
|
///
|
|
/// `(Composer =~ "Beethoven.*")` or `(Genre !~ "Pop.*")`
|
|
///
|
|
/// The bool indicates whether the comparison is negated (true for !~, false for =~)
|
|
PerlRegex(Tag, bool),
|
|
|
|
/// Equality comparison on the URI. e.g.
|
|
///
|
|
/// `(uri == "Rock/Classics/track01.mp3")`
|
|
EqUri(String),
|
|
|
|
/// Base path filter. e.g.
|
|
///
|
|
/// `(base "Rock/Classics")`
|
|
Base(String),
|
|
|
|
/// Filter for files added since the given timestamp. e.g.
|
|
///
|
|
/// `(added-since '1622505600')` or `(added-since '2021-06-01T00:00:00Z')`
|
|
AddedSince(DateTime<Utc>),
|
|
|
|
/// Filter for files modified since the given timestamp. e.g.
|
|
///
|
|
/// `(modified-since '1622505600')` or `(modified-since '2021-06-01T00:00:00Z')`
|
|
ModifiedSince(DateTime<Utc>),
|
|
|
|
/// Equality comparison on audio format. e.g.
|
|
///
|
|
/// `(AudioFormat == '44100:16:2')`
|
|
AudioFormatEq {
|
|
sample_rate: u32,
|
|
bits: u8,
|
|
channels: u8,
|
|
},
|
|
|
|
/// Masked equality comparison on audio format. e.g.
|
|
///
|
|
/// `(AudioFormat =~ '44100:*:2')`
|
|
AudioFormatEqMask {
|
|
sample_rate: Option<u32>,
|
|
bits: Option<u8>,
|
|
channels: Option<u8>,
|
|
},
|
|
|
|
/// Priority comparison. e.g.
|
|
///
|
|
/// `(prio >= 42)` or `(prio != 10)`
|
|
PrioCmp(ComparisonOperator, Priority),
|
|
}
|
|
|
|
fn quote_string(s: &str) -> String {
|
|
let mut result = String::new();
|
|
result.push('"');
|
|
for c in s.chars() {
|
|
match c {
|
|
'\n' => result.push_str(r"\n"),
|
|
'\t' => result.push_str(r"\t"),
|
|
'\r' => result.push_str(r"\r"),
|
|
'\\' => result.push_str(r"\\"),
|
|
'"' => result.push_str(r#"\""#),
|
|
other => result.push(other),
|
|
}
|
|
}
|
|
result.push('"');
|
|
result
|
|
}
|
|
|
|
impl fmt::Display for Filter {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Filter::Not(inner) => write!(f, "(!{})", inner),
|
|
Filter::And(inner) => write!(
|
|
f,
|
|
"({})",
|
|
inner
|
|
.iter()
|
|
.map(|f| format!("{}", f))
|
|
.collect::<Vec<_>>()
|
|
.join(" AND "),
|
|
),
|
|
Filter::EqTag(tag, case_sensitivity, negated) => {
|
|
let op = match (negated, case_sensitivity) {
|
|
(false, CaseSensitivity::CommandDependent) => "==",
|
|
(true, CaseSensitivity::CommandDependent) => "!=",
|
|
(false, CaseSensitivity::CaseSensitive) => "eq_cs",
|
|
(true, CaseSensitivity::CaseSensitive) => "!eq_cs",
|
|
(false, CaseSensitivity::CaseInsensitive) => "eq_ci",
|
|
(true, CaseSensitivity::CaseInsensitive) => "!eq_ci",
|
|
};
|
|
write!(f, "({} {} {})", tag.key(), op, quote_string(tag.value()))
|
|
}
|
|
Filter::Contains(tag, case_sensitivity, negated) => {
|
|
let op = if *negated { "!contains" } else { "contains" };
|
|
let cs = match case_sensitivity {
|
|
CaseSensitivity::CaseSensitive => "_cs",
|
|
CaseSensitivity::CaseInsensitive => "_ci",
|
|
CaseSensitivity::CommandDependent => "",
|
|
};
|
|
write!(
|
|
f,
|
|
"({} {}{} {})",
|
|
tag.key(),
|
|
op,
|
|
cs,
|
|
quote_string(tag.value())
|
|
)
|
|
}
|
|
Filter::StartsWith(tag, case_sensitivity, negated) => {
|
|
let op = if *negated {
|
|
"!starts_with"
|
|
} else {
|
|
"starts_with"
|
|
};
|
|
let cs = match case_sensitivity {
|
|
CaseSensitivity::CaseSensitive => "_cs",
|
|
CaseSensitivity::CaseInsensitive => "_ci",
|
|
CaseSensitivity::CommandDependent => "",
|
|
};
|
|
write!(
|
|
f,
|
|
"({} {}{} {})",
|
|
tag.key(),
|
|
op,
|
|
cs,
|
|
quote_string(tag.value())
|
|
)
|
|
}
|
|
Filter::PerlRegex(tag, negated) => {
|
|
if *negated {
|
|
write!(f, "({} !~ {})", tag.key(), quote_string(tag.value()))
|
|
} else {
|
|
write!(f, "({} =~ {})", tag.key(), quote_string(tag.value()))
|
|
}
|
|
}
|
|
Filter::EqUri(uri) => {
|
|
write!(f, "(uri == {})", quote_string(uri))
|
|
}
|
|
Filter::Base(path) => {
|
|
write!(f, "(base {})", quote_string(path))
|
|
}
|
|
Filter::ModifiedSince(timestamp) => {
|
|
write!(
|
|
f,
|
|
"(modified-since {})",
|
|
quote_string(×tamp.timestamp().to_string())
|
|
)
|
|
}
|
|
Filter::AddedSince(timestamp) => {
|
|
write!(
|
|
f,
|
|
"(added-since {})",
|
|
quote_string(×tamp.timestamp().to_string())
|
|
)
|
|
}
|
|
Filter::AudioFormatEq {
|
|
sample_rate,
|
|
bits,
|
|
channels,
|
|
} => {
|
|
write!(
|
|
f,
|
|
"(AudioFormat == '{}:{}:{}')",
|
|
sample_rate, bits, channels
|
|
)
|
|
}
|
|
Filter::AudioFormatEqMask {
|
|
sample_rate,
|
|
bits,
|
|
channels,
|
|
} => {
|
|
let sr_str = match sample_rate {
|
|
Some(sr) => sr.to_string(),
|
|
None => "*".to_string(),
|
|
};
|
|
let bits_str = match bits {
|
|
Some(b) => b.to_string(),
|
|
None => "*".to_string(),
|
|
};
|
|
let ch_str = match channels {
|
|
Some(c) => c.to_string(),
|
|
None => "*".to_string(),
|
|
};
|
|
write!(f, "(AudioFormat =~ '{}:{}:{}')", sr_str, bits_str, ch_str)
|
|
}
|
|
Filter::PrioCmp(op, prio) => {
|
|
let op_str = match op {
|
|
ComparisonOperator::Equal => "==",
|
|
ComparisonOperator::NotEqual => "!=",
|
|
ComparisonOperator::GreaterThan => ">",
|
|
ComparisonOperator::GreaterThanOrEqual => ">=",
|
|
ComparisonOperator::LessThan => "<",
|
|
ComparisonOperator::LessThanOrEqual => "<=",
|
|
};
|
|
write!(f, "(prio {} {})", op_str, prio)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(super) fn unescape_string(s: &str) -> String {
|
|
let mut result = String::new();
|
|
let mut chars = s.chars();
|
|
while let Some(c) = chars.next() {
|
|
if c == '\\' {
|
|
if let Some(escaped) = chars.next() {
|
|
match escaped {
|
|
'n' => result.push('\n'),
|
|
't' => result.push('\t'),
|
|
'r' => result.push('\r'),
|
|
'\\' => result.push('\\'),
|
|
'"' => result.push('"'),
|
|
other => {
|
|
result.push('\\');
|
|
result.push(other);
|
|
}
|
|
}
|
|
} else {
|
|
result.push('\\');
|
|
}
|
|
} else {
|
|
result.push(c);
|
|
}
|
|
}
|
|
result
|
|
}
|
|
|
|
lalrpop_mod!(filter_grammar, "/filter_grammar.rs");
|
|
|
|
impl Filter {
|
|
pub fn parse(input: &str) -> Result<Filter, RequestParserError> {
|
|
let parser = filter_grammar::ExpressionParser::new();
|
|
// println!("Parsing filter: {:#?}", parser.parse(input));
|
|
parser
|
|
.parse(input)
|
|
.map_err(|e| RequestParserError::SyntaxError(0, format!("{}", e)))
|
|
}
|
|
}
|
|
|
|
// TODO: There is a significant amount of error handling to be improved and tested here.
|
|
|
|
// TODO: thiserror
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub enum FilterParserError<'a> {
|
|
/// Could not parse the response due to a syntax error.
|
|
SyntaxError(usize, &'a str),
|
|
|
|
/// Audio format string is invalid.
|
|
InvalidAudioFormat,
|
|
|
|
/// Parentheses are unbalanced.
|
|
UnbalancedParentheses,
|
|
|
|
/// String literal is not closed.
|
|
UnclosedStringLiteral,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use chrono::DateTime;
|
|
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_parse_filter_eq_tag() {
|
|
let filter = Filter::parse(r#"(artist == "The Beatles")"#);
|
|
assert_eq!(
|
|
filter,
|
|
Ok(Filter::EqTag(
|
|
Tag::Artist("The Beatles".to_string()),
|
|
CaseSensitivity::CommandDependent,
|
|
false,
|
|
)),
|
|
);
|
|
|
|
let filter = Filter::parse(r#"(artist != "The Beatles")"#);
|
|
assert_eq!(
|
|
filter,
|
|
Ok(Filter::EqTag(
|
|
Tag::Artist("The Beatles".to_string()),
|
|
CaseSensitivity::CommandDependent,
|
|
true,
|
|
)),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_filter_contains() {
|
|
let filter = Filter::parse(r#"(album contains "Greatest Hits")"#);
|
|
assert_eq!(
|
|
filter,
|
|
Ok(Filter::Contains(
|
|
Tag::Album("Greatest Hits".to_string()),
|
|
CaseSensitivity::CommandDependent,
|
|
false,
|
|
))
|
|
);
|
|
|
|
let filter = Filter::parse(r#"(album !contains "Greatest Hits")"#);
|
|
assert_eq!(
|
|
filter,
|
|
Ok(Filter::Contains(
|
|
Tag::Album("Greatest Hits".to_string()),
|
|
CaseSensitivity::CommandDependent,
|
|
true,
|
|
)),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_filter_starts_with() {
|
|
let filter = Filter::parse(r#"(title starts_with "Symphony No. ")"#);
|
|
assert_eq!(
|
|
filter,
|
|
Ok(Filter::StartsWith(
|
|
Tag::Title("Symphony No. ".to_string()),
|
|
CaseSensitivity::CommandDependent,
|
|
false,
|
|
)),
|
|
);
|
|
|
|
let filter = Filter::parse(r#"(title !starts_with "Symphony No. ")"#);
|
|
assert_eq!(
|
|
filter,
|
|
Ok(Filter::StartsWith(
|
|
Tag::Title("Symphony No. ".to_string()),
|
|
CaseSensitivity::CommandDependent,
|
|
true,
|
|
)),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_filter_perl_regex_positive() {
|
|
let filter = Filter::parse(r#"(composer =~ "Beethoven.*")"#);
|
|
assert_eq!(
|
|
filter,
|
|
Ok(Filter::PerlRegex(
|
|
Tag::Composer("Beethoven.*".to_string()),
|
|
false,
|
|
)),
|
|
);
|
|
|
|
let filter = Filter::parse(r#"(genre !~ "Pop.*")"#);
|
|
assert_eq!(
|
|
filter,
|
|
Ok(Filter::PerlRegex(Tag::Genre("Pop.*".to_string()), true)),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_filter_base() {
|
|
let filter = Filter::parse(r#"(base "Rock/Classics")"#);
|
|
assert_eq!(filter, Ok(Filter::Base("Rock/Classics".to_string())));
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_filter_modified_since() {
|
|
let filter = Filter::parse(r#"(modified-since '1622505600')"#);
|
|
assert_eq!(
|
|
filter,
|
|
Ok(Filter::ModifiedSince(
|
|
DateTime::from_timestamp(1622505600, 0).unwrap()
|
|
))
|
|
);
|
|
|
|
let date_time = DateTime::from_timestamp(1622505600, 0).unwrap();
|
|
let iso8601_str = date_time.to_rfc3339();
|
|
let filter = Filter::parse(format!(r#"(modified-since "{}")"#, iso8601_str).as_str());
|
|
assert_eq!(filter, Ok(Filter::ModifiedSince(date_time)));
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_filter_audio_added_since() {
|
|
let filter = Filter::parse(r#"(added-since '1622505600')"#);
|
|
assert_eq!(
|
|
filter,
|
|
Ok(Filter::AddedSince(
|
|
DateTime::from_timestamp(1622505600, 0).unwrap()
|
|
))
|
|
);
|
|
|
|
let date_time = DateTime::from_timestamp(1622505600, 0).unwrap();
|
|
let iso8601_str = date_time.to_rfc3339();
|
|
let filter = Filter::parse(format!(r#"(added-since "{}")"#, iso8601_str).as_str());
|
|
assert_eq!(filter, Ok(Filter::AddedSince(date_time)));
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_filter_audio_format_eq() {
|
|
let filter = Filter::parse(r#"(AudioFormat == 44100:16:2)"#);
|
|
assert_eq!(
|
|
filter,
|
|
Ok(Filter::AudioFormatEq {
|
|
sample_rate: 44100,
|
|
bits: 16,
|
|
channels: 2,
|
|
}),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_filter_audio_format_eq_mask() {
|
|
let filter = Filter::parse(r#"(AudioFormat =~ 44100:*:2)"#);
|
|
assert_eq!(
|
|
filter,
|
|
Ok(Filter::AudioFormatEqMask {
|
|
sample_rate: Some(44100),
|
|
bits: None,
|
|
channels: Some(2),
|
|
}),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_filter_prio_cmp() {
|
|
for (op_str, op_enum) in &[
|
|
(">", ComparisonOperator::GreaterThan),
|
|
(">=", ComparisonOperator::GreaterThanOrEqual),
|
|
("<", ComparisonOperator::LessThan),
|
|
("<=", ComparisonOperator::LessThanOrEqual),
|
|
("==", ComparisonOperator::Equal),
|
|
("!=", ComparisonOperator::NotEqual),
|
|
] {
|
|
let filter_str = format!(r#"(prio {} 42)"#, op_str);
|
|
let filter = Filter::parse(&filter_str);
|
|
assert_eq!(filter, Ok(Filter::PrioCmp(op_enum.clone(), 42)),);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_filter_not() {
|
|
let filter = Filter::parse(r#"(!(artist == "The Beatles"))"#);
|
|
assert_eq!(
|
|
filter,
|
|
Ok(Filter::Not(Box::new(Filter::EqTag(
|
|
Tag::Artist("The Beatles".to_string()),
|
|
CaseSensitivity::CommandDependent,
|
|
false,
|
|
)))),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_filter_and() {
|
|
let filter = Filter::parse(
|
|
r#"((artist == "The Beatles") AND (album == "Abbey Road") AND (title == "Come Together"))"#,
|
|
);
|
|
assert_eq!(
|
|
filter,
|
|
Ok(Filter::And(vec![
|
|
Filter::EqTag(
|
|
Tag::Artist("The Beatles".to_string()),
|
|
CaseSensitivity::CommandDependent,
|
|
false,
|
|
),
|
|
Filter::EqTag(
|
|
Tag::Album("Abbey Road".to_string()),
|
|
CaseSensitivity::CommandDependent,
|
|
false,
|
|
),
|
|
Filter::EqTag(
|
|
Tag::Title("Come Together".to_string()),
|
|
CaseSensitivity::CommandDependent,
|
|
false,
|
|
),
|
|
])),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_filter_explicitly_case_sensitive() {
|
|
let filter = Filter::parse(r#"(artist eq_cs "The Beatles")"#);
|
|
assert_eq!(
|
|
filter,
|
|
Ok(Filter::EqTag(
|
|
Tag::Artist("The Beatles".to_string()),
|
|
CaseSensitivity::CaseSensitive,
|
|
false,
|
|
)),
|
|
);
|
|
|
|
let filter = Filter::parse(r#"(artist !eq_cs "The Beatles")"#);
|
|
assert_eq!(
|
|
filter,
|
|
Ok(Filter::EqTag(
|
|
Tag::Artist("The Beatles".to_string()),
|
|
CaseSensitivity::CaseSensitive,
|
|
true,
|
|
)),
|
|
);
|
|
|
|
let filter = Filter::parse(r#"(album contains_cs "Greatest Hits")"#);
|
|
assert_eq!(
|
|
filter,
|
|
Ok(Filter::Contains(
|
|
Tag::Album("Greatest Hits".to_string()),
|
|
CaseSensitivity::CaseSensitive,
|
|
false,
|
|
)),
|
|
);
|
|
|
|
let filter = Filter::parse(r#"(album !contains_cs "Greatest Hits")"#);
|
|
assert_eq!(
|
|
filter,
|
|
Ok(Filter::Contains(
|
|
Tag::Album("Greatest Hits".to_string()),
|
|
CaseSensitivity::CaseSensitive,
|
|
true,
|
|
)),
|
|
);
|
|
|
|
let filter = Filter::parse(r#"(title starts_with_cs "Symphony No. ")"#);
|
|
assert_eq!(
|
|
filter,
|
|
Ok(Filter::StartsWith(
|
|
Tag::Title("Symphony No. ".to_string()),
|
|
CaseSensitivity::CaseSensitive,
|
|
false,
|
|
)),
|
|
);
|
|
|
|
let filter = Filter::parse(r#"(title !starts_with_cs "Symphony No. ")"#);
|
|
assert_eq!(
|
|
filter,
|
|
Ok(Filter::StartsWith(
|
|
Tag::Title("Symphony No. ".to_string()),
|
|
CaseSensitivity::CaseSensitive,
|
|
true,
|
|
)),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_filter_explicitly_case_insensitive() {
|
|
let filter = Filter::parse(r#"(artist eq_ci "The Beatles")"#);
|
|
assert_eq!(
|
|
filter,
|
|
Ok(Filter::EqTag(
|
|
Tag::Artist("The Beatles".to_string()),
|
|
CaseSensitivity::CaseInsensitive,
|
|
false,
|
|
)),
|
|
);
|
|
|
|
let filter = Filter::parse(r#"(artist !eq_ci "The Beatles")"#);
|
|
assert_eq!(
|
|
filter,
|
|
Ok(Filter::EqTag(
|
|
Tag::Artist("The Beatles".to_string()),
|
|
CaseSensitivity::CaseInsensitive,
|
|
true,
|
|
)),
|
|
);
|
|
|
|
let filter = Filter::parse(r#"(album contains_ci "Greatest Hits")"#);
|
|
assert_eq!(
|
|
filter,
|
|
Ok(Filter::Contains(
|
|
Tag::Album("Greatest Hits".to_string()),
|
|
CaseSensitivity::CaseInsensitive,
|
|
false,
|
|
)),
|
|
);
|
|
|
|
let filter = Filter::parse(r#"(album !contains_ci "Greatest Hits")"#);
|
|
assert_eq!(
|
|
filter,
|
|
Ok(Filter::Contains(
|
|
Tag::Album("Greatest Hits".to_string()),
|
|
CaseSensitivity::CaseInsensitive,
|
|
true,
|
|
)),
|
|
);
|
|
|
|
let filter = Filter::parse(r#"(title starts_with_ci "Symphony No. ")"#);
|
|
assert_eq!(
|
|
filter,
|
|
Ok(Filter::StartsWith(
|
|
Tag::Title("Symphony No. ".to_string()),
|
|
CaseSensitivity::CaseInsensitive,
|
|
false,
|
|
)),
|
|
);
|
|
|
|
let filter = Filter::parse(r#"(title !starts_with_ci "Symphony No. ")"#);
|
|
assert_eq!(
|
|
filter,
|
|
Ok(Filter::StartsWith(
|
|
Tag::Title("Symphony No. ".to_string()),
|
|
CaseSensitivity::CaseInsensitive,
|
|
true,
|
|
)),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_filter_string_escapes() {
|
|
let filter = Filter::parse(r#"(Artist == "\"foo\\'bar\\\"")"#);
|
|
assert_eq!(
|
|
filter,
|
|
Ok(Filter::EqTag(
|
|
Tag::Artist(r#""foo\'bar\""#.to_string()),
|
|
CaseSensitivity::CommandDependent,
|
|
false,
|
|
)),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_filter_excessive_whitespace() {
|
|
let filter = Filter::parse("(\tartist\n == \"The Beatles\" ) ");
|
|
assert_eq!(
|
|
filter,
|
|
Ok(Filter::EqTag(
|
|
Tag::Artist("The Beatles".to_string()),
|
|
CaseSensitivity::CommandDependent,
|
|
false,
|
|
)),
|
|
);
|
|
}
|
|
}
|