Skip to main content

koan_core/format/
functions.rs

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