Skip to main content

koan_core/
smart.rs

1//! Smart playlists: a playlist whose contents are a rule over the library
2//! rather than a list someone made.
3//!
4//! The rule is stored on the playlist row as JSON in the shape [`Rules`]
5//! serialises to, and compiled to SQL by `db::queries::smart`. What a rule
6//! selects is written into the playlist's entries like any other playlist's,
7//! so every reader (counts, covers, the queue following a playlist, Subsonic,
8//! the apps) sees an ordinary playlist that it may not edit.
9//!
10//! The JSON shape, as callers write it:
11//!
12//! ```json
13//! {
14//!   "match": "all",
15//!   "rules": [
16//!     { "field": "playCount", "op": "gt", "value": 5 },
17//!     { "match": "any", "rules": [
18//!       { "field": "genre", "op": "contains", "value": "jazz" },
19//!       { "field": "favourite", "op": "is", "value": true }
20//!     ] }
21//!   ],
22//!   "sort": [{ "field": "lastPlayed", "desc": true }],
23//!   "limit": 100
24//! }
25//! ```
26//!
27//! Navidrome's `.nsp` files are read into the same model by [`from_nsp`].
28
29use serde::{Deserialize, Serialize};
30use serde_json::Value;
31
32/// How deep groups may nest, and how many conditions a rule may hold in all.
33/// Rules arrive from files and from the API; neither should be able to build
34/// a query SQLite refuses or takes a long time to plan.
35const MAX_DEPTH: usize = 8;
36const MAX_CONDITIONS: usize = 200;
37
38/// A smart playlist's definition: which tracks, in what order, how many.
39#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
40pub struct Rules {
41    #[serde(rename = "match", default)]
42    pub matching: Match,
43    #[serde(default)]
44    pub rules: Vec<Condition>,
45    /// Applied in order. Ties fall back to album order.
46    #[serde(default, skip_serializing_if = "Vec::is_empty")]
47    pub sort: Vec<Sort>,
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub limit: Option<u32>,
50}
51
52/// Whether every condition in a group must hold, or any one.
53#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(rename_all = "lowercase")]
55pub enum Match {
56    #[default]
57    All,
58    Any,
59}
60
61/// A condition, or a nested group of them. Told apart by shape: a group has
62/// `rules`, a condition has `field`.
63#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
64#[serde(try_from = "Value", into = "Value")]
65pub enum Condition {
66    Group {
67        matching: Match,
68        rules: Vec<Condition>,
69    },
70    Rule(Rule),
71}
72
73#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
74pub struct Rule {
75    pub field: Field,
76    pub op: Op,
77    pub value: Value,
78}
79
80/// What a condition or a sort can look at. Per-account fields (`playCount`,
81/// `lastPlayed`, `favourite`) are the playlist owner's.
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(rename_all = "camelCase")]
84pub enum Field {
85    Title,
86    Artist,
87    AlbumArtist,
88    Album,
89    Genre,
90    /// The codec, as koan names it: `FLAC`, `MP3`, `AAC`…
91    Format,
92    /// The file's path on disk. Absent for tracks only on a server.
93    Path,
94    Year,
95    /// Seconds.
96    Duration,
97    BitDepth,
98    SampleRate,
99    TrackNumber,
100    DiscNumber,
101    PlayCount,
102    LastPlayed,
103    /// When the track's album entered the library.
104    DateAdded,
105    Favourite,
106}
107
108/// How a field is compared, which decides the operators and values it takes.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub enum Kind {
111    Text,
112    Number,
113    Date,
114    Bool,
115}
116
117impl Field {
118    pub const ALL: [Field; 17] = [
119        Field::Title,
120        Field::Artist,
121        Field::AlbumArtist,
122        Field::Album,
123        Field::Genre,
124        Field::Format,
125        Field::Path,
126        Field::Year,
127        Field::Duration,
128        Field::BitDepth,
129        Field::SampleRate,
130        Field::TrackNumber,
131        Field::DiscNumber,
132        Field::PlayCount,
133        Field::LastPlayed,
134        Field::DateAdded,
135        Field::Favourite,
136    ];
137
138    pub fn kind(self) -> Kind {
139        match self {
140            Field::Title
141            | Field::Artist
142            | Field::AlbumArtist
143            | Field::Album
144            | Field::Genre
145            | Field::Format
146            | Field::Path => Kind::Text,
147            Field::Year
148            | Field::Duration
149            | Field::BitDepth
150            | Field::SampleRate
151            | Field::TrackNumber
152            | Field::DiscNumber
153            | Field::PlayCount => Kind::Number,
154            Field::LastPlayed | Field::DateAdded => Kind::Date,
155            Field::Favourite => Kind::Bool,
156        }
157    }
158
159    pub fn name(self) -> &'static str {
160        match self {
161            Field::Title => "title",
162            Field::Artist => "artist",
163            Field::AlbumArtist => "albumArtist",
164            Field::Album => "album",
165            Field::Genre => "genre",
166            Field::Format => "format",
167            Field::Path => "path",
168            Field::Year => "year",
169            Field::Duration => "duration",
170            Field::BitDepth => "bitDepth",
171            Field::SampleRate => "sampleRate",
172            Field::TrackNumber => "trackNumber",
173            Field::DiscNumber => "discNumber",
174            Field::PlayCount => "playCount",
175            Field::LastPlayed => "lastPlayed",
176            Field::DateAdded => "dateAdded",
177            Field::Favourite => "favourite",
178        }
179    }
180
181    fn parse(name: &str) -> Result<Self, String> {
182        Field::ALL
183            .into_iter()
184            .find(|f| f.name().eq_ignore_ascii_case(name))
185            .ok_or_else(|| {
186                let known: Vec<&str> = Field::ALL.iter().map(|f| f.name()).collect();
187                format!("unknown field '{name}'; known fields: {}", known.join(", "))
188            })
189    }
190}
191
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
193#[serde(rename_all = "camelCase")]
194pub enum Op {
195    Is,
196    IsNot,
197    Contains,
198    NotContains,
199    StartsWith,
200    EndsWith,
201    Gt,
202    Lt,
203    /// Inclusive, `[low, high]`.
204    InTheRange,
205    /// Dates, `YYYY-MM-DD`.
206    Before,
207    After,
208    /// A number of days back from now.
209    InTheLast,
210    /// Not within that many days, or never.
211    NotInTheLast,
212}
213
214impl Op {
215    const ALL: [Op; 13] = [
216        Op::Is,
217        Op::IsNot,
218        Op::Contains,
219        Op::NotContains,
220        Op::StartsWith,
221        Op::EndsWith,
222        Op::Gt,
223        Op::Lt,
224        Op::InTheRange,
225        Op::Before,
226        Op::After,
227        Op::InTheLast,
228        Op::NotInTheLast,
229    ];
230
231    pub fn name(self) -> &'static str {
232        match self {
233            Op::Is => "is",
234            Op::IsNot => "isNot",
235            Op::Contains => "contains",
236            Op::NotContains => "notContains",
237            Op::StartsWith => "startsWith",
238            Op::EndsWith => "endsWith",
239            Op::Gt => "gt",
240            Op::Lt => "lt",
241            Op::InTheRange => "inTheRange",
242            Op::Before => "before",
243            Op::After => "after",
244            Op::InTheLast => "inTheLast",
245            Op::NotInTheLast => "notInTheLast",
246        }
247    }
248
249    fn parse(name: &str) -> Result<Self, String> {
250        Op::ALL
251            .into_iter()
252            .find(|o| o.name().eq_ignore_ascii_case(name))
253            .ok_or_else(|| {
254                let known: Vec<&str> = Op::ALL.iter().map(|o| o.name()).collect();
255                format!(
256                    "unknown operator '{name}'; known operators: {}",
257                    known.join(", ")
258                )
259            })
260    }
261
262    /// Which operators each kind of field takes.
263    pub fn applies_to(self, kind: Kind) -> bool {
264        use Op::*;
265        match kind {
266            Kind::Text => matches!(
267                self,
268                Is | IsNot | Contains | NotContains | StartsWith | EndsWith
269            ),
270            Kind::Number => matches!(self, Is | IsNot | Gt | Lt | InTheRange),
271            Kind::Date => matches!(self, Before | After | InTheLast | NotInTheLast | InTheRange),
272            Kind::Bool => matches!(self, Is | IsNot),
273        }
274    }
275}
276
277/// One sort key: a field, or `random`.
278#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
279#[serde(try_from = "Value", into = "Value")]
280pub struct Sort {
281    /// `None` is a random order, drawn again each time the playlist is.
282    pub field: Option<Field>,
283    pub desc: bool,
284}
285
286impl TryFrom<Value> for Condition {
287    type Error = String;
288
289    fn try_from(value: Value) -> Result<Self, String> {
290        let Value::Object(map) = &value else {
291            return Err(format!("a condition must be an object, not {value}"));
292        };
293        if map.contains_key("rules") {
294            let matching = match map.get("match") {
295                None => Match::All,
296                Some(m) => serde_json::from_value(m.clone())
297                    .map_err(|_| format!("'match' must be \"all\" or \"any\", not {m}"))?,
298            };
299            let rules = serde_json::from_value(map["rules"].clone()).map_err(|e| e.to_string())?;
300            return Ok(Condition::Group { matching, rules });
301        }
302        let text = |key: &str| {
303            map.get(key)
304                .and_then(Value::as_str)
305                .ok_or_else(|| format!("a condition needs '{key}' as a string: {value}"))
306        };
307        let field = Field::parse(text("field")?)?;
308        let op = Op::parse(text("op")?)?;
309        let value = map
310            .get("value")
311            .cloned()
312            .ok_or_else(|| format!("a condition needs a 'value': {value}"))?;
313        Ok(Condition::Rule(Rule { field, op, value }))
314    }
315}
316
317impl From<Condition> for Value {
318    fn from(c: Condition) -> Value {
319        match c {
320            Condition::Group { matching, rules } => {
321                serde_json::json!({ "match": matching, "rules": rules })
322            }
323            Condition::Rule(r) => {
324                serde_json::json!({ "field": r.field, "op": r.op, "value": r.value })
325            }
326        }
327    }
328}
329
330impl TryFrom<Value> for Sort {
331    type Error = String;
332
333    fn try_from(value: Value) -> Result<Self, String> {
334        let (name, desc) = match &value {
335            Value::String(s) => (s.as_str(), false),
336            Value::Object(map) => (
337                map.get("field")
338                    .and_then(Value::as_str)
339                    .ok_or_else(|| format!("a sort needs 'field' as a string: {value}"))?,
340                map.get("desc").and_then(Value::as_bool).unwrap_or(false),
341            ),
342            _ => return Err(format!("a sort must be an object or a field name: {value}")),
343        };
344        if name.eq_ignore_ascii_case("random") {
345            return Ok(Sort { field: None, desc });
346        }
347        Ok(Sort {
348            field: Some(Field::parse(name)?),
349            desc,
350        })
351    }
352}
353
354impl From<Sort> for Value {
355    fn from(s: Sort) -> Value {
356        let field = s.field.map_or("random", Field::name);
357        serde_json::json!({ "field": field, "desc": s.desc })
358    }
359}
360
361impl Rules {
362    /// Read rules from their JSON, checking every condition: the error names
363    /// what is wrong, for a caller writing rules by hand.
364    pub fn parse(json: &str) -> Result<Self, String> {
365        let rules: Rules = serde_json::from_str(json).map_err(|e| e.to_string())?;
366        rules.check()?;
367        Ok(rules)
368    }
369
370    pub fn from_value(value: Value) -> Result<Self, String> {
371        let rules: Rules = serde_json::from_value(value).map_err(|e| e.to_string())?;
372        rules.check()?;
373        Ok(rules)
374    }
375
376    pub fn to_json(&self) -> String {
377        serde_json::to_string(self).expect("rules serialise")
378    }
379
380    /// Whether the order is drawn at random, so each evaluation differs.
381    pub fn is_random(&self) -> bool {
382        self.sort.iter().any(|s| s.field.is_none())
383    }
384
385    /// Whether any condition or sort key reads one of `fields`.
386    pub fn uses(&self, fields: &[Field]) -> bool {
387        fn any(rules: &[Condition], fields: &[Field]) -> bool {
388            rules.iter().any(|c| match c {
389                Condition::Group { rules, .. } => any(rules, fields),
390                Condition::Rule(r) => fields.contains(&r.field),
391            })
392        }
393        any(&self.rules, fields)
394            || self
395                .sort
396                .iter()
397                .any(|s| s.field.is_some_and(|f| fields.contains(&f)))
398    }
399
400    /// Every condition takes an operator its field allows, with a value of
401    /// the right type; nesting and size stay within bounds.
402    pub fn check(&self) -> Result<(), String> {
403        let mut count = 0;
404        check_group(&self.rules, 1, &mut count)
405    }
406}
407
408fn check_group(rules: &[Condition], depth: usize, count: &mut usize) -> Result<(), String> {
409    if depth > MAX_DEPTH {
410        return Err(format!("groups nest more than {MAX_DEPTH} deep"));
411    }
412    for condition in rules {
413        *count += 1;
414        if *count > MAX_CONDITIONS {
415            return Err(format!("more than {MAX_CONDITIONS} conditions"));
416        }
417        match condition {
418            Condition::Group { rules, .. } => check_group(rules, depth + 1, count)?,
419            Condition::Rule(rule) => {
420                rule.operand()?;
421            }
422        }
423    }
424    Ok(())
425}
426
427/// A condition's value, checked against its field and operator.
428#[derive(Debug, Clone, PartialEq)]
429pub enum Operand {
430    Text(String),
431    Number(f64),
432    NumberRange(f64, f64),
433    /// Unix seconds.
434    Instant(i64),
435    /// Unix seconds, inclusive at both ends.
436    InstantRange(i64, i64),
437    Days(f64),
438    Bool(bool),
439}
440
441impl Rule {
442    pub fn operand(&self) -> Result<Operand, String> {
443        let kind = self.field.kind();
444        let (field, op) = (self.field.name(), self.op.name());
445        if !self.op.applies_to(kind) {
446            let allowed: Vec<&str> = Op::ALL
447                .iter()
448                .filter(|o| o.applies_to(kind))
449                .map(|o| o.name())
450                .collect();
451            return Err(format!(
452                "'{field}' does not take '{op}'; it takes {}",
453                allowed.join(", ")
454            ));
455        }
456        let wrong = |wanted: &str| format!("'{field} {op}' wants {wanted}, not {}", self.value);
457        let pair = || match &self.value {
458            Value::Array(items) if items.len() == 2 => Ok((&items[0], &items[1])),
459            _ => Err(wrong("a pair, [low, high]")),
460        };
461        Ok(match (kind, self.op) {
462            (Kind::Text, _) => Operand::Text(
463                self.value
464                    .as_str()
465                    .ok_or_else(|| wrong("a string"))?
466                    .to_owned(),
467            ),
468            (Kind::Bool, _) => {
469                Operand::Bool(self.value.as_bool().ok_or_else(|| wrong("true or false"))?)
470            }
471            (Kind::Number, Op::InTheRange) => {
472                let (lo, hi) = pair()?;
473                let num = |v: &Value| v.as_f64().ok_or_else(|| wrong("two numbers"));
474                Operand::NumberRange(num(lo)?, num(hi)?)
475            }
476            (Kind::Number, _) => {
477                Operand::Number(self.value.as_f64().ok_or_else(|| wrong("a number"))?)
478            }
479            (Kind::Date, Op::InTheLast | Op::NotInTheLast) => {
480                let days = self
481                    .value
482                    .as_f64()
483                    .ok_or_else(|| wrong("a number of days"))?;
484                if days < 0.0 {
485                    return Err(wrong("a positive number of days"));
486                }
487                Operand::Days(days)
488            }
489            (Kind::Date, Op::InTheRange) => {
490                let (lo, hi) = pair()?;
491                let day = |v: &Value| {
492                    v.as_str()
493                        .and_then(parse_day)
494                        .ok_or_else(|| wrong("two dates, YYYY-MM-DD"))
495                };
496                Operand::InstantRange(day(lo)?, day(hi)? + 86_399)
497            }
498            (Kind::Date, _) => Operand::Instant(
499                self.value
500                    .as_str()
501                    .and_then(parse_day)
502                    .ok_or_else(|| wrong("a date, YYYY-MM-DD"))?,
503            ),
504        })
505    }
506}
507
508/// Midnight UTC at the start of a `YYYY-MM-DD` day, as unix seconds.
509fn parse_day(s: &str) -> Option<i64> {
510    let day = chrono::NaiveDate::parse_from_str(s.get(..10).unwrap_or(s), "%Y-%m-%d").ok()?;
511    Some(day.and_hms_opt(0, 0, 0)?.and_utc().timestamp())
512}
513
514/// A Navidrome smart playlist file, read into kōan's model.
515#[derive(Debug, Clone, PartialEq)]
516pub struct Nsp {
517    pub name: Option<String>,
518    pub comment: Option<String>,
519    pub rules: Rules,
520}
521
522/// Read a Navidrome `.nsp` file.
523///
524/// Navidrome writes `{"all": [...]}` or `{"any": [...]}` with each condition as
525/// `{"<operator>": {"<field>": <value>}}`, plus `sort`, `order` and `limit`.
526/// Field names are matched case-insensitively, as Navidrome does. A field or
527/// operator kōan has no counterpart for (ratings, BPM, comments, other
528/// playlists) is an error naming it: a playlist that silently dropped a
529/// condition would select more than its author asked for.
530pub fn from_nsp(json: &str) -> Result<Nsp, String> {
531    let root: Value = serde_json::from_str(json).map_err(|e| format!("not JSON: {e}"))?;
532    let Value::Object(map) = &root else {
533        return Err("not a JSON object".into());
534    };
535    let text = |key: &str| map.get(key).and_then(Value::as_str).map(str::to_owned);
536    let (matching, rules) = nsp_group(&root)?.ok_or("neither 'all' nor 'any' at the top")?;
537
538    let desc = text("order").is_some_and(|o| o.eq_ignore_ascii_case("desc"));
539    let mut sort = Vec::new();
540    if let Some(keys) = text("sort") {
541        // Newer Navidrome takes several keys, comma separated, each with an
542        // optional sign; `order` applies to keys without one.
543        for key in keys.split(',').map(str::trim).filter(|k| !k.is_empty()) {
544            let (key, desc) = match key.as_bytes()[0] {
545                b'-' => (&key[1..], true),
546                b'+' => (&key[1..], false),
547                _ => (key, desc),
548            };
549            if key.eq_ignore_ascii_case("random") {
550                sort.push(Sort { field: None, desc });
551            } else {
552                sort.push(Sort {
553                    field: Some(nsp_field(key)?),
554                    desc,
555                });
556            }
557        }
558    }
559    let limit = match map.get("limit") {
560        None | Some(Value::Null) => None,
561        Some(v) => Some(
562            v.as_u64()
563                .and_then(|n| u32::try_from(n).ok())
564                .ok_or_else(|| format!("'limit' must be a whole number, not {v}"))?,
565        ),
566    };
567    let rules = Rules {
568        matching,
569        rules,
570        sort,
571        limit: limit.filter(|&n| n > 0),
572    };
573    rules.check()?;
574    Ok(Nsp {
575        name: text("name").filter(|n| !n.trim().is_empty()),
576        comment: text("comment").filter(|c| !c.trim().is_empty()),
577        rules,
578    })
579}
580
581/// `{"all": [...]}` or `{"any": [...]}`, if the object is a group.
582fn nsp_group(value: &Value) -> Result<Option<(Match, Vec<Condition>)>, String> {
583    for (key, matching) in [("all", Match::All), ("any", Match::Any)] {
584        if let Some(list) = value.get(key) {
585            let list = list
586                .as_array()
587                .ok_or_else(|| format!("'{key}' must be a list"))?;
588            let rules = list.iter().map(nsp_condition).collect::<Result<_, _>>()?;
589            return Ok(Some((matching, rules)));
590        }
591    }
592    Ok(None)
593}
594
595fn nsp_condition(value: &Value) -> Result<Condition, String> {
596    if let Some((matching, rules)) = nsp_group(value)? {
597        return Ok(Condition::Group { matching, rules });
598    }
599    let single = |v: &Value| -> Option<(String, Value)> {
600        let map = v.as_object()?;
601        (map.len() == 1).then(|| map.iter().next().map(|(k, v)| (k.clone(), v.clone())))?
602    };
603    let (op_name, inner) = single(value).ok_or_else(|| format!("not a condition: {value}"))?;
604    let (field_name, raw) =
605        single(&inner).ok_or_else(|| format!("'{op_name}' wants one field: {inner}"))?;
606    let op = match op_name.to_ascii_lowercase().as_str() {
607        "inplaylist" | "notinplaylist" => {
608            return Err(format!("'{op_name}' (other playlists) is not supported"));
609        }
610        _ => Op::parse(&op_name)?,
611    };
612    let field = nsp_field(&field_name)?;
613    let value = nsp_value(field.kind(), op, raw);
614    let rule = Rule { field, op, value };
615    rule.operand()?;
616    Ok(Condition::Rule(rule))
617}
618
619/// Navidrome's field names, lowercased, and what they are in kōan.
620fn nsp_field(name: &str) -> Result<Field, String> {
621    Ok(match name.to_ascii_lowercase().as_str() {
622        "title" => Field::Title,
623        "artist" => Field::Artist,
624        "albumartist" => Field::AlbumArtist,
625        "album" => Field::Album,
626        "genre" => Field::Genre,
627        "filetype" => Field::Format,
628        "filepath" => Field::Path,
629        "year" => Field::Year,
630        "duration" => Field::Duration,
631        "bitdepth" => Field::BitDepth,
632        "samplerate" => Field::SampleRate,
633        "tracknumber" => Field::TrackNumber,
634        "discnumber" => Field::DiscNumber,
635        "playcount" => Field::PlayCount,
636        "lastplayed" => Field::LastPlayed,
637        "dateadded" => Field::DateAdded,
638        "loved" => Field::Favourite,
639        _ => return Err(format!("field '{name}' is not supported")),
640    })
641}
642
643/// Navidrome files are hand-written, and numbers and booleans turn up as
644/// strings. They are taken as what the field wants where they read as one.
645fn nsp_value(kind: Kind, op: Op, raw: Value) -> Value {
646    let number = |v: Value| match &v {
647        Value::String(s) => s
648            .trim()
649            .parse::<f64>()
650            .ok()
651            .and_then(serde_json::Number::from_f64)
652            .map_or(v, Value::Number),
653        _ => v,
654    };
655    match (kind, op, raw) {
656        (Kind::Number, Op::InTheRange, Value::Array(items)) => {
657            Value::Array(items.into_iter().map(number).collect())
658        }
659        (Kind::Number, _, v) | (Kind::Date, Op::InTheLast | Op::NotInTheLast, v) => number(v),
660        (Kind::Bool, _, Value::String(s)) => match s.trim().to_ascii_lowercase().as_str() {
661            "true" => Value::Bool(true),
662            "false" => Value::Bool(false),
663            _ => Value::String(s),
664        },
665        (_, _, v) => v,
666    }
667}
668
669#[cfg(test)]
670mod tests {
671    use super::*;
672
673    #[test]
674    fn rules_round_trip_through_json() {
675        let json = r#"{
676            "match": "all",
677            "rules": [
678                { "field": "playCount", "op": "gt", "value": 5 },
679                { "match": "any", "rules": [
680                    { "field": "genre", "op": "contains", "value": "jazz" },
681                    { "field": "favourite", "op": "is", "value": true }
682                ] }
683            ],
684            "sort": [{ "field": "lastPlayed", "desc": true }, "random"],
685            "limit": 100
686        }"#;
687        let rules = Rules::parse(json).unwrap();
688        assert_eq!(rules.rules.len(), 2);
689        assert_eq!(rules.sort[0].field, Some(Field::LastPlayed));
690        assert!(rules.is_random());
691        assert_eq!(Rules::parse(&rules.to_json()).unwrap(), rules);
692    }
693
694    #[test]
695    fn errors_name_what_is_wrong() {
696        let err = |json: &str| Rules::parse(json).unwrap_err();
697        assert!(
698            err(r#"{"rules":[{"field":"plays","op":"gt","value":1}]}"#)
699                .contains("unknown field 'plays'")
700        );
701        assert!(
702            err(r#"{"rules":[{"field":"title","op":"gt","value":1}]}"#)
703                .contains("does not take 'gt'")
704        );
705        assert!(
706            err(r#"{"rules":[{"field":"year","op":"is","value":"x"}]}"#).contains("wants a number")
707        );
708        assert!(
709            err(r#"{"rules":[{"field":"lastPlayed","op":"before","value":"soon"}]}"#)
710                .contains("YYYY-MM-DD")
711        );
712        assert!(
713            err(r#"{"rules":[{"field":"year","op":"inTheRange","value":[1990]}]}"#)
714                .contains("pair")
715        );
716    }
717
718    #[test]
719    fn nesting_is_bounded() {
720        let mut json = r#"{"field":"year","op":"gt","value":1}"#.to_string();
721        for _ in 0..=MAX_DEPTH {
722            json = format!(r#"{{"rules":[{json}]}}"#);
723        }
724        assert!(Rules::parse(&json).unwrap_err().contains("nest"));
725    }
726
727    #[test]
728    fn a_navidrome_file_reads_into_the_model() {
729        let nsp = from_nsp(
730            r#"{
731                "name": "80's Favourites",
732                "comment": "Loved, from the eighties",
733                "all": [
734                    { "any": [ {"is": {"loved": true}}, {"gt": {"playCount": "10"}} ] },
735                    { "inTheRange": { "year": [1981, 1990] } },
736                    { "inTheLast": { "lastPlayed": 30 } },
737                    { "contains": { "Genre": "synth" } }
738                ],
739                "sort": "-playcount,title",
740                "order": "asc",
741                "limit": 25
742            }"#,
743        )
744        .unwrap();
745        assert_eq!(nsp.name.as_deref(), Some("80's Favourites"));
746        let rules = nsp.rules;
747        assert_eq!(rules.matching, Match::All);
748        assert_eq!(rules.limit, Some(25));
749        assert_eq!(
750            rules.sort,
751            vec![
752                Sort {
753                    field: Some(Field::PlayCount),
754                    desc: true
755                },
756                Sort {
757                    field: Some(Field::Title),
758                    desc: false
759                },
760            ]
761        );
762        let Condition::Group {
763            matching,
764            rules: any,
765        } = &rules.rules[0]
766        else {
767            panic!("expected a group")
768        };
769        assert_eq!(*matching, Match::Any);
770        assert_eq!(
771            any[1],
772            Condition::Rule(Rule {
773                field: Field::PlayCount,
774                op: Op::Gt,
775                value: serde_json::json!(10.0)
776            }),
777            "a number written as a string reads as a number"
778        );
779        assert!(matches!(
780            &rules.rules[3],
781            Condition::Rule(Rule {
782                field: Field::Genre,
783                op: Op::Contains,
784                ..
785            })
786        ));
787    }
788
789    #[test]
790    fn a_navidrome_file_with_an_unsupported_field_is_refused() {
791        let err = from_nsp(r#"{"all":[{"gt":{"rating":3}}]}"#).unwrap_err();
792        assert!(err.contains("rating"), "{err}");
793        let err = from_nsp(r#"{"all":[{"inPlaylist":{"id":"x"}}]}"#).unwrap_err();
794        assert!(err.contains("inPlaylist"), "{err}");
795        assert!(from_nsp(r#"{"sort":"title"}"#).is_err());
796    }
797
798    #[test]
799    fn random_sort_in_a_navidrome_file() {
800        let nsp =
801            from_nsp(r#"{"any":[{"is":{"loved":"true"}}],"sort":"random","limit":50}"#).unwrap();
802        assert!(nsp.rules.is_random());
803        assert_eq!(nsp.name, None);
804    }
805}