Skip to main content

koan_core/format/
functions.rs

1use std::path::Path;
2
3/// Every function the engine implements. Parsing rejects anything not listed here,
4/// so a typo is a clear error rather than a silently mangled path.
5pub const KNOWN_FUNCTIONS: &[&str] = &[
6    "left",
7    "right",
8    "pad",
9    "pad_right",
10    "padcut",
11    "padcut_right",
12    "replace",
13    "trim",
14    "lower",
15    "upper",
16    "caps",
17    "caps2",
18    "abbr",
19    "substr",
20    "insert",
21    "repeat",
22    "stripprefix",
23    "swapprefix",
24    "rot13",
25    "fix_eol",
26    "strchr",
27    "strrchr",
28    "strstr",
29    "strcmp",
30    "stricmp",
31    "longer",
32    "longest",
33    "shortest",
34    "if",
35    "if2",
36    "if3",
37    "ifequal",
38    "ifgreater",
39    "iflonger",
40    "select",
41    "not",
42    "and",
43    "or",
44    "xor",
45    "greater",
46    "num",
47    "add",
48    "sub",
49    "mul",
50    "muldiv",
51    "div",
52    "mod",
53    "max",
54    "min",
55    "hex",
56    "directory",
57    "directory_path",
58    "ext",
59    "filename",
60    "tab",
61    "crlf",
62    "char",
63    "info",
64    "len",
65];
66
67pub fn is_known_function(name: &str) -> bool {
68    KNOWN_FUNCTIONS.contains(&name)
69}
70
71/// Ceiling on length-driven allocations (`$pad`, `$repeat`, `$num`, `$tab`), so a
72/// runaway count in a format string can't exhaust memory.
73const MAX_GENERATED_LEN: usize = 4096;
74
75/// Parse a length argument, rejecting anything that would allocate without bound.
76fn bounded_len(arg: &str) -> Option<usize> {
77    let n: usize = arg.parse().ok()?;
78    (n <= MAX_GENERATED_LEN).then_some(n)
79}
80
81/// fb2k boolean: truthy = "1", falsy = ""
82fn bool_str(v: bool) -> String {
83    if v { "1".into() } else { String::new() }
84}
85
86pub fn call_function(name: &str, args: &[String]) -> Option<String> {
87    match name {
88        // --- String functions ---
89        "left" => {
90            let s = args.first()?;
91            let n: usize = args.get(1)?.parse().ok()?;
92            Some(s.chars().take(n).collect())
93        }
94        "right" => {
95            let s = args.first()?;
96            let n: usize = args.get(1)?.parse().ok()?;
97            let chars: Vec<char> = s.chars().collect();
98            let start = chars.len().saturating_sub(n);
99            Some(chars[start..].iter().collect())
100        }
101        "pad" => {
102            let s = args.first()?;
103            let n = bounded_len(args.get(1)?)?;
104            Some(format!("{s:>n$}"))
105        }
106        "pad_right" => {
107            let s = args.first()?;
108            let n = bounded_len(args.get(1)?)?;
109            Some(format!("{s:<n$}"))
110        }
111        "padcut" => {
112            let s = args.first()?;
113            let n = bounded_len(args.get(1)?)?;
114            let padded = format!("{s:>n$}");
115            Some(padded.chars().take(n).collect())
116        }
117        "padcut_right" => {
118            let s = args.first()?;
119            let n = bounded_len(args.get(1)?)?;
120            let padded = format!("{s:<n$}");
121            Some(padded.chars().take(n).collect())
122        }
123        "replace" => {
124            let s = args.first()?;
125            let from = args.get(1)?;
126            let to = args.get(2)?;
127            Some(s.replace(from.as_str(), to.as_str()))
128        }
129        "trim" => Some(args.first()?.trim().to_string()),
130        "lower" => Some(args.first()?.to_lowercase()),
131        "upper" => Some(args.first()?.to_uppercase()),
132        "caps" => {
133            let s = args.first()?;
134            Some(capitalize_words(s))
135        }
136        "caps2" => {
137            let s = args.first()?;
138            Some(capitalize_words_smart(s))
139        }
140        "abbr" => {
141            let s = args.first()?;
142            Some(abbreviate(s))
143        }
144        "substr" => {
145            let s = args.first()?;
146            let from: usize = args.get(1)?.parse().ok()?;
147            let to: usize = args.get(2)?.parse().ok()?;
148            let chars: Vec<char> = s.chars().collect();
149            let start = from.min(chars.len());
150            let end = to.min(chars.len());
151            if start > end {
152                Some(String::new())
153            } else {
154                Some(chars[start..end].iter().collect())
155            }
156        }
157        "insert" => {
158            let s = args.first()?;
159            let sub = args.get(1)?;
160            let pos: usize = args.get(2)?.parse().ok()?;
161            let mut chars: Vec<char> = s.chars().collect();
162            let idx = pos.min(chars.len());
163            for (i, c) in sub.chars().enumerate() {
164                chars.insert(idx + i, c);
165            }
166            Some(chars.into_iter().collect())
167        }
168        "repeat" => {
169            let s = args.first()?;
170            let n = bounded_len(args.get(1)?)?;
171            (s.len().checked_mul(n)? <= MAX_GENERATED_LEN).then(|| s.repeat(n))
172        }
173        "stripprefix" => {
174            let s = args.first()?;
175            // Custom prefix list or default articles
176            let prefixes = if args.len() > 1 {
177                args[1..].to_vec()
178            } else {
179                vec!["A ".into(), "The ".into()]
180            };
181            for prefix in &prefixes {
182                if let Some(rest) = s.strip_prefix(prefix.as_str()) {
183                    return Some(rest.to_string());
184                }
185            }
186            Some(s.clone())
187        }
188        "swapprefix" => {
189            let s = args.first()?;
190            let prefixes = if args.len() > 1 {
191                args[1..].to_vec()
192            } else {
193                vec!["A ".into(), "The ".into()]
194            };
195            for prefix in &prefixes {
196                if let Some(rest) = s.strip_prefix(prefix.as_str()) {
197                    return Some(format!("{}, {}", rest, prefix.trim()));
198                }
199            }
200            Some(s.clone())
201        }
202        "rot13" => {
203            let s = args.first()?;
204            Some(
205                s.chars()
206                    .map(|c| match c {
207                        'a'..='m' | 'A'..='M' => (c as u8 + 13) as char,
208                        'n'..='z' | 'N'..='Z' => (c as u8 - 13) as char,
209                        _ => c,
210                    })
211                    .collect(),
212            )
213        }
214        "fix_eol" => {
215            let s = args.first()?;
216            let replacement = args.get(1).map(|s| s.as_str()).unwrap_or(" ");
217            Some(s.replace(['\r', '\n'], replacement))
218        }
219
220        // --- String search ---
221        "strchr" => {
222            let s = args.first()?;
223            let c = args.get(1)?.chars().next()?;
224            Some(s.find(c).map_or(String::new(), |i| (i + 1).to_string()))
225        }
226        "strrchr" => {
227            let s = args.first()?;
228            let c = args.get(1)?.chars().next()?;
229            Some(s.rfind(c).map_or(String::new(), |i| (i + 1).to_string()))
230        }
231        "strstr" => {
232            let s = args.first()?;
233            let sub = args.get(1)?;
234            Some(
235                s.find(sub.as_str())
236                    .map_or(String::new(), |i| (i + 1).to_string()),
237            )
238        }
239
240        // --- String comparison (boolean) ---
241        "strcmp" => {
242            let a = args.first()?;
243            let b = args.get(1)?;
244            Some(bool_str(a == b))
245        }
246        "stricmp" => {
247            let a = args.first()?;
248            let b = args.get(1)?;
249            Some(bool_str(a.to_lowercase() == b.to_lowercase()))
250        }
251        "longer" => {
252            let a = args.first()?;
253            let b = args.get(1)?;
254            Some(bool_str(a.len() > b.len()))
255        }
256        "longest" => args.iter().max_by_key(|s| s.len()).cloned(),
257        "shortest" => args.iter().min_by_key(|s| s.len()).cloned(),
258
259        // --- Logic functions ---
260        "if" => {
261            let cond = args.first()?;
262            if !cond.is_empty() {
263                // then branch — may be empty string (valid for $if(cond,,else) pattern)
264                Some(args.get(1).cloned().unwrap_or_default())
265            } else {
266                Some(args.get(2).cloned().unwrap_or_default())
267            }
268        }
269        "if2" => {
270            let a = args.first()?;
271            if !a.is_empty() {
272                Some(a.clone())
273            } else {
274                Some(args.get(1)?.clone())
275            }
276        }
277        "if3" => args
278            .iter()
279            .find(|a| !a.is_empty())
280            .cloned()
281            .or(Some(String::new())),
282        "ifequal" => {
283            let a: i64 = args.first()?.parse().ok()?;
284            let b: i64 = args.get(1)?.parse().ok()?;
285            if a == b {
286                Some(args.get(2).cloned().unwrap_or_default())
287            } else {
288                Some(args.get(3).cloned().unwrap_or_default())
289            }
290        }
291        "ifgreater" => {
292            let a: i64 = args.first()?.parse().ok()?;
293            let b: i64 = args.get(1)?.parse().ok()?;
294            if a > b {
295                Some(args.get(2).cloned().unwrap_or_default())
296            } else {
297                Some(args.get(3).cloned().unwrap_or_default())
298            }
299        }
300        "iflonger" => {
301            let s = args.first()?;
302            let n: usize = args.get(1)?.parse().ok()?;
303            if s.len() > n {
304                Some(args.get(2).cloned().unwrap_or_default())
305            } else {
306                Some(args.get(3).cloned().unwrap_or_default())
307            }
308        }
309        "select" => {
310            let n: usize = args.first()?.parse().ok()?;
311            if n == 0 || n > args.len() - 1 {
312                Some(String::new())
313            } else {
314                Some(args[n].clone())
315            }
316        }
317        "not" => {
318            let a = args.first()?;
319            Some(bool_str(a.is_empty()))
320        }
321        "and" => {
322            let a = args.first()?;
323            let b = args.get(1)?;
324            Some(bool_str(!a.is_empty() && !b.is_empty()))
325        }
326        "or" => {
327            let a = args.first()?;
328            let b = args.get(1)?;
329            Some(bool_str(!a.is_empty() || !b.is_empty()))
330        }
331        "xor" => {
332            let a = args.first()?;
333            let b = args.get(1)?;
334            Some(bool_str(a.is_empty() != b.is_empty()))
335        }
336        "greater" => {
337            let a: i64 = args.first()?.parse().ok()?;
338            let b: i64 = args.get(1)?.parse().ok()?;
339            Some(bool_str(a > b))
340        }
341
342        // --- Numeric functions ---
343        "num" => {
344            let n = args.first()?;
345            let digits = bounded_len(args.get(1)?)?;
346            Some(format!("{n:0>digits$}"))
347        }
348        "add" => {
349            let a: i64 = args.first()?.parse().ok()?;
350            let b: i64 = args.get(1)?.parse().ok()?;
351            Some(a.checked_add(b)?.to_string())
352        }
353        "sub" => {
354            let a: i64 = args.first()?.parse().ok()?;
355            let b: i64 = args.get(1)?.parse().ok()?;
356            Some(a.checked_sub(b)?.to_string())
357        }
358        "mul" => {
359            let a: i64 = args.first()?.parse().ok()?;
360            let b: i64 = args.get(1)?.parse().ok()?;
361            Some(a.checked_mul(b)?.to_string())
362        }
363        "muldiv" => {
364            let a: i64 = args.first()?.parse().ok()?;
365            let b: i64 = args.get(1)?.parse().ok()?;
366            let c: i64 = args.get(2)?.parse().ok()?;
367            if c == 0 {
368                return Some(String::new());
369            }
370            Some(((a as i128 * b as i128) / c as i128).to_string())
371        }
372        "div" => {
373            let a: i64 = args.first()?.parse().ok()?;
374            let b: i64 = args.get(1)?.parse().ok()?;
375            Some(a.checked_div(b).map_or(String::new(), |v| v.to_string()))
376        }
377        "mod" => {
378            let a: i64 = args.first()?.parse().ok()?;
379            let b: i64 = args.get(1)?.parse().ok()?;
380            Some(a.checked_rem(b).map_or(String::new(), |v| v.to_string()))
381        }
382        "max" => {
383            let a: i64 = args.first()?.parse().ok()?;
384            let b: i64 = args.get(1)?.parse().ok()?;
385            Some(a.max(b).to_string())
386        }
387        "min" => {
388            let a: i64 = args.first()?.parse().ok()?;
389            let b: i64 = args.get(1)?.parse().ok()?;
390            Some(a.min(b).to_string())
391        }
392        "hex" => {
393            let n: i64 = args.first()?.parse().ok()?;
394            let digits = match args.get(1) {
395                Some(a) => bounded_len(a)?,
396                None => 0,
397            };
398            if digits > 0 {
399                Some(format!("{n:0>width$X}", width = digits))
400            } else {
401                Some(format!("{n:X}"))
402            }
403        }
404
405        // --- Path functions ---
406        "directory" => {
407            let p = Path::new(args.first()?);
408            p.parent()
409                .and_then(|p| p.file_name())
410                .and_then(|n| n.to_str())
411                .map(String::from)
412        }
413        "directory_path" => {
414            let p = Path::new(args.first()?);
415            p.parent().and_then(|p| p.to_str()).map(String::from)
416        }
417        "ext" => {
418            let p = Path::new(args.first()?);
419            p.extension().and_then(|e| e.to_str()).map(String::from)
420        }
421        "filename" => {
422            let p = Path::new(args.first()?);
423            p.file_stem().and_then(|s| s.to_str()).map(String::from)
424        }
425
426        // --- Special character functions ---
427        "tab" => {
428            let n = match args.first() {
429                Some(a) => bounded_len(a)?,
430                None => 1,
431            };
432            Some("\t".repeat(n))
433        }
434        "crlf" => Some("\r\n".into()),
435        "char" => {
436            let n: u32 = args.first()?.parse().ok()?;
437            char::from_u32(n).map(|c| c.to_string())
438        }
439
440        // --- Meta functions ---
441        "info" => Some(args.first()?.clone()),
442        "len" => Some(args.first()?.len().to_string()),
443
444        _ => None,
445    }
446}
447
448fn capitalize_words(s: &str) -> String {
449    s.split_inclusive(char::is_whitespace)
450        .map(|word| {
451            let mut chars = word.chars();
452            match chars.next() {
453                Some(c) => {
454                    let upper: String = c.to_uppercase().collect();
455                    upper + &chars.as_str().to_lowercase()
456                }
457                None => String::new(),
458            }
459        })
460        .collect()
461}
462
463/// Like capitalize_words but keeps articles/prepositions lowercase (unless first word).
464fn capitalize_words_smart(s: &str) -> String {
465    const LOWER_WORDS: &[&str] = &[
466        "a", "an", "the", "and", "or", "nor", "but", "in", "on", "at", "to", "for", "of", "with",
467        "by", "from", "as", "is", "vs",
468    ];
469    let words: Vec<&str> = s.split_inclusive(char::is_whitespace).collect();
470    words
471        .iter()
472        .enumerate()
473        .map(|(i, word)| {
474            let trimmed = word.trim().to_lowercase();
475            if i > 0 && LOWER_WORDS.contains(&trimmed.as_str()) {
476                word.to_lowercase()
477            } else {
478                let mut chars = word.chars();
479                match chars.next() {
480                    Some(c) => {
481                        let upper: String = c.to_uppercase().collect();
482                        upper + &chars.as_str().to_lowercase()
483                    }
484                    None => String::new(),
485                }
486            }
487        })
488        .collect()
489}
490
491/// Abbreviate: take first letter of each word.
492fn abbreviate(s: &str) -> String {
493    s.split_whitespace()
494        .filter_map(|w| w.chars().next())
495        .collect()
496}
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501
502    /// The parser rejects anything not in `KNOWN_FUNCTIONS`, so a name in the list
503    /// that no arm implements would be accepted at parse time and vanish at eval time.
504    #[test]
505    fn every_listed_function_is_implemented() {
506        let arg_sets = [
507            vec!["a/b/c.flac", "2", "1", "1"],
508            vec!["65", "2", "1", "1"],
509            vec!["The Beatles", "The ", "A ", "1"],
510        ];
511        for name in KNOWN_FUNCTIONS {
512            assert!(
513                arg_sets
514                    .iter()
515                    .any(|args| call_function(name, &s(args)).is_some()),
516                "{name} is listed but not implemented"
517            );
518        }
519        assert!(!is_known_function("nun"));
520    }
521
522    #[test]
523    fn length_driven_allocations_are_capped() {
524        assert_eq!(call_function("repeat", &s(&["a", "99999999999"])), None);
525        assert_eq!(call_function("pad", &s(&["a", "99999999999"])), None);
526        assert_eq!(call_function("padcut", &s(&["a", "99999999999"])), None);
527        assert_eq!(call_function("num", &s(&["1", "99999999999"])), None);
528        assert_eq!(call_function("tab", &s(&["99999999999"])), None);
529        // A repeat whose product overflows the cap is refused too.
530        assert_eq!(call_function("repeat", &s(&["abcdefghij", "4000"])), None);
531        assert_eq!(
532            call_function("repeat", &s(&["ab", "3"])),
533            Some("ababab".into())
534        );
535    }
536
537    #[test]
538    fn arithmetic_overflow_yields_no_value() {
539        assert_eq!(
540            call_function("add", &s(&["9223372036854775807", "1"])),
541            None
542        );
543        assert_eq!(
544            call_function("sub", &s(&["-9223372036854775808", "1"])),
545            None
546        );
547        assert_eq!(
548            call_function("mul", &s(&["9223372036854775807", "2"])),
549            None
550        );
551        assert_eq!(call_function("div", &s(&["1", "0"])), Some(String::new()));
552        assert_eq!(call_function("mod", &s(&["1", "0"])), Some(String::new()));
553    }
554
555    // ---- String functions ----
556
557    #[test]
558    fn left() {
559        assert_eq!(
560            call_function("left", &s(&["hello", "3"])),
561            Some("hel".into())
562        );
563    }
564
565    #[test]
566    fn left_exceeds_length() {
567        assert_eq!(call_function("left", &s(&["hi", "10"])), Some("hi".into()));
568    }
569
570    #[test]
571    fn left_zero() {
572        assert_eq!(call_function("left", &s(&["hello", "0"])), Some("".into()));
573    }
574
575    #[test]
576    fn left_unicode() {
577        assert_eq!(
578            call_function("left", &s(&["\u{00e9}l\u{00e8}ve", "3"])),
579            Some("\u{00e9}l\u{00e8}".into())
580        );
581    }
582
583    #[test]
584    fn right() {
585        assert_eq!(
586            call_function("right", &s(&["hello", "3"])),
587            Some("llo".into())
588        );
589    }
590
591    #[test]
592    fn right_exceeds_length() {
593        assert_eq!(call_function("right", &s(&["hi", "10"])), Some("hi".into()));
594    }
595
596    #[test]
597    fn pad_left() {
598        assert_eq!(call_function("pad", &s(&["42", "5"])), Some("   42".into()));
599    }
600
601    #[test]
602    fn pad_left_already_wider() {
603        assert_eq!(
604            call_function("pad", &s(&["hello", "3"])),
605            Some("hello".into())
606        );
607    }
608
609    #[test]
610    fn pad_right() {
611        assert_eq!(
612            call_function("pad_right", &s(&["42", "5"])),
613            Some("42   ".into())
614        );
615    }
616
617    #[test]
618    fn padcut() {
619        assert_eq!(
620            call_function("padcut", &s(&["hi", "5"])),
621            Some("   hi".into())
622        );
623        assert_eq!(
624            call_function("padcut", &s(&["hello world", "5"])),
625            Some("hello".into())
626        );
627    }
628
629    #[test]
630    fn padcut_right() {
631        assert_eq!(
632            call_function("padcut_right", &s(&["hi", "5"])),
633            Some("hi   ".into())
634        );
635        assert_eq!(
636            call_function("padcut_right", &s(&["hello world", "5"])),
637            Some("hello".into())
638        );
639    }
640
641    #[test]
642    fn replace() {
643        assert_eq!(
644            call_function("replace", &s(&["hello world", "world", "rust"])),
645            Some("hello rust".into())
646        );
647    }
648
649    #[test]
650    fn replace_no_match() {
651        assert_eq!(
652            call_function("replace", &s(&["hello", "xyz", "abc"])),
653            Some("hello".into())
654        );
655    }
656
657    #[test]
658    fn trim() {
659        assert_eq!(
660            call_function("trim", &s(&["  hello  "])),
661            Some("hello".into())
662        );
663    }
664
665    #[test]
666    fn trim_empty() {
667        assert_eq!(call_function("trim", &s(&["   "])), Some("".into()));
668    }
669
670    #[test]
671    fn lower() {
672        assert_eq!(call_function("lower", &s(&["HELLO"])), Some("hello".into()));
673    }
674
675    #[test]
676    fn upper() {
677        assert_eq!(call_function("upper", &s(&["hello"])), Some("HELLO".into()));
678    }
679
680    #[test]
681    fn caps() {
682        assert_eq!(
683            call_function("caps", &s(&["hello world"])),
684            Some("Hello World".into())
685        );
686    }
687
688    #[test]
689    fn caps_mixed_case() {
690        assert_eq!(
691            call_function("caps", &s(&["hELLO wORLD"])),
692            Some("Hello World".into())
693        );
694    }
695
696    #[test]
697    fn caps2_articles() {
698        assert_eq!(
699            call_function("caps2", &s(&["the quick and the dead"])),
700            Some("The Quick and the Dead".into())
701        );
702    }
703
704    #[test]
705    fn abbr() {
706        assert_eq!(
707            call_function("abbr", &s(&["Aphex Twin"])),
708            Some("AT".into())
709        );
710        assert_eq!(
711            call_function("abbr", &s(&["The Chemical Brothers"])),
712            Some("TCB".into())
713        );
714    }
715
716    #[test]
717    fn substr() {
718        assert_eq!(
719            call_function("substr", &s(&["hello world", "6", "11"])),
720            Some("world".into())
721        );
722    }
723
724    #[test]
725    fn substr_out_of_bounds() {
726        assert_eq!(
727            call_function("substr", &s(&["hi", "0", "100"])),
728            Some("hi".into())
729        );
730    }
731
732    #[test]
733    fn insert() {
734        assert_eq!(
735            call_function("insert", &s(&["hello", "XX", "2"])),
736            Some("heXXllo".into())
737        );
738    }
739
740    #[test]
741    fn repeat() {
742        assert_eq!(
743            call_function("repeat", &s(&["ab", "3"])),
744            Some("ababab".into())
745        );
746    }
747
748    #[test]
749    fn repeat_zero() {
750        assert_eq!(call_function("repeat", &s(&["ab", "0"])), Some("".into()));
751    }
752
753    #[test]
754    fn stripprefix_the() {
755        assert_eq!(
756            call_function("stripprefix", &s(&["The Beatles"])),
757            Some("Beatles".into())
758        );
759    }
760
761    #[test]
762    fn stripprefix_a() {
763        assert_eq!(
764            call_function("stripprefix", &s(&["A Perfect Circle"])),
765            Some("Perfect Circle".into())
766        );
767    }
768
769    #[test]
770    fn stripprefix_no_match() {
771        assert_eq!(
772            call_function("stripprefix", &s(&["Radiohead"])),
773            Some("Radiohead".into())
774        );
775    }
776
777    #[test]
778    fn swapprefix() {
779        assert_eq!(
780            call_function("swapprefix", &s(&["The Beatles"])),
781            Some("Beatles, The".into())
782        );
783    }
784
785    #[test]
786    fn swapprefix_no_match() {
787        assert_eq!(
788            call_function("swapprefix", &s(&["Radiohead"])),
789            Some("Radiohead".into())
790        );
791    }
792
793    #[test]
794    fn rot13() {
795        assert_eq!(call_function("rot13", &s(&["Hello"])), Some("Uryyb".into()));
796        assert_eq!(call_function("rot13", &s(&["Uryyb"])), Some("Hello".into()));
797    }
798
799    #[test]
800    fn fix_eol() {
801        assert_eq!(
802            call_function("fix_eol", &s(&["line1\nline2\rline3"])),
803            Some("line1 line2 line3".into())
804        );
805    }
806
807    #[test]
808    fn fix_eol_custom() {
809        assert_eq!(
810            call_function("fix_eol", &s(&["a\nb", " | "])),
811            Some("a | b".into())
812        );
813    }
814
815    // ---- String search ----
816
817    #[test]
818    fn strchr_found() {
819        assert_eq!(
820            call_function("strchr", &s(&["hello", "l"])),
821            Some("3".into())
822        );
823    }
824
825    #[test]
826    fn strchr_not_found() {
827        assert_eq!(
828            call_function("strchr", &s(&["hello", "z"])),
829            Some("".into())
830        );
831    }
832
833    #[test]
834    fn strrchr_found() {
835        assert_eq!(
836            call_function("strrchr", &s(&["hello", "l"])),
837            Some("4".into())
838        );
839    }
840
841    #[test]
842    fn strstr_found() {
843        assert_eq!(
844            call_function("strstr", &s(&["hello world", "world"])),
845            Some("7".into())
846        );
847    }
848
849    #[test]
850    fn strstr_not_found() {
851        assert_eq!(
852            call_function("strstr", &s(&["hello", "xyz"])),
853            Some("".into())
854        );
855    }
856
857    // ---- String comparison (boolean) ----
858
859    #[test]
860    fn strcmp_equal() {
861        assert_eq!(
862            call_function("strcmp", &s(&["hello", "hello"])),
863            Some("1".into())
864        );
865    }
866
867    #[test]
868    fn strcmp_not_equal() {
869        assert_eq!(
870            call_function("strcmp", &s(&["hello", "HELLO"])),
871            Some("".into())
872        );
873    }
874
875    #[test]
876    fn stricmp_equal() {
877        assert_eq!(
878            call_function("stricmp", &s(&["hello", "HELLO"])),
879            Some("1".into())
880        );
881    }
882
883    #[test]
884    fn stricmp_not_equal() {
885        assert_eq!(
886            call_function("stricmp", &s(&["hello", "world"])),
887            Some("".into())
888        );
889    }
890
891    #[test]
892    fn stricmp_various_artists() {
893        assert_eq!(
894            call_function("stricmp", &s(&["Various Artists", "Various Artists"])),
895            Some("1".into())
896        );
897        assert_eq!(
898            call_function("stricmp", &s(&["various artists", "Various Artists"])),
899            Some("1".into())
900        );
901        assert_eq!(
902            call_function("stricmp", &s(&["Aphex Twin", "Various Artists"])),
903            Some("".into())
904        );
905    }
906
907    #[test]
908    fn longer() {
909        assert_eq!(
910            call_function("longer", &s(&["hello", "hi"])),
911            Some("1".into())
912        );
913        assert_eq!(
914            call_function("longer", &s(&["hi", "hello"])),
915            Some("".into())
916        );
917        assert_eq!(call_function("longer", &s(&["hi", "hi"])), Some("".into()));
918    }
919
920    #[test]
921    fn longest() {
922        assert_eq!(
923            call_function("longest", &s(&["a", "hello", "hi"])),
924            Some("hello".into())
925        );
926    }
927
928    #[test]
929    fn shortest() {
930        assert_eq!(
931            call_function("shortest", &s(&["hello", "a", "hi"])),
932            Some("a".into())
933        );
934    }
935
936    // ---- Logic functions ----
937
938    #[test]
939    fn if_nonempty() {
940        assert_eq!(
941            call_function("if", &s(&["yes", "true", "false"])),
942            Some("true".into())
943        );
944    }
945
946    #[test]
947    fn if_empty() {
948        assert_eq!(
949            call_function("if", &s(&["", "true", "false"])),
950            Some("false".into())
951        );
952    }
953
954    #[test]
955    fn if_empty_then_branch() {
956        // $if(cond,,else) — empty then branch is valid
957        assert_eq!(
958            call_function("if", &s(&["yes", "", "fallback"])),
959            Some("".into())
960        );
961    }
962
963    #[test]
964    fn if_no_else() {
965        // $if(cond,then) — missing else defaults to ""
966        assert_eq!(call_function("if", &s(&["", "true"])), Some("".into()));
967    }
968
969    #[test]
970    fn if2_first() {
971        assert_eq!(
972            call_function("if2", &s(&["first", "second"])),
973            Some("first".into())
974        );
975    }
976
977    #[test]
978    fn if2_fallback() {
979        assert_eq!(
980            call_function("if2", &s(&["", "second"])),
981            Some("second".into())
982        );
983    }
984
985    #[test]
986    fn if3_third() {
987        assert_eq!(
988            call_function("if3", &s(&["", "", "third"])),
989            Some("third".into())
990        );
991    }
992
993    #[test]
994    fn if3_all_empty() {
995        assert_eq!(call_function("if3", &s(&["", "", ""])), Some("".into()));
996    }
997
998    #[test]
999    fn ifequal_match() {
1000        assert_eq!(
1001            call_function("ifequal", &s(&["5", "5", "yes", "no"])),
1002            Some("yes".into())
1003        );
1004    }
1005
1006    #[test]
1007    fn ifequal_no_match() {
1008        assert_eq!(
1009            call_function("ifequal", &s(&["5", "3", "yes", "no"])),
1010            Some("no".into())
1011        );
1012    }
1013
1014    #[test]
1015    fn ifgreater_true() {
1016        assert_eq!(
1017            call_function("ifgreater", &s(&["10", "5", "yes", "no"])),
1018            Some("yes".into())
1019        );
1020    }
1021
1022    #[test]
1023    fn ifgreater_false() {
1024        assert_eq!(
1025            call_function("ifgreater", &s(&["3", "5", "yes", "no"])),
1026            Some("no".into())
1027        );
1028    }
1029
1030    #[test]
1031    fn iflonger_true() {
1032        assert_eq!(
1033            call_function("iflonger", &s(&["hello", "3", "yes", "no"])),
1034            Some("yes".into())
1035        );
1036    }
1037
1038    #[test]
1039    fn iflonger_false() {
1040        assert_eq!(
1041            call_function("iflonger", &s(&["hi", "5", "yes", "no"])),
1042            Some("no".into())
1043        );
1044    }
1045
1046    #[test]
1047    fn select_valid() {
1048        assert_eq!(
1049            call_function("select", &s(&["2", "a", "b", "c"])),
1050            Some("b".into())
1051        );
1052    }
1053
1054    #[test]
1055    fn select_out_of_range() {
1056        assert_eq!(
1057            call_function("select", &s(&["0", "a", "b"])),
1058            Some("".into())
1059        );
1060        assert_eq!(
1061            call_function("select", &s(&["99", "a", "b"])),
1062            Some("".into())
1063        );
1064    }
1065
1066    #[test]
1067    fn not_truthy() {
1068        assert_eq!(call_function("not", &s(&["hello"])), Some("".into()));
1069    }
1070
1071    #[test]
1072    fn not_falsy() {
1073        assert_eq!(call_function("not", &s(&[""])), Some("1".into()));
1074    }
1075
1076    #[test]
1077    fn and_both_truthy() {
1078        assert_eq!(call_function("and", &s(&["a", "b"])), Some("1".into()));
1079    }
1080
1081    #[test]
1082    fn and_one_empty() {
1083        assert_eq!(call_function("and", &s(&["a", ""])), Some("".into()));
1084    }
1085
1086    #[test]
1087    fn or_one_truthy() {
1088        assert_eq!(call_function("or", &s(&["", "b"])), Some("1".into()));
1089    }
1090
1091    #[test]
1092    fn or_both_empty() {
1093        assert_eq!(call_function("or", &s(&["", ""])), Some("".into()));
1094    }
1095
1096    #[test]
1097    fn xor_different() {
1098        assert_eq!(call_function("xor", &s(&["a", ""])), Some("1".into()));
1099    }
1100
1101    #[test]
1102    fn xor_same() {
1103        assert_eq!(call_function("xor", &s(&["a", "b"])), Some("".into()));
1104        assert_eq!(call_function("xor", &s(&["", ""])), Some("".into()));
1105    }
1106
1107    #[test]
1108    fn greater_true() {
1109        assert_eq!(call_function("greater", &s(&["10", "5"])), Some("1".into()));
1110    }
1111
1112    #[test]
1113    fn greater_false() {
1114        assert_eq!(call_function("greater", &s(&["3", "5"])), Some("".into()));
1115    }
1116
1117    #[test]
1118    fn greater_equal() {
1119        assert_eq!(call_function("greater", &s(&["5", "5"])), Some("".into()));
1120    }
1121
1122    // ---- Numeric functions ----
1123
1124    #[test]
1125    fn num_zero_pad() {
1126        assert_eq!(call_function("num", &s(&["5", "3"])), Some("005".into()));
1127    }
1128
1129    #[test]
1130    fn num_already_wide() {
1131        assert_eq!(
1132            call_function("num", &s(&["12345", "3"])),
1133            Some("12345".into())
1134        );
1135    }
1136
1137    #[test]
1138    fn add() {
1139        assert_eq!(call_function("add", &s(&["3", "4"])), Some("7".into()));
1140    }
1141
1142    #[test]
1143    fn add_negative() {
1144        assert_eq!(call_function("add", &s(&["10", "-3"])), Some("7".into()));
1145    }
1146
1147    #[test]
1148    fn sub() {
1149        assert_eq!(call_function("sub", &s(&["10", "3"])), Some("7".into()));
1150    }
1151
1152    #[test]
1153    fn mul() {
1154        assert_eq!(call_function("mul", &s(&["3", "4"])), Some("12".into()));
1155    }
1156
1157    #[test]
1158    fn muldiv() {
1159        assert_eq!(
1160            call_function("muldiv", &s(&["10", "3", "2"])),
1161            Some("15".into())
1162        );
1163    }
1164
1165    #[test]
1166    fn muldiv_by_zero() {
1167        assert_eq!(
1168            call_function("muldiv", &s(&["10", "3", "0"])),
1169            Some("".into())
1170        );
1171    }
1172
1173    #[test]
1174    fn div() {
1175        assert_eq!(call_function("div", &s(&["10", "3"])), Some("3".into()));
1176    }
1177
1178    #[test]
1179    fn div_by_zero() {
1180        assert_eq!(call_function("div", &s(&["10", "0"])), Some("".into()));
1181    }
1182
1183    #[test]
1184    fn modulo() {
1185        assert_eq!(call_function("mod", &s(&["10", "3"])), Some("1".into()));
1186    }
1187
1188    #[test]
1189    fn mod_by_zero() {
1190        assert_eq!(call_function("mod", &s(&["10", "0"])), Some("".into()));
1191    }
1192
1193    #[test]
1194    fn max() {
1195        assert_eq!(call_function("max", &s(&["3", "7"])), Some("7".into()));
1196    }
1197
1198    #[test]
1199    fn min() {
1200        assert_eq!(call_function("min", &s(&["3", "7"])), Some("3".into()));
1201    }
1202
1203    #[test]
1204    fn hex_basic() {
1205        assert_eq!(call_function("hex", &s(&["255"])), Some("FF".into()));
1206    }
1207
1208    #[test]
1209    fn hex_padded() {
1210        assert_eq!(call_function("hex", &s(&["255", "4"])), Some("00FF".into()));
1211    }
1212
1213    // ---- Path functions ----
1214
1215    #[test]
1216    fn directory() {
1217        assert_eq!(
1218            call_function("directory", &s(&["/music/artist/album/track.flac"])),
1219            Some("album".into())
1220        );
1221    }
1222
1223    #[test]
1224    fn directory_path() {
1225        assert_eq!(
1226            call_function("directory_path", &s(&["/music/artist/album/track.flac"])),
1227            Some("/music/artist/album".into())
1228        );
1229    }
1230
1231    #[test]
1232    fn ext() {
1233        assert_eq!(
1234            call_function("ext", &s(&["track.flac"])),
1235            Some("flac".into())
1236        );
1237    }
1238
1239    #[test]
1240    fn ext_no_extension() {
1241        assert_eq!(call_function("ext", &s(&["track"])), None);
1242    }
1243
1244    #[test]
1245    fn filename() {
1246        assert_eq!(
1247            call_function("filename", &s(&["track.flac"])),
1248            Some("track".into())
1249        );
1250    }
1251
1252    #[test]
1253    fn filename_with_path() {
1254        assert_eq!(
1255            call_function("filename", &s(&["/music/track.flac"])),
1256            Some("track".into())
1257        );
1258    }
1259
1260    // ---- Special characters ----
1261
1262    #[test]
1263    fn tab() {
1264        assert_eq!(call_function("tab", &s(&[])), Some("\t".into()));
1265        assert_eq!(call_function("tab", &s(&["3"])), Some("\t\t\t".into()));
1266    }
1267
1268    #[test]
1269    fn crlf() {
1270        assert_eq!(call_function("crlf", &s(&[])), Some("\r\n".into()));
1271    }
1272
1273    #[test]
1274    fn char_function() {
1275        assert_eq!(call_function("char", &s(&["65"])), Some("A".into()));
1276        assert_eq!(
1277            call_function("char", &s(&["8226"])),
1278            Some("\u{2022}".into())
1279        );
1280    }
1281
1282    // ---- Meta ----
1283
1284    #[test]
1285    fn info() {
1286        assert_eq!(call_function("info", &s(&["test"])), Some("test".into()));
1287    }
1288
1289    #[test]
1290    fn len() {
1291        assert_eq!(call_function("len", &s(&["hello"])), Some("5".into()));
1292    }
1293
1294    #[test]
1295    fn len_empty() {
1296        assert_eq!(call_function("len", &s(&[""])), Some("0".into()));
1297    }
1298
1299    #[test]
1300    fn unknown_function() {
1301        assert_eq!(call_function("nonexistent", &s(&["arg"])), None);
1302    }
1303
1304    // ---- Missing args return None ----
1305
1306    #[test]
1307    fn left_no_args() {
1308        assert_eq!(call_function("left", &s(&[])), None);
1309    }
1310
1311    #[test]
1312    fn div_non_numeric() {
1313        assert_eq!(call_function("div", &s(&["abc", "3"])), None);
1314    }
1315
1316    // Helper to make arg arrays less noisy
1317    fn s(args: &[&str]) -> Vec<String> {
1318        args.iter().map(|s| (*s).to_string()).collect()
1319    }
1320}