1
//! [`Filter`]s for querying the database and playlists.
2
//!
3
//! The filter syntax uses a context-free grammar, and has its own
4
//! set of parsers and parsing errors.
5

            
6
use std::fmt;
7

            
8
use chrono::{DateTime, Utc};
9
use lalrpop_util::lalrpop_mod;
10
use serde::{Deserialize, Serialize};
11

            
12
use crate::{
13
    commands::RequestParserError,
14
    types::{Priority, Tag},
15
};
16

            
17
/// Represents the case sensitivity of a string comparison,
18
/// used in multiple filter variants.
19
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
20
pub enum CaseSensitivity {
21
    CaseSensitive,
22
    CaseInsensitive,
23
    CommandDependent,
24
}
25

            
26
/// Represents a comparison operator for priority comparisons.
27
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28
pub enum ComparisonOperator {
29
    Equal,
30
    NotEqual,
31
    GreaterThan,
32
    GreaterThanOrEqual,
33
    LessThan,
34
    LessThanOrEqual,
35
}
36

            
37
/// Represents a filter expression for querying the music database.
38
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39
pub enum Filter {
40
    /// Logical NOT of a filter. e.g.
41
    ///
42
    /// `(!(Artist == "The Beatles"))`
43
    Not(Box<Filter>),
44

            
45
    /// Logical AND of multiple filters. e.g.
46
    ///
47
    /// `((Artist == "The Beatles") AND (Album == "Abbey Road") AND (Title == "Come Together"))`
48
    And(Vec<Filter>),
49

            
50
    /// Equality comparison on a tag. e.g.
51
    ///
52
    /// `(Artist == "The Beatles")` or `(Album != "Greatest Hits")`
53
    ///
54
    /// The bool indicates whether the comparison is negated (true for !=, false for ==)
55
    EqTag(Tag, CaseSensitivity, bool),
56

            
57
    /// Substring containment on a tag. e.g.
58
    ///
59
    /// `(Title contains "Symphony")` or `(Album !contains "Live")`
60
    ///
61
    /// The bool indicates whether the comparison is negated (true for !contains, false for contains)
62
    Contains(Tag, CaseSensitivity, bool),
63

            
64
    /// Prefix matching on a tag. e.g.
65
    ///
66
    /// `(Title starts_with "Symphony")` or `(Album !starts_with "Live")`
67
    ////
68
    /// The bool indicates whether the comparison is negated (true for !starts_with, false for starts_with)
69
    StartsWith(Tag, CaseSensitivity, bool),
70

            
71
    /// Perl-compatible regular expression matching on a tag. e.g.
72
    ///
73
    /// `(Composer =~ "Beethoven.*")` or `(Genre !~ "Pop.*")`
74
    ///
75
    /// The bool indicates whether the comparison is negated (true for !~, false for =~)
76
    PerlRegex(Tag, bool),
77

            
78
    /// Equality comparison on the URI. e.g.
79
    ///
80
    /// `(uri == "Rock/Classics/track01.mp3")`
81
    EqUri(String),
82

            
83
    /// Base path filter. e.g.
84
    ///
85
    /// `(base "Rock/Classics")`
86
    Base(String),
87

            
88
    /// Filter for files added since the given timestamp. e.g.
89
    ///
90
    /// `(added-since '1622505600')` or `(added-since '2021-06-01T00:00:00Z')`
91
    AddedSince(DateTime<Utc>),
92

            
93
    /// Filter for files modified since the given timestamp. e.g.
94
    ///
95
    /// `(modified-since '1622505600')` or `(modified-since '2021-06-01T00:00:00Z')`
96
    ModifiedSince(DateTime<Utc>),
97

            
98
    /// Equality comparison on audio format. e.g.
99
    ///
100
    /// `(AudioFormat == '44100:16:2')`
101
    AudioFormatEq {
102
        sample_rate: u32,
103
        bits: u8,
104
        channels: u8,
105
    },
106

            
107
    /// Masked equality comparison on audio format. e.g.
108
    ///
109
    /// `(AudioFormat =~ '44100:*:2')`
110
    AudioFormatEqMask {
111
        sample_rate: Option<u32>,
112
        bits: Option<u8>,
113
        channels: Option<u8>,
114
    },
115

            
116
    /// Priority comparison. e.g.
117
    ///
118
    /// `(prio >= 42)` or `(prio != 10)`
119
    PrioCmp(ComparisonOperator, Priority),
120
}
121

            
122
fn quote_string(s: &str) -> String {
123
    let mut result = String::new();
124
    result.push('"');
125
    for c in s.chars() {
126
        match c {
127
            '\n' => result.push_str(r"\n"),
128
            '\t' => result.push_str(r"\t"),
129
            '\r' => result.push_str(r"\r"),
130
            '\\' => result.push_str(r"\\"),
131
            '"' => result.push_str(r#"\""#),
132
            other => result.push(other),
133
        }
134
    }
135
    result.push('"');
136
    result
137
}
138

            
139
impl fmt::Display for Filter {
140
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
141
        match self {
142
            Filter::Not(inner) => write!(f, "(!{})", inner),
143
            Filter::And(inner) => write!(
144
                f,
145
                "({})",
146
                inner
147
                    .iter()
148
                    .map(|f| format!("{}", f))
149
                    .collect::<Vec<_>>()
150
                    .join(" AND "),
151
            ),
152
            Filter::EqTag(tag, case_sensitivity, negated) => {
153
                let op = match (negated, case_sensitivity) {
154
                    (false, CaseSensitivity::CommandDependent) => "==",
155
                    (true, CaseSensitivity::CommandDependent) => "!=",
156
                    (false, CaseSensitivity::CaseSensitive) => "eq_cs",
157
                    (true, CaseSensitivity::CaseSensitive) => "!eq_cs",
158
                    (false, CaseSensitivity::CaseInsensitive) => "eq_ci",
159
                    (true, CaseSensitivity::CaseInsensitive) => "!eq_ci",
160
                };
161
                write!(f, "({} {} {})", tag.key(), op, quote_string(tag.value()))
162
            }
163
            Filter::Contains(tag, case_sensitivity, negated) => {
164
                let op = if *negated { "!contains" } else { "contains" };
165
                let cs = match case_sensitivity {
166
                    CaseSensitivity::CaseSensitive => "_cs",
167
                    CaseSensitivity::CaseInsensitive => "_ci",
168
                    CaseSensitivity::CommandDependent => "",
169
                };
170
                write!(
171
                    f,
172
                    "({} {}{} {})",
173
                    tag.key(),
174
                    op,
175
                    cs,
176
                    quote_string(tag.value())
177
                )
178
            }
179
            Filter::StartsWith(tag, case_sensitivity, negated) => {
180
                let op = if *negated {
181
                    "!starts_with"
182
                } else {
183
                    "starts_with"
184
                };
185
                let cs = match case_sensitivity {
186
                    CaseSensitivity::CaseSensitive => "_cs",
187
                    CaseSensitivity::CaseInsensitive => "_ci",
188
                    CaseSensitivity::CommandDependent => "",
189
                };
190
                write!(
191
                    f,
192
                    "({} {}{} {})",
193
                    tag.key(),
194
                    op,
195
                    cs,
196
                    quote_string(tag.value())
197
                )
198
            }
199
            Filter::PerlRegex(tag, negated) => {
200
                if *negated {
201
                    write!(f, "({} !~ {})", tag.key(), quote_string(tag.value()))
202
                } else {
203
                    write!(f, "({} =~ {})", tag.key(), quote_string(tag.value()))
204
                }
205
            }
206
            Filter::EqUri(uri) => {
207
                write!(f, "(uri == {})", quote_string(uri))
208
            }
209
            Filter::Base(path) => {
210
                write!(f, "(base {})", quote_string(path))
211
            }
212
            Filter::ModifiedSince(timestamp) => {
213
                write!(
214
                    f,
215
                    "(modified-since {})",
216
                    quote_string(&timestamp.timestamp().to_string())
217
                )
218
            }
219
            Filter::AddedSince(timestamp) => {
220
                write!(
221
                    f,
222
                    "(added-since {})",
223
                    quote_string(&timestamp.timestamp().to_string())
224
                )
225
            }
226
            Filter::AudioFormatEq {
227
                sample_rate,
228
                bits,
229
                channels,
230
            } => {
231
                write!(
232
                    f,
233
                    "(AudioFormat == '{}:{}:{}')",
234
                    sample_rate, bits, channels
235
                )
236
            }
237
            Filter::AudioFormatEqMask {
238
                sample_rate,
239
                bits,
240
                channels,
241
            } => {
242
                let sr_str = match sample_rate {
243
                    Some(sr) => sr.to_string(),
244
                    None => "*".to_string(),
245
                };
246
                let bits_str = match bits {
247
                    Some(b) => b.to_string(),
248
                    None => "*".to_string(),
249
                };
250
                let ch_str = match channels {
251
                    Some(c) => c.to_string(),
252
                    None => "*".to_string(),
253
                };
254
                write!(f, "(AudioFormat =~ '{}:{}:{}')", sr_str, bits_str, ch_str)
255
            }
256
            Filter::PrioCmp(op, prio) => {
257
                let op_str = match op {
258
                    ComparisonOperator::Equal => "==",
259
                    ComparisonOperator::NotEqual => "!=",
260
                    ComparisonOperator::GreaterThan => ">",
261
                    ComparisonOperator::GreaterThanOrEqual => ">=",
262
                    ComparisonOperator::LessThan => "<",
263
                    ComparisonOperator::LessThanOrEqual => "<=",
264
                };
265
                write!(f, "(prio {} {})", op_str, prio)
266
            }
267
        }
268
    }
269
}
270

            
271
57
pub(super) fn unescape_string(s: &str) -> String {
272
57
    let mut result = String::new();
273
57
    let mut chars = s.chars();
274
588
    while let Some(c) = chars.next() {
275
531
        if c == '\\' {
276
4
            if let Some(escaped) = chars.next() {
277
4
                match escaped {
278
                    'n' => result.push('\n'),
279
                    't' => result.push('\t'),
280
                    'r' => result.push('\r'),
281
2
                    '\\' => result.push('\\'),
282
2
                    '"' => result.push('"'),
283
                    other => {
284
                        result.push('\\');
285
                        result.push(other);
286
                    }
287
                }
288
            } else {
289
                result.push('\\');
290
            }
291
527
        } else {
292
527
            result.push(c);
293
527
        }
294
    }
295
57
    result
296
57
}
297

            
298
lalrpop_mod!(filter_grammar, "/filter_grammar.rs");
299

            
300
impl Filter {
301
37
    pub fn parse(input: &str) -> Result<Filter, RequestParserError> {
302
37
        let parser = filter_grammar::ExpressionParser::new();
303
        // println!("Parsing filter: {:#?}", parser.parse(input));
304
37
        parser
305
37
            .parse(input)
306
37
            .map_err(|e| RequestParserError::SyntaxError(0, format!("{}", e)))
307
37
    }
308
}
309

            
310
// TODO: There is a significant amount of error handling to be improved and tested here.
311

            
312
// TODO: thiserror
313

            
314
#[derive(Debug, Clone, PartialEq)]
315
pub enum FilterParserError<'a> {
316
    /// Could not parse the response due to a syntax error.
317
    SyntaxError(usize, &'a str),
318

            
319
    /// Audio format string is invalid.
320
    InvalidAudioFormat,
321

            
322
    /// Parentheses are unbalanced.
323
    UnbalancedParentheses,
324

            
325
    /// String literal is not closed.
326
    UnclosedStringLiteral,
327
}
328

            
329
#[cfg(test)]
330
mod tests {
331
    use chrono::DateTime;
332

            
333
    use super::*;
334

            
335
    #[test]
336
1
    fn test_parse_filter_eq_tag() {
337
1
        let filter = Filter::parse(r#"(artist == "The Beatles")"#);
338
1
        assert_eq!(
339
            filter,
340
1
            Ok(Filter::EqTag(
341
1
                Tag::Artist("The Beatles".to_string()),
342
1
                CaseSensitivity::CommandDependent,
343
1
                false,
344
1
            )),
345
        );
346

            
347
1
        let filter = Filter::parse(r#"(artist != "The Beatles")"#);
348
1
        assert_eq!(
349
            filter,
350
1
            Ok(Filter::EqTag(
351
1
                Tag::Artist("The Beatles".to_string()),
352
1
                CaseSensitivity::CommandDependent,
353
1
                true,
354
1
            )),
355
        );
356
1
    }
357

            
358
    #[test]
359
1
    fn test_parse_filter_contains() {
360
1
        let filter = Filter::parse(r#"(album contains "Greatest Hits")"#);
361
1
        assert_eq!(
362
            filter,
363
1
            Ok(Filter::Contains(
364
1
                Tag::Album("Greatest Hits".to_string()),
365
1
                CaseSensitivity::CommandDependent,
366
1
                false,
367
1
            ))
368
        );
369

            
370
1
        let filter = Filter::parse(r#"(album !contains "Greatest Hits")"#);
371
1
        assert_eq!(
372
            filter,
373
1
            Ok(Filter::Contains(
374
1
                Tag::Album("Greatest Hits".to_string()),
375
1
                CaseSensitivity::CommandDependent,
376
1
                true,
377
1
            )),
378
        );
379
1
    }
380

            
381
    #[test]
382
1
    fn test_parse_filter_starts_with() {
383
1
        let filter = Filter::parse(r#"(title starts_with "Symphony No. ")"#);
384
1
        assert_eq!(
385
            filter,
386
1
            Ok(Filter::StartsWith(
387
1
                Tag::Title("Symphony No. ".to_string()),
388
1
                CaseSensitivity::CommandDependent,
389
1
                false,
390
1
            )),
391
        );
392

            
393
1
        let filter = Filter::parse(r#"(title !starts_with "Symphony No. ")"#);
394
1
        assert_eq!(
395
            filter,
396
1
            Ok(Filter::StartsWith(
397
1
                Tag::Title("Symphony No. ".to_string()),
398
1
                CaseSensitivity::CommandDependent,
399
1
                true,
400
1
            )),
401
        );
402
1
    }
403

            
404
    #[test]
405
1
    fn test_parse_filter_perl_regex_positive() {
406
1
        let filter = Filter::parse(r#"(composer =~ "Beethoven.*")"#);
407
1
        assert_eq!(
408
            filter,
409
1
            Ok(Filter::PerlRegex(
410
1
                Tag::Composer("Beethoven.*".to_string()),
411
1
                false,
412
1
            )),
413
        );
414

            
415
1
        let filter = Filter::parse(r#"(genre !~ "Pop.*")"#);
416
1
        assert_eq!(
417
            filter,
418
1
            Ok(Filter::PerlRegex(Tag::Genre("Pop.*".to_string()), true)),
419
        );
420
1
    }
421

            
422
    #[test]
423
1
    fn test_parse_filter_base() {
424
1
        let filter = Filter::parse(r#"(base "Rock/Classics")"#);
425
1
        assert_eq!(filter, Ok(Filter::Base("Rock/Classics".to_string())));
426
1
    }
427

            
428
    #[test]
429
1
    fn test_parse_filter_modified_since() {
430
1
        let filter = Filter::parse(r#"(modified-since '1622505600')"#);
431
1
        assert_eq!(
432
            filter,
433
1
            Ok(Filter::ModifiedSince(
434
1
                DateTime::from_timestamp(1622505600, 0).unwrap()
435
1
            ))
436
        );
437

            
438
1
        let date_time = DateTime::from_timestamp(1622505600, 0).unwrap();
439
1
        let iso8601_str = date_time.to_rfc3339();
440
1
        let filter = Filter::parse(format!(r#"(modified-since "{}")"#, iso8601_str).as_str());
441
1
        assert_eq!(filter, Ok(Filter::ModifiedSince(date_time)));
442
1
    }
443

            
444
    #[test]
445
1
    fn test_parse_filter_audio_added_since() {
446
1
        let filter = Filter::parse(r#"(added-since '1622505600')"#);
447
1
        assert_eq!(
448
            filter,
449
1
            Ok(Filter::AddedSince(
450
1
                DateTime::from_timestamp(1622505600, 0).unwrap()
451
1
            ))
452
        );
453

            
454
1
        let date_time = DateTime::from_timestamp(1622505600, 0).unwrap();
455
1
        let iso8601_str = date_time.to_rfc3339();
456
1
        let filter = Filter::parse(format!(r#"(added-since "{}")"#, iso8601_str).as_str());
457
1
        assert_eq!(filter, Ok(Filter::AddedSince(date_time)));
458
1
    }
459

            
460
    #[test]
461
1
    fn test_parse_filter_audio_format_eq() {
462
1
        let filter = Filter::parse(r#"(AudioFormat == 44100:16:2)"#);
463
1
        assert_eq!(
464
            filter,
465
            Ok(Filter::AudioFormatEq {
466
                sample_rate: 44100,
467
                bits: 16,
468
                channels: 2,
469
            }),
470
        );
471
1
    }
472

            
473
    #[test]
474
1
    fn test_parse_filter_audio_format_eq_mask() {
475
1
        let filter = Filter::parse(r#"(AudioFormat =~ 44100:*:2)"#);
476
1
        assert_eq!(
477
            filter,
478
            Ok(Filter::AudioFormatEqMask {
479
                sample_rate: Some(44100),
480
                bits: None,
481
                channels: Some(2),
482
            }),
483
        );
484
1
    }
485

            
486
    #[test]
487
1
    fn test_parse_filter_prio_cmp() {
488
6
        for (op_str, op_enum) in &[
489
1
            (">", ComparisonOperator::GreaterThan),
490
1
            (">=", ComparisonOperator::GreaterThanOrEqual),
491
1
            ("<", ComparisonOperator::LessThan),
492
1
            ("<=", ComparisonOperator::LessThanOrEqual),
493
1
            ("==", ComparisonOperator::Equal),
494
1
            ("!=", ComparisonOperator::NotEqual),
495
1
        ] {
496
6
            let filter_str = format!(r#"(prio {} 42)"#, op_str);
497
6
            let filter = Filter::parse(&filter_str);
498
6
            assert_eq!(filter, Ok(Filter::PrioCmp(op_enum.clone(), 42)),);
499
        }
500
1
    }
501

            
502
    #[test]
503
1
    fn test_parse_filter_not() {
504
1
        let filter = Filter::parse(r#"(!(artist == "The Beatles"))"#);
505
1
        assert_eq!(
506
            filter,
507
1
            Ok(Filter::Not(Box::new(Filter::EqTag(
508
1
                Tag::Artist("The Beatles".to_string()),
509
1
                CaseSensitivity::CommandDependent,
510
1
                false,
511
1
            )))),
512
        );
513
1
    }
514

            
515
    #[test]
516
1
    fn test_parse_filter_and() {
517
1
        let filter = Filter::parse(
518
1
            r#"((artist == "The Beatles") AND (album == "Abbey Road") AND (title == "Come Together"))"#,
519
        );
520
1
        assert_eq!(
521
            filter,
522
1
            Ok(Filter::And(vec![
523
1
                Filter::EqTag(
524
1
                    Tag::Artist("The Beatles".to_string()),
525
1
                    CaseSensitivity::CommandDependent,
526
1
                    false,
527
1
                ),
528
1
                Filter::EqTag(
529
1
                    Tag::Album("Abbey Road".to_string()),
530
1
                    CaseSensitivity::CommandDependent,
531
1
                    false,
532
1
                ),
533
1
                Filter::EqTag(
534
1
                    Tag::Title("Come Together".to_string()),
535
1
                    CaseSensitivity::CommandDependent,
536
1
                    false,
537
1
                ),
538
1
            ])),
539
        );
540
1
    }
541

            
542
    #[test]
543
1
    fn test_parse_filter_explicitly_case_sensitive() {
544
1
        let filter = Filter::parse(r#"(artist eq_cs "The Beatles")"#);
545
1
        assert_eq!(
546
            filter,
547
1
            Ok(Filter::EqTag(
548
1
                Tag::Artist("The Beatles".to_string()),
549
1
                CaseSensitivity::CaseSensitive,
550
1
                false,
551
1
            )),
552
        );
553

            
554
1
        let filter = Filter::parse(r#"(artist !eq_cs "The Beatles")"#);
555
1
        assert_eq!(
556
            filter,
557
1
            Ok(Filter::EqTag(
558
1
                Tag::Artist("The Beatles".to_string()),
559
1
                CaseSensitivity::CaseSensitive,
560
1
                true,
561
1
            )),
562
        );
563

            
564
1
        let filter = Filter::parse(r#"(album contains_cs "Greatest Hits")"#);
565
1
        assert_eq!(
566
            filter,
567
1
            Ok(Filter::Contains(
568
1
                Tag::Album("Greatest Hits".to_string()),
569
1
                CaseSensitivity::CaseSensitive,
570
1
                false,
571
1
            )),
572
        );
573

            
574
1
        let filter = Filter::parse(r#"(album !contains_cs "Greatest Hits")"#);
575
1
        assert_eq!(
576
            filter,
577
1
            Ok(Filter::Contains(
578
1
                Tag::Album("Greatest Hits".to_string()),
579
1
                CaseSensitivity::CaseSensitive,
580
1
                true,
581
1
            )),
582
        );
583

            
584
1
        let filter = Filter::parse(r#"(title starts_with_cs "Symphony No. ")"#);
585
1
        assert_eq!(
586
            filter,
587
1
            Ok(Filter::StartsWith(
588
1
                Tag::Title("Symphony No. ".to_string()),
589
1
                CaseSensitivity::CaseSensitive,
590
1
                false,
591
1
            )),
592
        );
593

            
594
1
        let filter = Filter::parse(r#"(title !starts_with_cs "Symphony No. ")"#);
595
1
        assert_eq!(
596
            filter,
597
1
            Ok(Filter::StartsWith(
598
1
                Tag::Title("Symphony No. ".to_string()),
599
1
                CaseSensitivity::CaseSensitive,
600
1
                true,
601
1
            )),
602
        );
603
1
    }
604

            
605
    #[test]
606
1
    fn test_parse_filter_explicitly_case_insensitive() {
607
1
        let filter = Filter::parse(r#"(artist eq_ci "The Beatles")"#);
608
1
        assert_eq!(
609
            filter,
610
1
            Ok(Filter::EqTag(
611
1
                Tag::Artist("The Beatles".to_string()),
612
1
                CaseSensitivity::CaseInsensitive,
613
1
                false,
614
1
            )),
615
        );
616

            
617
1
        let filter = Filter::parse(r#"(artist !eq_ci "The Beatles")"#);
618
1
        assert_eq!(
619
            filter,
620
1
            Ok(Filter::EqTag(
621
1
                Tag::Artist("The Beatles".to_string()),
622
1
                CaseSensitivity::CaseInsensitive,
623
1
                true,
624
1
            )),
625
        );
626

            
627
1
        let filter = Filter::parse(r#"(album contains_ci "Greatest Hits")"#);
628
1
        assert_eq!(
629
            filter,
630
1
            Ok(Filter::Contains(
631
1
                Tag::Album("Greatest Hits".to_string()),
632
1
                CaseSensitivity::CaseInsensitive,
633
1
                false,
634
1
            )),
635
        );
636

            
637
1
        let filter = Filter::parse(r#"(album !contains_ci "Greatest Hits")"#);
638
1
        assert_eq!(
639
            filter,
640
1
            Ok(Filter::Contains(
641
1
                Tag::Album("Greatest Hits".to_string()),
642
1
                CaseSensitivity::CaseInsensitive,
643
1
                true,
644
1
            )),
645
        );
646

            
647
1
        let filter = Filter::parse(r#"(title starts_with_ci "Symphony No. ")"#);
648
1
        assert_eq!(
649
            filter,
650
1
            Ok(Filter::StartsWith(
651
1
                Tag::Title("Symphony No. ".to_string()),
652
1
                CaseSensitivity::CaseInsensitive,
653
1
                false,
654
1
            )),
655
        );
656

            
657
1
        let filter = Filter::parse(r#"(title !starts_with_ci "Symphony No. ")"#);
658
1
        assert_eq!(
659
            filter,
660
1
            Ok(Filter::StartsWith(
661
1
                Tag::Title("Symphony No. ".to_string()),
662
1
                CaseSensitivity::CaseInsensitive,
663
1
                true,
664
1
            )),
665
        );
666
1
    }
667

            
668
    #[test]
669
1
    fn test_parse_filter_string_escapes() {
670
1
        let filter = Filter::parse(r#"(Artist == "\"foo\\'bar\\\"")"#);
671
1
        assert_eq!(
672
            filter,
673
1
            Ok(Filter::EqTag(
674
1
                Tag::Artist(r#""foo\'bar\""#.to_string()),
675
1
                CaseSensitivity::CommandDependent,
676
1
                false,
677
1
            )),
678
        );
679
1
    }
680

            
681
    #[test]
682
1
    fn test_parse_filter_excessive_whitespace() {
683
1
        let filter = Filter::parse("(\tartist\n  ==            \"The Beatles\"   )  ");
684
1
        assert_eq!(
685
            filter,
686
1
            Ok(Filter::EqTag(
687
1
                Tag::Artist("The Beatles".to_string()),
688
1
                CaseSensitivity::CommandDependent,
689
1
                false,
690
1
            )),
691
        );
692
1
    }
693
}