Skip to main content

koan_core/format/
mod.rs

1pub mod eval;
2pub mod functions;
3pub mod parser;
4
5pub use eval::{MetadataProvider, evaluate};
6pub use parser::{FormatError, Token, parse};
7
8/// Parse and evaluate a format string in one call.
9pub fn format(template: &str, provider: &dyn MetadataProvider) -> Result<String, FormatError> {
10    let tokens = parse(template)?;
11    Ok(evaluate(&tokens, provider))
12}
13
14#[cfg(test)]
15mod tests {
16    use super::*;
17    use std::collections::HashMap;
18
19    fn provider(data: &[(&str, &str)]) -> HashMap<String, String> {
20        data.iter()
21            .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
22            .collect()
23    }
24
25    // ---- Basic field/conditional/function tests ----
26
27    #[test]
28    fn basic_artist() {
29        let p = provider(&[("album artist", "Radiohead")]);
30        assert_eq!(format("%album artist%", &p).unwrap(), "Radiohead");
31    }
32
33    #[test]
34    fn conditional_artist_album() {
35        let p = provider(&[("album artist", "Radiohead"), ("album", "OK Computer")]);
36        assert_eq!(
37            format("[%album artist% - ]%album%", &p).unwrap(),
38            "Radiohead - OK Computer"
39        );
40    }
41
42    #[test]
43    fn conditional_missing_artist() {
44        let p = provider(&[("album", "OK Computer")]);
45        assert_eq!(
46            format("[%album artist% - ]%album%", &p).unwrap(),
47            "OK Computer"
48        );
49    }
50
51    #[test]
52    fn quoted_literal_with_function() {
53        let p = provider(&[("date", "1997-06-16"), ("album", "OK Computer")]);
54        assert_eq!(
55            format("['('$left(%date%,4)')' ]%album%", &p).unwrap(),
56            "(1997) OK Computer"
57        );
58    }
59
60    #[test]
61    fn quoted_literal_missing_date() {
62        let p = provider(&[("album", "OK Computer")]);
63        assert_eq!(
64            format("['('$left(%date%,4)')' ]%album%", &p).unwrap(),
65            "OK Computer"
66        );
67    }
68
69    #[test]
70    fn complex_display() {
71        let p = provider(&[
72            ("tracknumber", "3"),
73            ("title", "Subterranean Homesick Alien"),
74            ("album artist", "Radiohead"),
75            ("album", "OK Computer"),
76            ("date", "1997-06-16"),
77        ]);
78        assert_eq!(
79            format(
80                "$num(%tracknumber%,2). %title% - [%album artist% - ]%album%[ '('$left(%date%,4)')']",
81                &p
82            )
83            .unwrap(),
84            "03. Subterranean Homesick Alien - Radiohead - OK Computer (1997)"
85        );
86    }
87
88    #[test]
89    fn if_function_integration() {
90        let p = provider(&[("genre", "Rock")]);
91        assert_eq!(format("$if(%genre%,%genre%,Unknown)", &p).unwrap(), "Rock");
92    }
93
94    #[test]
95    fn if_function_missing() {
96        let p = provider(&[]);
97        assert_eq!(
98            format("$if(%genre%,%genre%,Unknown)", &p).unwrap(),
99            "Unknown"
100        );
101    }
102
103    // ======================================================================
104    // Real-world pattern tests — the two patterns that MUST work perfectly
105    // ======================================================================
106
107    // Pattern 1: %album artist%/$if($stricmp(%album artist%,Various Artists),,['('$left(%date%,4)')' ])%album% '['%codec%']'/[$num(%discnumber%,2)][%tracknumber%. ][%artist% - ]%title%
108    //
109    // Logic: for VA albums, skip the date prefix. For normal albums, show (year) if date exists.
110
111    const PATTERN_1: &str = "%album artist%/$if($stricmp(%album artist%,Various Artists),,['('$left(%date%,4)')' ])%album% '['%codec%']'/[$num(%discnumber%,2)][%tracknumber%. ][%artist% - ]%title%";
112
113    #[test]
114    fn pattern1_normal_album_full_metadata() {
115        let p = provider(&[
116            ("album artist", "Aphex Twin"),
117            ("album", "Selected Ambient Works 85-92"),
118            ("date", "1992-11-09"),
119            ("codec", "FLAC"),
120            ("discnumber", "1"),
121            ("tracknumber", "01"),
122            ("artist", "Aphex Twin"),
123            ("title", "Xtal"),
124        ]);
125        assert_eq!(
126            format(PATTERN_1, &p).unwrap(),
127            "Aphex Twin/(1992) Selected Ambient Works 85-92 [FLAC]/0101. Aphex Twin - Xtal"
128        );
129    }
130
131    #[test]
132    fn pattern1_normal_album_no_disc() {
133        let p = provider(&[
134            ("album artist", "Aphex Twin"),
135            ("album", "Selected Ambient Works 85-92"),
136            ("date", "1992-11-09"),
137            ("codec", "FLAC"),
138            ("tracknumber", "01"),
139            ("artist", "Aphex Twin"),
140            ("title", "Xtal"),
141        ]);
142        assert_eq!(
143            format(PATTERN_1, &p).unwrap(),
144            "Aphex Twin/(1992) Selected Ambient Works 85-92 [FLAC]/01. Aphex Twin - Xtal"
145        );
146    }
147
148    #[test]
149    fn pattern1_normal_album_no_date() {
150        let p = provider(&[
151            ("album artist", "Aphex Twin"),
152            ("album", "Selected Ambient Works 85-92"),
153            ("codec", "FLAC"),
154            ("tracknumber", "01"),
155            ("artist", "Aphex Twin"),
156            ("title", "Xtal"),
157        ]);
158        // No date → the conditional ['(' ... ')' ] is skipped entirely
159        assert_eq!(
160            format(PATTERN_1, &p).unwrap(),
161            "Aphex Twin/Selected Ambient Works 85-92 [FLAC]/01. Aphex Twin - Xtal"
162        );
163    }
164
165    #[test]
166    fn pattern1_va_album_skips_date() {
167        let p = provider(&[
168            ("album artist", "Various Artists"),
169            ("album", "Warp 20 Recreated"),
170            ("date", "2009"),
171            ("codec", "FLAC"),
172            ("tracknumber", "03"),
173            ("artist", "Flying Lotus"),
174            ("title", "Roygbiv"),
175        ]);
176        // VA album → $stricmp matches → $if takes truthy (empty) branch → no date prefix
177        assert_eq!(
178            format(PATTERN_1, &p).unwrap(),
179            "Various Artists/Warp 20 Recreated [FLAC]/03. Flying Lotus - Roygbiv"
180        );
181    }
182
183    #[test]
184    fn pattern1_va_case_insensitive() {
185        let p = provider(&[
186            ("album artist", "various artists"),
187            ("album", "Compilation"),
188            ("date", "2020"),
189            ("codec", "MP3"),
190            ("tracknumber", "01"),
191            ("artist", "Some Artist"),
192            ("title", "Some Track"),
193        ]);
194        assert_eq!(
195            format(PATTERN_1, &p).unwrap(),
196            "various artists/Compilation [MP3]/01. Some Artist - Some Track"
197        );
198    }
199
200    #[test]
201    fn pattern1_artist_same_as_album_artist() {
202        let p = provider(&[
203            ("album artist", "Radiohead"),
204            ("album", "OK Computer"),
205            ("date", "1997-06-16"),
206            ("codec", "FLAC"),
207            ("tracknumber", "01"),
208            ("artist", "Radiohead"),
209            ("title", "Airbag"),
210        ]);
211        // artist == album artist → still shows "Radiohead - " per the pattern
212        assert_eq!(
213            format(PATTERN_1, &p).unwrap(),
214            "Radiohead/(1997) OK Computer [FLAC]/01. Radiohead - Airbag"
215        );
216    }
217
218    #[test]
219    fn pattern1_no_artist_tag() {
220        let p = provider(&[
221            ("album artist", "Radiohead"),
222            ("album", "OK Computer"),
223            ("date", "1997"),
224            ("codec", "FLAC"),
225            ("tracknumber", "01"),
226            ("title", "Airbag"),
227        ]);
228        // No artist tag → the [%artist% - ] conditional is skipped
229        assert_eq!(
230            format(PATTERN_1, &p).unwrap(),
231            "Radiohead/(1997) OK Computer [FLAC]/01. Airbag"
232        );
233    }
234
235    #[test]
236    fn pattern1_multi_disc() {
237        let p = provider(&[
238            ("album artist", "Tool"),
239            ("album", "10,000 Days"),
240            ("date", "2006"),
241            ("codec", "FLAC"),
242            ("discnumber", "1"),
243            ("tracknumber", "01"),
244            ("artist", "Tool"),
245            ("title", "Vicarious"),
246        ]);
247        assert_eq!(
248            format(PATTERN_1, &p).unwrap(),
249            "Tool/(2006) 10,000 Days [FLAC]/0101. Tool - Vicarious"
250        );
251    }
252
253    // Pattern 2: $if2(%label%,%album artist%)/%album% '['%codec%']'/[$num(%discnumber%,2)][%tracknumber%. ][%artist% - ]%title%
254    //
255    // Logic: use label if available, fall back to album artist. Rest is same structure.
256
257    const PATTERN_2: &str = "$if2(%label%,%album artist%)/%album% '['%codec%']'/[$num(%discnumber%,2)][%tracknumber%. ][%artist% - ]%title%";
258
259    #[test]
260    fn pattern2_with_label() {
261        let p = provider(&[
262            ("label", "Warp Records"),
263            ("album artist", "Aphex Twin"),
264            ("album", "Selected Ambient Works 85-92"),
265            ("codec", "FLAC"),
266            ("tracknumber", "01"),
267            ("artist", "Aphex Twin"),
268            ("title", "Xtal"),
269        ]);
270        assert_eq!(
271            format(PATTERN_2, &p).unwrap(),
272            "Warp Records/Selected Ambient Works 85-92 [FLAC]/01. Aphex Twin - Xtal"
273        );
274    }
275
276    #[test]
277    fn pattern2_no_label_fallback_to_artist() {
278        let p = provider(&[
279            ("album artist", "Aphex Twin"),
280            ("album", "Selected Ambient Works 85-92"),
281            ("codec", "FLAC"),
282            ("tracknumber", "01"),
283            ("artist", "Aphex Twin"),
284            ("title", "Xtal"),
285        ]);
286        assert_eq!(
287            format(PATTERN_2, &p).unwrap(),
288            "Aphex Twin/Selected Ambient Works 85-92 [FLAC]/01. Aphex Twin - Xtal"
289        );
290    }
291
292    #[test]
293    fn pattern2_with_disc_number() {
294        let p = provider(&[
295            ("album artist", "Tool"),
296            ("album", "10,000 Days"),
297            ("codec", "FLAC"),
298            ("discnumber", "2"),
299            ("tracknumber", "01"),
300            ("artist", "Tool"),
301            ("title", "Wings for Marie"),
302        ]);
303        assert_eq!(
304            format(PATTERN_2, &p).unwrap(),
305            "Tool/10,000 Days [FLAC]/0201. Tool - Wings for Marie"
306        );
307    }
308
309    #[test]
310    fn pattern2_minimal_metadata() {
311        let p = provider(&[
312            ("album artist", "Unknown"),
313            ("album", "Untitled"),
314            ("codec", "MP3"),
315            ("title", "Track 1"),
316        ]);
317        // No disc, no tracknumber, no artist → all conditionals skipped
318        assert_eq!(
319            format(PATTERN_2, &p).unwrap(),
320            "Unknown/Untitled [MP3]/Track 1"
321        );
322    }
323
324    // ======================================================================
325    // Additional edge cases
326    // ======================================================================
327
328    #[test]
329    fn nested_function_in_function() {
330        let p = provider(&[("artist", "aphex twin")]);
331        assert_eq!(format("$upper($left(%artist%,5))", &p).unwrap(), "APHEX");
332    }
333
334    #[test]
335    fn multiple_conditionals_adjacent() {
336        let p = provider(&[("artist", "Radiohead"), ("album", "OK Computer")]);
337        assert_eq!(
338            format("[%artist%][ - %album%]", &p).unwrap(),
339            "Radiohead - OK Computer"
340        );
341    }
342
343    #[test]
344    fn multiple_conditionals_partial() {
345        let p = provider(&[("artist", "Radiohead")]);
346        assert_eq!(
347            format("[%artist%][ - %album%][ (%date%)]", &p).unwrap(),
348            "Radiohead"
349        );
350    }
351
352    #[test]
353    fn empty_format_string() {
354        let p = provider(&[]);
355        assert_eq!(format("", &p).unwrap(), "");
356    }
357
358    #[test]
359    fn only_literals() {
360        let p = provider(&[]);
361        assert_eq!(format("hello world", &p).unwrap(), "hello world");
362    }
363
364    #[test]
365    fn quoted_brackets_outside_conditional() {
366        let p = provider(&[("codec", "FLAC")]);
367        assert_eq!(format("'['%codec%']'", &p).unwrap(), "[FLAC]");
368    }
369
370    #[test]
371    fn if_with_stricmp_nested_in_conditional() {
372        // $if inside a conditional: the conditional tracks field resolution
373        let p = provider(&[("album artist", "Radiohead"), ("date", "1997")]);
374        assert_eq!(
375            format(
376                "[$if($stricmp(%album artist%,Various Artists),,'('$left(%date%,4)')' )]%album artist%",
377                &p
378            )
379            .unwrap(),
380            "(1997) Radiohead"
381        );
382    }
383
384    #[test]
385    fn stricmp_in_if_empty_then_branch() {
386        // The key pattern: $if($stricmp(x,y),,alternative) — empty then branch
387        let p = provider(&[("album artist", "Radiohead")]);
388        assert_eq!(
389            format("$if($stricmp(%album artist%,Radiohead),,not radiohead)", &p).unwrap(),
390            "" // stricmp matches → takes empty then branch
391        );
392    }
393
394    #[test]
395    fn stricmp_in_if_else_branch() {
396        let p = provider(&[("album artist", "Aphex Twin")]);
397        assert_eq!(
398            format("$if($stricmp(%album artist%,Radiohead),,not radiohead)", &p).unwrap(),
399            "not radiohead" // stricmp doesn't match → takes else branch
400        );
401    }
402
403    #[test]
404    fn if2_both_empty() {
405        let p = provider(&[]);
406        // Both %label% and %album artist% missing → $if2 returns None → function returns None → empty
407        assert_eq!(format("$if2(%label%,%album artist%)", &p).unwrap(), "");
408    }
409
410    #[test]
411    fn organize_standard_layout() {
412        // Standard organize pattern from docs/format-strings.md
413        let p = provider(&[
414            ("album artist", "Aphex Twin"),
415            ("date", "1999"),
416            ("album", "Windowlicker EP"),
417            ("tracknumber", "01"),
418            ("title", "Windowlicker"),
419        ]);
420        assert_eq!(
421            format("%album artist%/(%date%) %album%/%tracknumber%. %title%", &p).unwrap(),
422            "Aphex Twin/(1999) Windowlicker EP/01. Windowlicker"
423        );
424    }
425
426    #[test]
427    fn organize_multi_disc() {
428        // Multi-disc organize pattern from docs/format-strings.md
429        let p = provider(&[
430            ("album artist", "Tool"),
431            ("date", "2006"),
432            ("album", "10,000 Days"),
433            ("discnumber", "1"),
434            ("tracknumber", "01"),
435            ("title", "Vicarious"),
436        ]);
437        assert_eq!(
438            format(
439                "%album artist%/[(%date%) ]%album%/[%discnumber%-]%tracknumber%. %title%",
440                &p
441            )
442            .unwrap(),
443            "Tool/(2006) 10,000 Days/1-01. Vicarious"
444        );
445    }
446
447    #[test]
448    fn organize_multi_disc_no_disc() {
449        let p = provider(&[
450            ("album artist", "Tool"),
451            ("date", "2006"),
452            ("album", "10,000 Days"),
453            ("tracknumber", "01"),
454            ("title", "Vicarious"),
455        ]);
456        assert_eq!(
457            format(
458                "%album artist%/[(%date%) ]%album%/[%discnumber%-]%tracknumber%. %title%",
459                &p
460            )
461            .unwrap(),
462            "Tool/(2006) 10,000 Days/01. Vicarious"
463        );
464    }
465
466    #[test]
467    fn organize_if2_fallback() {
468        // Fallback artist pattern from docs/format-strings.md
469        let p = provider(&[
470            ("artist", "Flying Lotus"),
471            ("album", "Cosmogramma"),
472            ("tracknumber", "01"),
473            ("title", "Clock Catcher"),
474        ]);
475        assert_eq!(
476            format(
477                "$if2(%album artist%,%artist%)/%album%/%tracknumber%. %title%",
478                &p
479            )
480            .unwrap(),
481            "Flying Lotus/Cosmogramma/01. Clock Catcher"
482        );
483    }
484
485    #[test]
486    fn organize_genre_based() {
487        let p = provider(&[
488            ("genre", "Electronic"),
489            ("album artist", "Aphex Twin"),
490            ("album", "Windowlicker EP"),
491            ("tracknumber", "01"),
492            ("title", "Windowlicker"),
493        ]);
494        assert_eq!(
495            format(
496                "[%genre%/]%album artist%/%album%/%tracknumber%. %title%",
497                &p
498            )
499            .unwrap(),
500            "Electronic/Aphex Twin/Windowlicker EP/01. Windowlicker"
501        );
502    }
503
504    #[test]
505    fn organize_genre_based_no_genre() {
506        let p = provider(&[
507            ("album artist", "Aphex Twin"),
508            ("album", "Windowlicker EP"),
509            ("tracknumber", "01"),
510            ("title", "Windowlicker"),
511        ]);
512        assert_eq!(
513            format(
514                "[%genre%/]%album artist%/%album%/%tracknumber%. %title%",
515                &p
516            )
517            .unwrap(),
518            "Aphex Twin/Windowlicker EP/01. Windowlicker"
519        );
520    }
521
522    // ======================================================================
523    // Real-world data: Bicep — CHROMA 000
524    //
525    // Actual Vorbis tags from the FLAC files:
526    //   ALBUM: CHROMA 000
527    //   ALBUM ARTIST: Bicep
528    //   ARTIST: Bicep
529    //   DATE: 2025-11-21
530    //   DISC: 1
531    //   LABEL: CHROMA
532    //   TITLE: CHROMA 001 HELIUM
533    //   TRACK: 1
534    //   codec: FLAC
535    //
536    // Expected foobar2000 output for the default organize pattern:
537    //   Bicep/(2025) CHROMA 000 [FLAC]/0101. Bicep - CHROMA 001 HELIUM
538    // ======================================================================
539
540    fn bicep_chroma_track1() -> HashMap<String, String> {
541        provider(&[
542            ("album artist", "Bicep"),
543            ("album", "CHROMA 000"),
544            ("artist", "Bicep"),
545            ("date", "2025-11-21"),
546            ("discnumber", "1"),
547            ("label", "CHROMA"),
548            ("title", "CHROMA 001 HELIUM"),
549            ("tracknumber", "01"),
550            ("codec", "FLAC"),
551        ])
552    }
553
554    fn bicep_chroma_track3_feat() -> HashMap<String, String> {
555        provider(&[
556            ("album artist", "Bicep"),
557            ("album", "CHROMA 000"),
558            ("artist", "Dove"),
559            ("date", "2025-11-21"),
560            ("discnumber", "1"),
561            ("label", "CHROMA"),
562            ("title", "CHROMA 003 Bi83 feat. Dove"),
563            ("tracknumber", "03"),
564            ("codec", "FLAC"),
565        ])
566    }
567
568    #[test]
569    fn chroma_pattern2_label_present() {
570        // Pattern 2 with label present: $if2(%label%,%album artist%) → "CHROMA" (label wins)
571        let p = bicep_chroma_track1();
572        assert_eq!(
573            format(PATTERN_2, &p).unwrap(),
574            "CHROMA/CHROMA 000 [FLAC]/0101. Bicep - CHROMA 001 HELIUM"
575        );
576    }
577
578    #[test]
579    fn chroma_pattern1_uses_album_artist_and_date() {
580        // Pattern 1 uses %album artist% directly + date prefix → matches fb2k output
581        let p = bicep_chroma_track1();
582        assert_eq!(
583            format(PATTERN_1, &p).unwrap(),
584            "Bicep/(2025) CHROMA 000 [FLAC]/0101. Bicep - CHROMA 001 HELIUM"
585        );
586    }
587
588    #[test]
589    fn chroma_pattern1_feat_artist() {
590        // Track with different artist than album artist
591        let p = bicep_chroma_track3_feat();
592        assert_eq!(
593            format(PATTERN_1, &p).unwrap(),
594            "Bicep/(2025) CHROMA 000 [FLAC]/0103. Dove - CHROMA 003 Bi83 feat. Dove"
595        );
596    }
597
598    #[test]
599    fn chroma_pattern2_no_label_falls_through() {
600        // Same track data but without label → falls through to album artist
601        let mut p = bicep_chroma_track1();
602        p.remove("label");
603        assert_eq!(
604            format(PATTERN_2, &p).unwrap(),
605            "Bicep/CHROMA 000 [FLAC]/0101. Bicep - CHROMA 001 HELIUM"
606        );
607    }
608
609    // Pattern with date prefix (what the user actually wants for the fb2k output):
610    // $if2(%label%,%album artist%)/['('$left(%date%,4)')' ]%album% '['%codec%']'/[$num(%discnumber%,2)][%tracknumber%. ][%artist% - ]%title%
611    const PATTERN_2_WITH_DATE: &str = "$if2(%label%,%album artist%)/['('$left(%date%,4)')' ]%album% '['%codec%']'/[$num(%discnumber%,2)][%tracknumber%. ][%artist% - ]%title%";
612
613    #[test]
614    fn chroma_pattern2_with_date_label_present() {
615        let p = bicep_chroma_track1();
616        assert_eq!(
617            format(PATTERN_2_WITH_DATE, &p).unwrap(),
618            "CHROMA/(2025) CHROMA 000 [FLAC]/0101. Bicep - CHROMA 001 HELIUM"
619        );
620    }
621
622    #[test]
623    fn chroma_pattern2_with_date_no_label() {
624        let mut p = bicep_chroma_track1();
625        p.remove("label");
626        assert_eq!(
627            format(PATTERN_2_WITH_DATE, &p).unwrap(),
628            "Bicep/(2025) CHROMA 000 [FLAC]/0101. Bicep - CHROMA 001 HELIUM"
629        );
630    }
631
632    #[test]
633    fn chroma_track11_tangz_ii() {
634        // Track 11: CHROMA 012 TANGZ II — artist same as album artist
635        let p = provider(&[
636            ("album artist", "Bicep"),
637            ("album", "CHROMA 000"),
638            ("artist", "Bicep"),
639            ("date", "2025-11-21"),
640            ("discnumber", "1"),
641            ("title", "CHROMA 012 TANGZ II"),
642            ("tracknumber", "11"),
643            ("codec", "FLAC"),
644        ]);
645        assert_eq!(
646            format(PATTERN_1, &p).unwrap(),
647            "Bicep/(2025) CHROMA 000 [FLAC]/0111. Bicep - CHROMA 012 TANGZ II"
648        );
649    }
650
651    #[test]
652    fn chroma_track10_aloe_ii_dots_in_title() {
653        // Track 10: title "A.L.O.E II" contains dots — must not be truncated
654        let p = provider(&[
655            ("album artist", "Bicep"),
656            ("album", "CHROMA 000"),
657            ("artist", "Bicep"),
658            ("date", "2025-11-21"),
659            ("discnumber", "1"),
660            ("title", "CHROMA 011 A.L.O.E II"),
661            ("tracknumber", "10"),
662            ("codec", "FLAC"),
663        ]);
664        assert_eq!(
665            format(PATTERN_1, &p).unwrap(),
666            "Bicep/(2025) CHROMA 000 [FLAC]/0110. Bicep - CHROMA 011 A.L.O.E II"
667        );
668    }
669
670    #[test]
671    fn chroma_track2_feat_in_title() {
672        // Track 2: different artist, "feat." in title — dots must survive
673        let p = provider(&[
674            ("album artist", "Bicep"),
675            ("album", "CHROMA 000"),
676            ("artist", "BDB, Bicep"),
677            ("date", "2025-11-21"),
678            ("discnumber", "1"),
679            ("title", "CHROMA 002 L.A.V.A feat. Benjamin Damage"),
680            ("tracknumber", "02"),
681            ("codec", "FLAC"),
682        ]);
683        assert_eq!(
684            format(PATTERN_1, &p).unwrap(),
685            "Bicep/(2025) CHROMA 000 [FLAC]/0102. BDB, Bicep - CHROMA 002 L.A.V.A feat. Benjamin Damage"
686        );
687    }
688}