sherpack_engine/
filters.rs

1//! Kubernetes-specific template filters
2//!
3//! These filters extend MiniJinja with Helm-compatible functionality.
4
5use base64::Engine as _;
6use minijinja::{Error, ErrorKind, Value, value::ValueKind};
7use semver::{Version, VersionReq};
8
9/// Convert a value to YAML format
10///
11/// Usage: {{ values.config | toyaml }}
12pub fn toyaml(value: Value) -> Result<String, Error> {
13    // Convert minijinja Value to serde_json::Value
14    let json_value: serde_json::Value = serde_json::to_value(&value)
15        .map_err(|e| Error::new(ErrorKind::InvalidOperation, e.to_string()))?;
16
17    // Convert to YAML
18    let yaml = serde_yaml::to_string(&json_value)
19        .map_err(|e| Error::new(ErrorKind::InvalidOperation, e.to_string()))?;
20
21    // Remove trailing newline and leading "---\n" if present
22    let yaml = yaml.trim_start_matches("---\n").trim_end();
23
24    Ok(yaml.to_string())
25}
26
27/// Convert a value to JSON format
28///
29/// Usage: {{ values.config | tojson }}
30pub fn tojson(value: Value) -> Result<String, Error> {
31    let json_value: serde_json::Value = serde_json::to_value(&value)
32        .map_err(|e| Error::new(ErrorKind::InvalidOperation, e.to_string()))?;
33
34    serde_json::to_string(&json_value)
35        .map_err(|e| Error::new(ErrorKind::InvalidOperation, e.to_string()))
36}
37
38/// Convert a value to pretty-printed JSON
39///
40/// Usage: {{ values.config | tojson_pretty }}
41pub fn tojson_pretty(value: Value) -> Result<String, Error> {
42    let json_value: serde_json::Value = serde_json::to_value(&value)
43        .map_err(|e| Error::new(ErrorKind::InvalidOperation, e.to_string()))?;
44
45    serde_json::to_string_pretty(&json_value)
46        .map_err(|e| Error::new(ErrorKind::InvalidOperation, e.to_string()))
47}
48
49/// Base64 encode a string
50///
51/// Usage: {{ secret | b64encode }}
52#[must_use]
53pub fn b64encode(value: String) -> String {
54    base64::engine::general_purpose::STANDARD.encode(value.as_bytes())
55}
56
57/// Base64 decode a string
58///
59/// Usage: {{ encoded | b64decode }}
60pub fn b64decode(value: String) -> Result<String, Error> {
61    let decoded = base64::engine::general_purpose::STANDARD
62        .decode(value.as_bytes())
63        .map_err(|e| {
64            Error::new(
65                ErrorKind::InvalidOperation,
66                format!("base64 decode error: {}", e),
67            )
68        })?;
69
70    String::from_utf8(decoded).map_err(|e| {
71        Error::new(
72            ErrorKind::InvalidOperation,
73            format!("UTF-8 decode error: {}", e),
74        )
75    })
76}
77
78/// Quote a string with double quotes
79///
80/// Usage: {{ name | quote }}
81#[must_use]
82pub fn quote(value: Value) -> String {
83    let s = if let Some(str_val) = value.as_str() {
84        str_val.to_string()
85    } else {
86        value.to_string()
87    };
88    format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
89}
90
91/// Quote a string with single quotes
92///
93/// Usage: {{ name | squote }}
94#[must_use]
95pub fn squote(value: Value) -> String {
96    let s = if let Some(str_val) = value.as_str() {
97        str_val.to_string()
98    } else {
99        value.to_string()
100    };
101    format!("'{}'", s.replace('\'', "''"))
102}
103
104/// Indent text with a newline prefix (like Helm's nindent)
105///
106/// Usage: {{ content | nindent(4) }}
107#[must_use]
108pub fn nindent(value: String, spaces: usize) -> String {
109    let line_count = value.lines().count();
110    // Pre-allocate: newline + (spaces + avg_line_length) * lines
111    let mut result = String::with_capacity(1 + value.len() + spaces * line_count + line_count);
112    result.push('\n');
113
114    let indent = " ".repeat(spaces);
115    let mut first = true;
116
117    for line in value.lines() {
118        if !first {
119            result.push('\n');
120        }
121        first = false;
122
123        if !line.is_empty() {
124            result.push_str(&indent);
125            result.push_str(line);
126        }
127    }
128
129    result
130}
131
132/// Indent text without newline prefix
133///
134/// Usage: {{ content | indent(4) }}
135pub fn indent(value: String, spaces: usize) -> String {
136    let line_count = value.lines().count();
137    // Pre-allocate: (spaces + avg_line_length) * lines
138    let mut result = String::with_capacity(value.len() + spaces * line_count + line_count);
139
140    let indent_str = " ".repeat(spaces);
141    let mut first = true;
142
143    for line in value.lines() {
144        if !first {
145            result.push('\n');
146        }
147        first = false;
148
149        if !line.is_empty() {
150            result.push_str(&indent_str);
151        }
152        result.push_str(line);
153    }
154
155    result
156}
157
158/// Require a value, fail if undefined or empty
159///
160/// Usage: {{ values.required_field | required("field is required") }}
161pub fn required(value: Value, message: Option<String>) -> Result<Value, Error> {
162    if value.is_undefined() || value.is_none() {
163        let msg = message.unwrap_or_else(|| "required value is missing".to_string());
164        Err(Error::new(ErrorKind::InvalidOperation, msg))
165    } else if let Some(s) = value.as_str() {
166        if s.is_empty() {
167            let msg = message.unwrap_or_else(|| "required value is empty".to_string());
168            Err(Error::new(ErrorKind::InvalidOperation, msg))
169        } else {
170            Ok(value)
171        }
172    } else {
173        Ok(value)
174    }
175}
176
177/// Check if a value is empty
178///
179/// Usage: {% if values.list | empty %}
180pub fn empty(value: Value) -> bool {
181    if value.is_undefined() || value.is_none() {
182        return true;
183    }
184
185    match value.len() {
186        Some(len) => len == 0,
187        None => {
188            if let Some(s) = value.as_str() {
189                s.is_empty()
190            } else {
191                false
192            }
193        }
194    }
195}
196
197/// Return the first non-empty value
198///
199/// Usage: {{ coalesce(values.a, values.b, "default") }}
200pub fn coalesce(args: Vec<Value>) -> Value {
201    for arg in args {
202        if !arg.is_undefined() && !arg.is_none() {
203            if let Some(s) = arg.as_str() {
204                if !s.is_empty() {
205                    return arg;
206                }
207            } else {
208                return arg;
209            }
210        }
211    }
212    Value::UNDEFINED
213}
214
215/// Check if a dict has a key
216///
217/// Usage: {% if values | haskey("foo") %}
218pub fn haskey(value: Value, key: String) -> bool {
219    value
220        .get_attr(&key)
221        .map(|v| !v.is_undefined())
222        .unwrap_or(false)
223}
224
225/// Get all keys from a dict
226///
227/// Usage: {{ values | keys }}
228pub fn keys(value: Value) -> Result<Vec<String>, Error> {
229    match value.try_iter() {
230        Ok(iter) => {
231            let keys: Vec<String> = iter
232                .filter_map(|v| v.as_str().map(|s| s.to_string()))
233                .collect();
234            Ok(keys)
235        }
236        Err(_) => Err(Error::new(
237            ErrorKind::InvalidOperation,
238            "cannot get keys from non-mapping value",
239        )),
240    }
241}
242
243/// Deep merge two dicts
244///
245/// Usage: {{ dict1 | merge(dict2) }}
246pub fn merge(base: Value, overlay: Value) -> Result<Value, Error> {
247    let mut base_json: serde_json::Value = serde_json::to_value(&base)
248        .map_err(|e| Error::new(ErrorKind::InvalidOperation, e.to_string()))?;
249    let overlay_json: serde_json::Value = serde_json::to_value(&overlay)
250        .map_err(|e| Error::new(ErrorKind::InvalidOperation, e.to_string()))?;
251
252    deep_merge_json(&mut base_json, &overlay_json);
253
254    Ok(Value::from_serialize(&base_json))
255}
256
257fn deep_merge_json(base: &mut serde_json::Value, overlay: &serde_json::Value) {
258    match (base, overlay) {
259        (serde_json::Value::Object(base_map), serde_json::Value::Object(overlay_map)) => {
260            for (key, overlay_value) in overlay_map {
261                match base_map.get_mut(key) {
262                    Some(base_value) => deep_merge_json(base_value, overlay_value),
263                    None => {
264                        base_map.insert(key.clone(), overlay_value.clone());
265                    }
266                }
267            }
268        }
269        (base, overlay) => {
270            *base = overlay.clone();
271        }
272    }
273}
274
275/// SHA256 hash of a string
276///
277/// Usage: {{ value | sha256 }}
278pub fn sha256sum(value: String) -> String {
279    use sha2::{Digest, Sha256};
280    let mut hasher = Sha256::new();
281    hasher.update(value.as_bytes());
282    format!("{:x}", hasher.finalize())
283}
284
285/// Truncate a string to a maximum length
286///
287/// Usage: {{ name | trunc(63) }}
288pub fn trunc(value: String, length: usize) -> String {
289    if value.len() <= length {
290        value
291    } else {
292        value.chars().take(length).collect()
293    }
294}
295
296/// Trim prefix from a string
297///
298/// Usage: {{ name | trimprefix("v") }}
299pub fn trimprefix(value: String, prefix: String) -> String {
300    value.strip_prefix(&prefix).unwrap_or(&value).to_string()
301}
302
303/// Trim suffix from a string
304///
305/// Usage: {{ name | trimsuffix(".yaml") }}
306pub fn trimsuffix(value: String, suffix: String) -> String {
307    value.strip_suffix(&suffix).unwrap_or(&value).to_string()
308}
309
310/// Convert to snake_case
311///
312/// Usage: {{ name | snakecase }}
313pub fn snakecase(value: String) -> String {
314    // Pre-allocate with extra space for potential underscores
315    let mut result = String::with_capacity(value.len() + value.len() / 4);
316    let mut prev_upper = false;
317
318    for (i, c) in value.chars().enumerate() {
319        if c.is_uppercase() {
320            if i > 0 && !prev_upper {
321                result.push('_');
322            }
323            result.push(c.to_lowercase().next().unwrap_or(c));
324            prev_upper = true;
325        } else if c == '-' || c == ' ' {
326            result.push('_');
327            prev_upper = false;
328        } else {
329            result.push(c);
330            prev_upper = false;
331        }
332    }
333
334    result
335}
336
337/// Convert to kebab-case
338///
339/// Usage: {{ name | kebabcase }}
340pub fn kebabcase(value: String) -> String {
341    snakecase(value).replace('_', "-")
342}
343
344/// Convert a list of values to a list of strings
345///
346/// Usage: {{ list(1, 2, 3) | tostrings }}
347/// Result: ["1", "2", "3"]
348///
349/// This is the Helm-compatible toStrings function.
350/// Each element is converted using its string representation.
351///
352/// ## Sherpack Extensions
353///
354/// Optional parameters (passed as kwargs):
355/// - `prefix`: String to prepend to each element
356/// - `suffix`: String to append to each element
357/// - `skip_empty`: If true, skip empty/null values (default: false)
358///
359/// Examples:
360/// ```jinja
361/// {{ list(80, 443) | tostrings(prefix="port-") }}
362/// → ["port-80", "port-443"]
363///
364/// {{ list(1, 2) | tostrings(suffix="/TCP") }}
365/// → ["1/TCP", "2/TCP"]
366///
367/// {{ list("a", "", "c") | tostrings(skip_empty=true) }}
368/// → ["a", "c"]
369/// ```
370pub fn tostrings(value: Value, kwargs: minijinja::value::Kwargs) -> Result<Vec<String>, Error> {
371    // Extract optional parameters using simpler approach
372    let prefix: String = kwargs.get("prefix").ok().flatten().unwrap_or_default();
373    let suffix: String = kwargs.get("suffix").ok().flatten().unwrap_or_default();
374    let skip_empty: bool = kwargs.get("skip_empty").ok().flatten().unwrap_or(false);
375
376    // Ensure no unknown kwargs
377    kwargs.assert_all_used()?;
378
379    let has_prefix = !prefix.is_empty();
380    let has_suffix = !suffix.is_empty();
381
382    let convert_value = |v: Value| -> Option<String> {
383        // Handle null/undefined
384        if v.is_undefined() || v.is_none() {
385            if skip_empty {
386                return None;
387            }
388            return Some(String::new());
389        }
390
391        let s = if let Some(str_val) = v.as_str() {
392            str_val.to_string()
393        } else {
394            v.to_string()
395        };
396
397        // Skip empty strings if requested
398        if skip_empty && s.is_empty() {
399            return None;
400        }
401
402        // Apply prefix and suffix if set
403        if has_prefix || has_suffix {
404            let mut result = String::with_capacity(s.len() + prefix.len() + suffix.len());
405            result.push_str(&prefix);
406            result.push_str(&s);
407            result.push_str(&suffix);
408            Some(result)
409        } else {
410            Some(s)
411        }
412    };
413
414    match value.try_iter() {
415        Ok(iter) => {
416            let strings: Vec<String> = iter.filter_map(convert_value).collect();
417            Ok(strings)
418        }
419        Err(_) => {
420            // Single value - convert to single-element list
421            match convert_value(value) {
422                Some(s) => Ok(vec![s]),
423                None => Ok(vec![]),
424            }
425        }
426    }
427}
428
429/// Compare a version against a semver constraint
430///
431/// This filter implements Helm's semverCompare function.
432/// The constraint can use standard semver operators:
433/// - `>=1.0.0` - greater than or equal
434/// - `<2.0.0` - less than
435/// - `^1.0.0` - compatible with
436/// - `~1.0.0` - approximately equivalent
437///
438/// Usage: {{ version | semver_match(">=1.21.0") }}
439pub fn semver_match(version: Value, constraint: String) -> Result<bool, Error> {
440    let version_str = version
441        .as_str()
442        .ok_or_else(|| Error::new(ErrorKind::InvalidOperation, "version must be a string"))?;
443
444    // Clean up the version string (remove 'v' prefix if present)
445    let version_clean = version_str.trim_start_matches('v');
446
447    // Parse the version (handle Kubernetes-style versions like "1.31.0-0")
448    let parsed_version = match Version::parse(version_clean) {
449        Ok(v) => v,
450        Err(_) => {
451            // Try to parse as major.minor.patch only
452            let parts: Vec<&str> = version_clean
453                .split('-')
454                .next()
455                .unwrap_or(version_clean)
456                .split('.')
457                .collect();
458
459            if parts.len() >= 3 {
460                let major: u64 = parts[0].parse().unwrap_or(0);
461                let minor: u64 = parts[1].parse().unwrap_or(0);
462                let patch: u64 = parts[2].parse().unwrap_or(0);
463                Version::new(major, minor, patch)
464            } else if parts.len() == 2 {
465                let major: u64 = parts[0].parse().unwrap_or(0);
466                let minor: u64 = parts[1].parse().unwrap_or(0);
467                Version::new(major, minor, 0)
468            } else {
469                return Err(Error::new(
470                    ErrorKind::InvalidOperation,
471                    format!("Invalid version format: {}", version_str),
472                ));
473            }
474        }
475    };
476
477    // Clean up the constraint string (handle Kubernetes-style constraints)
478    let constraint_clean = constraint.trim_start_matches(|c: char| c.is_whitespace());
479
480    // Parse the constraint
481    let req = VersionReq::parse(constraint_clean)
482        .or_else(|_| {
483            // Try to handle Kubernetes-style constraints like ">=1.31.0-0"
484            let constraint_base = constraint_clean
485                .split('-')
486                .next()
487                .unwrap_or(constraint_clean);
488            VersionReq::parse(constraint_base)
489        })
490        .map_err(|e| {
491            Error::new(
492                ErrorKind::InvalidOperation,
493                format!("Invalid constraint '{}': {}", constraint, e),
494            )
495        })?;
496
497    Ok(req.matches(&parsed_version))
498}
499
500/// Convert value to integer (truncates floats)
501pub fn int(value: Value) -> Result<i64, Error> {
502    match value.kind() {
503        ValueKind::Number => {
504            // Try i64 first, then f64 (truncated)
505            if let Some(i) = value.as_i64() {
506                Ok(i)
507            } else if let Ok(f) = f64::try_from(value.clone()) {
508                Ok(f as i64)
509            } else {
510                Err(Error::new(
511                    ErrorKind::InvalidOperation,
512                    "Cannot convert to int",
513                ))
514            }
515        }
516        ValueKind::String => {
517            let s = value.as_str().unwrap_or("");
518            s.parse::<i64>()
519                .or_else(|_| s.parse::<f64>().map(|f| f as i64))
520                .map_err(|_| {
521                    Error::new(
522                        ErrorKind::InvalidOperation,
523                        format!("Cannot parse '{}' as int", s),
524                    )
525                })
526        }
527        ValueKind::Bool => Ok(if value.is_true() { 1 } else { 0 }),
528        _ => Err(Error::new(
529            ErrorKind::InvalidOperation,
530            format!("Cannot convert {:?} to int", value.kind()),
531        )),
532    }
533}
534
535/// Convert value to float
536pub fn float(value: Value) -> Result<f64, Error> {
537    match value.kind() {
538        ValueKind::Number => {
539            // Try f64 conversion (handles both int and float)
540            f64::try_from(value)
541                .map_err(|_| Error::new(ErrorKind::InvalidOperation, "Cannot convert to float"))
542        }
543        ValueKind::String => {
544            let s = value.as_str().unwrap_or("");
545            s.parse::<f64>().map_err(|_| {
546                Error::new(
547                    ErrorKind::InvalidOperation,
548                    format!("Cannot parse '{}' as float", s),
549                )
550            })
551        }
552        ValueKind::Bool => Ok(if value.is_true() { 1.0 } else { 0.0 }),
553        _ => Err(Error::new(
554            ErrorKind::InvalidOperation,
555            format!("Cannot convert {:?} to float", value.kind()),
556        )),
557    }
558}
559
560/// Absolute value
561pub fn abs(value: Value) -> Result<Value, Error> {
562    match value.kind() {
563        ValueKind::Number => {
564            if let Some(i) = value.as_i64() {
565                Ok(Value::from(i.abs()))
566            } else if let Ok(f) = f64::try_from(value) {
567                Ok(Value::from(f.abs()))
568            } else {
569                Err(Error::new(
570                    ErrorKind::InvalidOperation,
571                    "Cannot get absolute value",
572                ))
573            }
574        }
575        _ => Err(Error::new(
576            ErrorKind::InvalidOperation,
577            format!("abs requires a number, got {:?}", value.kind()),
578        )),
579    }
580}
581
582// =============================================================================
583// Path Functions
584// =============================================================================
585
586/// Extract filename from path
587/// {{ "/etc/nginx/nginx.conf" | basename }}  →  "nginx.conf"
588pub fn basename(path: String) -> String {
589    std::path::Path::new(&path)
590        .file_name()
591        .map(|s| s.to_string_lossy().to_string())
592        .unwrap_or_default()
593}
594
595/// Extract directory from path
596/// {{ "/etc/nginx/nginx.conf" | dirname }}  →  "/etc/nginx"
597pub fn dirname(path: String) -> String {
598    std::path::Path::new(&path)
599        .parent()
600        .map(|p| p.to_string_lossy().to_string())
601        .unwrap_or_default()
602}
603
604/// Extract file extension (without the dot)
605/// {{ "file.tar.gz" | extname }}  →  "gz"
606pub fn extname(path: String) -> String {
607    std::path::Path::new(&path)
608        .extension()
609        .map(|s| s.to_string_lossy().to_string())
610        .unwrap_or_default()
611}
612
613/// Clean/normalize path (no filesystem access)
614/// {{ "a/b/../c/./d" | cleanpath }}  →  "a/c/d"
615pub fn cleanpath(path: String) -> String {
616    let is_absolute = path.starts_with('/');
617    let mut parts: Vec<&str> = vec![];
618
619    for part in path.split('/') {
620        match part {
621            "" | "." => continue,
622            ".." => {
623                parts.pop();
624            }
625            _ => parts.push(part),
626        }
627    }
628
629    let result = parts.join("/");
630    if is_absolute {
631        format!("/{}", result)
632    } else if result.is_empty() {
633        ".".to_string()
634    } else {
635        result
636    }
637}
638
639// =============================================================================
640// Regex Functions
641// =============================================================================
642
643/// Check if string matches regex pattern
644/// {% if name | regex_match("^v[0-9]+") %}matched{% endif %}
645pub fn regex_match(value: String, pattern: String) -> Result<bool, Error> {
646    regex::Regex::new(&pattern)
647        .map(|re| re.is_match(&value))
648        .map_err(|e| {
649            Error::new(
650                ErrorKind::InvalidOperation,
651                format!("invalid regex '{}': {}", pattern, e),
652            )
653        })
654}
655
656/// Replace all matches with replacement (supports capture groups: $1, $2, etc.)
657/// {{ "v1.2.3" | regex_replace("v([0-9]+)", "version-$1") }}  →  "version-1.2.3"
658pub fn regex_replace(value: String, pattern: String, replacement: String) -> Result<String, Error> {
659    regex::Regex::new(&pattern)
660        .map(|re| re.replace_all(&value, replacement.as_str()).to_string())
661        .map_err(|e| {
662            Error::new(
663                ErrorKind::InvalidOperation,
664                format!("invalid regex '{}': {}", pattern, e),
665            )
666        })
667}
668
669/// Find first match, returns empty string if no match
670/// {{ "port: 8080" | regex_find("[0-9]+") }}  →  "8080"
671pub fn regex_find(value: String, pattern: String) -> Result<String, Error> {
672    regex::Regex::new(&pattern)
673        .map(|re| {
674            re.find(&value)
675                .map(|m| m.as_str().to_string())
676                .unwrap_or_default()
677        })
678        .map_err(|e| {
679            Error::new(
680                ErrorKind::InvalidOperation,
681                format!("invalid regex '{}': {}", pattern, e),
682            )
683        })
684}
685
686/// Find all matches
687/// {{ "a1b2c3" | regex_find_all("[0-9]+") }}  →  ["1", "2", "3"]
688pub fn regex_find_all(value: String, pattern: String) -> Result<Vec<String>, Error> {
689    regex::Regex::new(&pattern)
690        .map(|re| {
691            re.find_iter(&value)
692                .map(|m| m.as_str().to_string())
693                .collect()
694        })
695        .map_err(|e| {
696            Error::new(
697                ErrorKind::InvalidOperation,
698                format!("invalid regex '{}': {}", pattern, e),
699            )
700        })
701}
702
703// =============================================================================
704// Dict Functions
705// =============================================================================
706
707/// Get all values from a dict as a list
708/// {{ mydict | values }}  →  [value1, value2, ...]
709pub fn values(dict: Value) -> Result<Value, Error> {
710    match dict.kind() {
711        ValueKind::Map => {
712            let items: Vec<Value> = dict
713                .try_iter()
714                .map_err(|_| Error::new(ErrorKind::InvalidOperation, "cannot iterate dict"))?
715                .filter_map(|k| dict.get_item(&k).ok())
716                .collect();
717            Ok(Value::from(items))
718        }
719        _ => Err(Error::new(
720            ErrorKind::InvalidOperation,
721            format!("values requires a dict, got {:?}", dict.kind()),
722        )),
723    }
724}
725
726/// Select only specified keys from dict
727/// {{ mydict | pick("name", "version") }}
728pub fn pick(dict: Value, keys: &[Value]) -> Result<Value, Error> {
729    match dict.kind() {
730        ValueKind::Map => {
731            let mut result = indexmap::IndexMap::new();
732            for key in keys {
733                if let Some(key_str) = key.as_str()
734                    && let Ok(val) = dict.get_item(key)
735                {
736                    result.insert(key_str.to_string(), val);
737                }
738            }
739            Ok(Value::from_iter(result))
740        }
741        _ => Err(Error::new(
742            ErrorKind::InvalidOperation,
743            format!("pick requires a dict, got {:?}", dict.kind()),
744        )),
745    }
746}
747
748/// Exclude specified keys from dict
749/// {{ mydict | omit("password", "secret") }}
750pub fn omit(dict: Value, keys: &[Value]) -> Result<Value, Error> {
751    match dict.kind() {
752        ValueKind::Map => {
753            let exclude: std::collections::HashSet<String> = keys
754                .iter()
755                .filter_map(|k| k.as_str().map(|s| s.to_string()))
756                .collect();
757
758            let mut result = indexmap::IndexMap::new();
759            if let Ok(iter) = dict.try_iter() {
760                for key in iter {
761                    if let Some(key_str) = key.as_str()
762                        && !exclude.contains(key_str)
763                        && let Ok(val) = dict.get_item(&key)
764                    {
765                        result.insert(key_str.to_string(), val);
766                    }
767                }
768            }
769            Ok(Value::from_iter(result))
770        }
771        _ => Err(Error::new(
772            ErrorKind::InvalidOperation,
773            format!("omit requires a dict, got {:?}", dict.kind()),
774        )),
775    }
776}
777
778// =============================================================================
779// List Functions
780// =============================================================================
781
782/// Append item to end of list (returns new list)
783/// {{ items | append("new") }}
784pub fn append(list: Value, item: Value) -> Result<Value, Error> {
785    match list.kind() {
786        ValueKind::Seq => {
787            let mut items: Vec<Value> = list
788                .try_iter()
789                .map_err(|_| Error::new(ErrorKind::InvalidOperation, "cannot iterate list"))?
790                .collect();
791            items.push(item);
792            Ok(Value::from(items))
793        }
794        _ => Err(Error::new(
795            ErrorKind::InvalidOperation,
796            format!("append requires a list, got {:?}", list.kind()),
797        )),
798    }
799}
800
801/// Prepend item to start of list (returns new list)
802/// {{ items | prepend("first") }}
803pub fn prepend(list: Value, item: Value) -> Result<Value, Error> {
804    match list.kind() {
805        ValueKind::Seq => {
806            let mut items: Vec<Value> = vec![item];
807            items.extend(
808                list.try_iter()
809                    .map_err(|_| Error::new(ErrorKind::InvalidOperation, "cannot iterate list"))?,
810            );
811            Ok(Value::from(items))
812        }
813        _ => Err(Error::new(
814            ErrorKind::InvalidOperation,
815            format!("prepend requires a list, got {:?}", list.kind()),
816        )),
817    }
818}
819
820/// Concatenate two lists
821/// {{ list1 | concat(list2) }}
822pub fn concat(list1: Value, list2: Value) -> Result<Value, Error> {
823    match (list1.kind(), list2.kind()) {
824        (ValueKind::Seq, ValueKind::Seq) => {
825            let mut items: Vec<Value> = list1
826                .try_iter()
827                .map_err(|_| Error::new(ErrorKind::InvalidOperation, "cannot iterate first list"))?
828                .collect();
829            items.extend(list2.try_iter().map_err(|_| {
830                Error::new(ErrorKind::InvalidOperation, "cannot iterate second list")
831            })?);
832            Ok(Value::from(items))
833        }
834        _ => Err(Error::new(
835            ErrorKind::InvalidOperation,
836            "concat requires two lists",
837        )),
838    }
839}
840
841/// Remove specified values from list
842/// {{ items | without("a", "b") }}
843pub fn without(list: Value, exclude: &[Value]) -> Result<Value, Error> {
844    match list.kind() {
845        ValueKind::Seq => {
846            let items: Vec<Value> = list
847                .try_iter()
848                .map_err(|_| Error::new(ErrorKind::InvalidOperation, "cannot iterate list"))?
849                .filter(|item| !exclude.contains(item))
850                .collect();
851            Ok(Value::from(items))
852        }
853        _ => Err(Error::new(
854            ErrorKind::InvalidOperation,
855            format!("without requires a list, got {:?}", list.kind()),
856        )),
857    }
858}
859
860/// Remove empty/falsy values from list
861/// {{ ["a", "", null, "b"] | compact }}  →  ["a", "b"]
862pub fn compact(list: Value) -> Result<Value, Error> {
863    match list.kind() {
864        ValueKind::Seq => {
865            let items: Vec<Value> = list
866                .try_iter()
867                .map_err(|_| Error::new(ErrorKind::InvalidOperation, "cannot iterate list"))?
868                .filter(|item| match item.kind() {
869                    ValueKind::Undefined | ValueKind::None => false,
870                    ValueKind::String => !item.as_str().unwrap_or("").is_empty(),
871                    ValueKind::Seq => item.len().unwrap_or(0) > 0,
872                    ValueKind::Map => item.len().unwrap_or(0) > 0,
873                    _ => true,
874                })
875                .collect();
876            Ok(Value::from(items))
877        }
878        _ => Err(Error::new(
879            ErrorKind::InvalidOperation,
880            format!("compact requires a list, got {:?}", list.kind()),
881        )),
882    }
883}
884
885// =============================================================================
886// Math Functions
887// =============================================================================
888
889/// Floor: round down to nearest integer
890/// {{ 3.7 | floor }}  →  3
891pub fn floor(value: Value) -> Result<i64, Error> {
892    match value.kind() {
893        ValueKind::Number => {
894            if let Some(i) = value.as_i64() {
895                Ok(i)
896            } else if let Ok(f) = f64::try_from(value) {
897                Ok(f.floor() as i64)
898            } else {
899                Err(Error::new(
900                    ErrorKind::InvalidOperation,
901                    "cannot convert to number",
902                ))
903            }
904        }
905        _ => Err(Error::new(
906            ErrorKind::InvalidOperation,
907            format!("floor requires a number, got {:?}", value.kind()),
908        )),
909    }
910}
911
912/// Ceil: round up to nearest integer
913/// {{ 3.2 | ceil }}  →  4
914pub fn ceil(value: Value) -> Result<i64, Error> {
915    match value.kind() {
916        ValueKind::Number => {
917            if let Some(i) = value.as_i64() {
918                Ok(i)
919            } else if let Ok(f) = f64::try_from(value) {
920                Ok(f.ceil() as i64)
921            } else {
922                Err(Error::new(
923                    ErrorKind::InvalidOperation,
924                    "cannot convert to number",
925                ))
926            }
927        }
928        _ => Err(Error::new(
929            ErrorKind::InvalidOperation,
930            format!("ceil requires a number, got {:?}", value.kind()),
931        )),
932    }
933}
934
935// =============================================================================
936// Crypto Functions
937// =============================================================================
938
939/// SHA-1 hash (hex encoded)
940/// {{ "hello" | sha1 }}
941pub fn sha1sum(value: String) -> String {
942    use sha1::{Digest, Sha1};
943    let mut hasher = Sha1::new();
944    hasher.update(value.as_bytes());
945    format!("{:x}", hasher.finalize())
946}
947
948/// SHA-512 hash (hex encoded)
949/// {{ "hello" | sha512 }}
950pub fn sha512sum(value: String) -> String {
951    use sha2::{Digest, Sha512};
952    let mut hasher = Sha512::new();
953    hasher.update(value.as_bytes());
954    format!("{:x}", hasher.finalize())
955}
956
957/// MD5 hash (hex encoded) - Note: MD5 is cryptographically broken, use only for checksums
958/// {{ "hello" | md5 }}
959pub fn md5sum(value: String) -> String {
960    use md5::{Digest, Md5};
961    let mut hasher = Md5::new();
962    hasher.update(value.as_bytes());
963    format!("{:x}", hasher.finalize())
964}
965
966// =============================================================================
967// String Functions
968// =============================================================================
969
970/// Repeat string N times
971/// {{ "-" | repeat(10) }}  →  "----------"
972pub fn repeat(value: String, count: usize) -> String {
973    value.repeat(count)
974}
975
976/// Convert to camelCase
977/// {{ "foo_bar_baz" | camelcase }}  →  "fooBarBaz"
978/// {{ "foo-bar-baz" | camelcase }}  →  "fooBarBaz"
979pub fn camelcase(value: String) -> String {
980    let mut result = String::with_capacity(value.len());
981    let mut capitalize_next = false;
982    let mut first = true;
983
984    for c in value.chars() {
985        if c == '_' || c == '-' || c == ' ' {
986            capitalize_next = true;
987        } else if capitalize_next {
988            result.extend(c.to_uppercase());
989            capitalize_next = false;
990        } else if first {
991            result.extend(c.to_lowercase());
992            first = false;
993        } else {
994            result.extend(c.to_lowercase());
995        }
996    }
997
998    result
999}
1000
1001/// Convert to PascalCase (UpperCamelCase)
1002/// {{ "foo_bar" | pascalcase }}  →  "FooBar"
1003pub fn pascalcase(value: String) -> String {
1004    let mut result = String::with_capacity(value.len());
1005    let mut capitalize_next = true;
1006
1007    for c in value.chars() {
1008        if c == '_' || c == '-' || c == ' ' {
1009            capitalize_next = true;
1010        } else if capitalize_next {
1011            result.extend(c.to_uppercase());
1012            capitalize_next = false;
1013        } else {
1014            result.push(c);
1015        }
1016    }
1017
1018    result
1019}
1020
1021/// Substring extraction
1022/// {{ "hello world" | substr(0, 5) }}  →  "hello"
1023/// {{ "hello world" | substr(6) }}  →  "world"
1024pub fn substr(value: String, start: usize, length: Option<usize>) -> String {
1025    let chars: Vec<char> = value.chars().collect();
1026    let end = length
1027        .map(|l| (start + l).min(chars.len()))
1028        .unwrap_or(chars.len());
1029    let start = start.min(chars.len());
1030    chars[start..end].iter().collect()
1031}
1032
1033/// Word wrap at specified width
1034/// {{ long_text | wrap(80) }}
1035pub fn wrap(value: String, width: usize) -> String {
1036    let mut result = String::with_capacity(value.len() + value.len() / width);
1037    let mut line_len = 0;
1038
1039    for word in value.split_whitespace() {
1040        let word_len = word.chars().count();
1041        if line_len > 0 && line_len + 1 + word_len > width {
1042            result.push('\n');
1043            line_len = 0;
1044        } else if line_len > 0 {
1045            result.push(' ');
1046            line_len += 1;
1047        }
1048        result.push_str(word);
1049        line_len += word_len;
1050    }
1051
1052    result
1053}
1054
1055/// Check if string starts with prefix (function form for Helm compatibility)
1056/// {{ "hello" | hasprefix("hel") }}  →  true
1057pub fn hasprefix(value: String, prefix: String) -> bool {
1058    value.starts_with(&prefix)
1059}
1060
1061/// Check if string ends with suffix (function form for Helm compatibility)
1062/// {{ "hello.txt" | hassuffix(".txt") }}  →  true
1063pub fn hassuffix(value: String, suffix: String) -> bool {
1064    value.ends_with(&suffix)
1065}
1066
1067#[cfg(test)]
1068mod tests {
1069    use super::*;
1070
1071    #[test]
1072    fn test_toyaml() {
1073        let value = Value::from_serialize(serde_json::json!({
1074            "name": "test",
1075            "port": 8080
1076        }));
1077        let yaml = toyaml(value).unwrap();
1078        assert!(yaml.contains("name: test"));
1079        assert!(yaml.contains("port: 8080"));
1080    }
1081
1082    #[test]
1083    fn test_b64encode_decode() {
1084        let original = "hello world".to_string();
1085        let encoded = b64encode(original.clone());
1086        let decoded = b64decode(encoded).unwrap();
1087        assert_eq!(original, decoded);
1088    }
1089
1090    #[test]
1091    fn test_quote() {
1092        assert_eq!(quote(Value::from("test")), "\"test\"");
1093        assert_eq!(squote(Value::from("test")), "'test'");
1094    }
1095
1096    #[test]
1097    fn test_nindent() {
1098        let input = "line1\nline2".to_string();
1099        let result = nindent(input, 4);
1100        assert_eq!(result, "\n    line1\n    line2");
1101    }
1102
1103    #[test]
1104    fn test_required() {
1105        assert!(required(Value::from("test"), None).is_ok());
1106        assert!(required(Value::UNDEFINED, None).is_err());
1107        assert!(required(Value::from(""), None).is_err());
1108    }
1109
1110    #[test]
1111    fn test_empty() {
1112        assert!(empty(Value::UNDEFINED));
1113        assert!(empty(Value::from("")));
1114        assert!(empty(Value::from_serialize(Vec::<i32>::new())));
1115        assert!(!empty(Value::from("test")));
1116    }
1117
1118    #[test]
1119    fn test_trunc() {
1120        assert_eq!(trunc("hello".to_string(), 3), "hel");
1121        assert_eq!(trunc("hi".to_string(), 10), "hi");
1122    }
1123
1124    #[test]
1125    fn test_snakecase() {
1126        assert_eq!(snakecase("camelCase".to_string()), "camel_case");
1127        assert_eq!(snakecase("PascalCase".to_string()), "pascal_case");
1128    }
1129
1130    #[test]
1131    fn test_tostrings_list() {
1132        use minijinja::Environment;
1133
1134        let mut env = Environment::new();
1135        env.add_filter("tostrings", tostrings);
1136
1137        let result: Vec<String> = env
1138            .render_str("{{ [1, 2, 3] | tostrings }}", ())
1139            .unwrap()
1140            .trim_matches(|c| c == '[' || c == ']')
1141            .split(", ")
1142            .map(|s| s.trim_matches('"').to_string())
1143            .collect();
1144        assert_eq!(result, vec!["1", "2", "3"]);
1145    }
1146
1147    #[test]
1148    fn test_tostrings_with_prefix() {
1149        use minijinja::Environment;
1150
1151        let mut env = Environment::new();
1152        env.add_filter("tostrings", tostrings);
1153
1154        let template = r#"{{ [80, 443] | tostrings(prefix="port-") | join(",") }}"#;
1155        let result = env.render_str(template, ()).unwrap();
1156        assert_eq!(result, "port-80,port-443");
1157    }
1158
1159    #[test]
1160    fn test_tostrings_with_suffix() {
1161        use minijinja::Environment;
1162
1163        let mut env = Environment::new();
1164        env.add_filter("tostrings", tostrings);
1165
1166        let template = r#"{{ [1, 2] | tostrings(suffix="/TCP") | join(",") }}"#;
1167        let result = env.render_str(template, ()).unwrap();
1168        assert_eq!(result, "1/TCP,2/TCP");
1169    }
1170
1171    #[test]
1172    fn test_tostrings_skip_empty() {
1173        use minijinja::Environment;
1174
1175        let mut env = Environment::new();
1176        env.add_filter("tostrings", tostrings);
1177
1178        let template = r#"{{ ["a", "", "c"] | tostrings(skip_empty=true) | join(",") }}"#;
1179        let result = env.render_str(template, ()).unwrap();
1180        assert_eq!(result, "a,c");
1181    }
1182
1183    #[test]
1184    fn test_tostrings_mixed() {
1185        use minijinja::Environment;
1186
1187        let mut env = Environment::new();
1188        env.add_filter("tostrings", tostrings);
1189
1190        let template = r#"{{ ["hello", 42, true] | tostrings | join(",") }}"#;
1191        let result = env.render_str(template, ()).unwrap();
1192        assert_eq!(result, "hello,42,true");
1193    }
1194
1195    #[test]
1196    fn test_int_filter() {
1197        // Integer passthrough
1198        assert_eq!(int(Value::from(42)).unwrap(), 42);
1199        // Float truncation
1200        assert_eq!(int(Value::from(3.7)).unwrap(), 3);
1201        assert_eq!(int(Value::from(-3.7)).unwrap(), -3);
1202        // String parsing
1203        assert_eq!(int(Value::from("123")).unwrap(), 123);
1204        assert_eq!(int(Value::from("45.9")).unwrap(), 45);
1205        // Bool conversion
1206        assert_eq!(int(Value::from(true)).unwrap(), 1);
1207        assert_eq!(int(Value::from(false)).unwrap(), 0);
1208    }
1209
1210    #[test]
1211    fn test_float_filter() {
1212        // Float passthrough
1213        let result = float(Value::from(2.5)).unwrap();
1214        assert!((result - 2.5).abs() < 0.001);
1215        // Integer conversion
1216        let result = float(Value::from(42)).unwrap();
1217        assert!((result - 42.0).abs() < 0.001);
1218        // String parsing
1219        let result = float(Value::from("2.5")).unwrap();
1220        assert!((result - 2.5).abs() < 0.001);
1221        // Bool conversion
1222        assert_eq!(float(Value::from(true)).unwrap(), 1.0);
1223        assert_eq!(float(Value::from(false)).unwrap(), 0.0);
1224    }
1225
1226    #[test]
1227    fn test_abs_filter() {
1228        // Positive integer
1229        let result = abs(Value::from(5)).unwrap();
1230        assert_eq!(result.as_i64().unwrap(), 5);
1231        // Negative integer
1232        let result = abs(Value::from(-5)).unwrap();
1233        assert_eq!(result.as_i64().unwrap(), 5);
1234        // Positive float
1235        let result = abs(Value::from(2.5)).unwrap();
1236        assert!((f64::try_from(result).unwrap() - 2.5).abs() < 0.001);
1237        // Negative float
1238        let result = abs(Value::from(-2.5)).unwrap();
1239        assert!((f64::try_from(result).unwrap() - 2.5).abs() < 0.001);
1240        // Zero
1241        let result = abs(Value::from(0)).unwrap();
1242        assert_eq!(result.as_i64().unwrap(), 0);
1243    }
1244
1245    // =========================================================================
1246    // Path Function Tests
1247    // =========================================================================
1248
1249    #[test]
1250    fn test_basename() {
1251        assert_eq!(basename("/etc/nginx/nginx.conf".to_string()), "nginx.conf");
1252        assert_eq!(basename("file.txt".to_string()), "file.txt");
1253        assert_eq!(basename("/path/to/dir/".to_string()), "dir"); // Trailing slash is normalized
1254        assert_eq!(basename("/".to_string()), "");
1255        assert_eq!(basename("".to_string()), "");
1256    }
1257
1258    #[test]
1259    fn test_dirname() {
1260        assert_eq!(dirname("/etc/nginx/nginx.conf".to_string()), "/etc/nginx");
1261        assert_eq!(dirname("file.txt".to_string()), "");
1262        assert_eq!(dirname("/single".to_string()), "/");
1263        assert_eq!(dirname("a/b/c".to_string()), "a/b");
1264    }
1265
1266    #[test]
1267    fn test_extname() {
1268        assert_eq!(extname("file.txt".to_string()), "txt");
1269        assert_eq!(extname("archive.tar.gz".to_string()), "gz");
1270        assert_eq!(extname("noext".to_string()), "");
1271        assert_eq!(extname(".hidden".to_string()), "");
1272    }
1273
1274    #[test]
1275    fn test_cleanpath() {
1276        assert_eq!(cleanpath("a/b/../c".to_string()), "a/c");
1277        assert_eq!(cleanpath("a/./b/./c".to_string()), "a/b/c");
1278        assert_eq!(cleanpath("/a/b/../c".to_string()), "/a/c");
1279        assert_eq!(cleanpath("../a".to_string()), "a");
1280        assert_eq!(cleanpath("".to_string()), ".");
1281    }
1282
1283    // =========================================================================
1284    // Regex Function Tests
1285    // =========================================================================
1286
1287    #[test]
1288    fn test_regex_match() {
1289        assert!(regex_match("v1.2.3".to_string(), r"^v\d+".to_string()).unwrap());
1290        assert!(!regex_match("1.2.3".to_string(), r"^v\d+".to_string()).unwrap());
1291        assert!(regex_match("hello@world.com".to_string(), r"@.*\.".to_string()).unwrap());
1292    }
1293
1294    #[test]
1295    fn test_regex_replace() {
1296        assert_eq!(
1297            regex_replace(
1298                "v1.2.3".to_string(),
1299                r"v(\d+)".to_string(),
1300                "version-$1".to_string()
1301            )
1302            .unwrap(),
1303            "version-1.2.3"
1304        );
1305        assert_eq!(
1306            regex_replace(
1307                "foo bar baz".to_string(),
1308                r"\s+".to_string(),
1309                "-".to_string()
1310            )
1311            .unwrap(),
1312            "foo-bar-baz"
1313        );
1314    }
1315
1316    #[test]
1317    fn test_regex_find() {
1318        assert_eq!(
1319            regex_find("port: 8080".to_string(), r"\d+".to_string()).unwrap(),
1320            "8080"
1321        );
1322        assert_eq!(
1323            regex_find("no numbers".to_string(), r"\d+".to_string()).unwrap(),
1324            ""
1325        );
1326    }
1327
1328    #[test]
1329    fn test_regex_find_all() {
1330        let result = regex_find_all("a1b2c3".to_string(), r"\d+".to_string()).unwrap();
1331        assert_eq!(result, vec!["1", "2", "3"]);
1332    }
1333
1334    // =========================================================================
1335    // Dict Function Tests
1336    // =========================================================================
1337
1338    #[test]
1339    fn test_values_filter() {
1340        use minijinja::Environment;
1341        let mut env = Environment::new();
1342        env.add_filter("values", values);
1343
1344        let result = env
1345            .render_str(r#"{{ {"a": 1, "b": 2} | values | sort | list }}"#, ())
1346            .unwrap();
1347        assert!(result.contains("1") && result.contains("2"));
1348    }
1349
1350    #[test]
1351    fn test_pick_filter() {
1352        use minijinja::Environment;
1353        let mut env = Environment::new();
1354        env.add_filter("pick", pick);
1355
1356        let result = env
1357            .render_str(r#"{{ {"a": 1, "b": 2, "c": 3} | pick("a", "c") }}"#, ())
1358            .unwrap();
1359        assert!(result.contains("a") && result.contains("c") && !result.contains("b"));
1360    }
1361
1362    #[test]
1363    fn test_omit_filter() {
1364        use minijinja::Environment;
1365        let mut env = Environment::new();
1366        env.add_filter("omit", omit);
1367
1368        let result = env
1369            .render_str(r#"{{ {"a": 1, "b": 2, "c": 3} | omit("b") }}"#, ())
1370            .unwrap();
1371        assert!(result.contains("a") && result.contains("c") && !result.contains(": 2"));
1372    }
1373
1374    // =========================================================================
1375    // List Function Tests
1376    // =========================================================================
1377
1378    #[test]
1379    fn test_append_filter() {
1380        use minijinja::Environment;
1381        let mut env = Environment::new();
1382        env.add_filter("append", append);
1383
1384        let result = env.render_str(r#"{{ [1, 2] | append(3) }}"#, ()).unwrap();
1385        assert_eq!(result, "[1, 2, 3]");
1386    }
1387
1388    #[test]
1389    fn test_prepend_filter() {
1390        use minijinja::Environment;
1391        let mut env = Environment::new();
1392        env.add_filter("prepend", prepend);
1393
1394        let result = env.render_str(r#"{{ [2, 3] | prepend(1) }}"#, ()).unwrap();
1395        assert_eq!(result, "[1, 2, 3]");
1396    }
1397
1398    #[test]
1399    fn test_concat_filter() {
1400        use minijinja::Environment;
1401        let mut env = Environment::new();
1402        env.add_filter("concat", concat);
1403
1404        let result = env
1405            .render_str(r#"{{ [1, 2] | concat([3, 4]) }}"#, ())
1406            .unwrap();
1407        assert_eq!(result, "[1, 2, 3, 4]");
1408    }
1409
1410    #[test]
1411    fn test_without_filter() {
1412        use minijinja::Environment;
1413        let mut env = Environment::new();
1414        env.add_filter("without", without);
1415
1416        let result = env
1417            .render_str(r#"{{ [1, 2, 3, 2] | without(2) }}"#, ())
1418            .unwrap();
1419        assert_eq!(result, "[1, 3]");
1420    }
1421
1422    #[test]
1423    fn test_compact_filter() {
1424        use minijinja::Environment;
1425        let mut env = Environment::new();
1426        env.add_filter("compact", compact);
1427
1428        let result = env
1429            .render_str(r#"{{ ["a", "", "b"] | compact }}"#, ())
1430            .unwrap();
1431        assert!(result.contains("a") && result.contains("b") && !result.contains(r#""""#));
1432    }
1433
1434    // =========================================================================
1435    // Math Function Tests
1436    // =========================================================================
1437
1438    #[test]
1439    fn test_floor_filter() {
1440        assert_eq!(floor(Value::from(3.7)).unwrap(), 3);
1441        assert_eq!(floor(Value::from(3.2)).unwrap(), 3);
1442        assert_eq!(floor(Value::from(-3.2)).unwrap(), -4);
1443        assert_eq!(floor(Value::from(5)).unwrap(), 5);
1444    }
1445
1446    #[test]
1447    fn test_ceil_filter() {
1448        assert_eq!(ceil(Value::from(3.2)).unwrap(), 4);
1449        assert_eq!(ceil(Value::from(3.7)).unwrap(), 4);
1450        assert_eq!(ceil(Value::from(-3.7)).unwrap(), -3);
1451        assert_eq!(ceil(Value::from(5)).unwrap(), 5);
1452    }
1453
1454    // =========================================================================
1455    // Crypto Function Tests
1456    // =========================================================================
1457
1458    #[test]
1459    fn test_sha1sum() {
1460        // SHA-1 of "hello"
1461        assert_eq!(
1462            sha1sum("hello".to_string()),
1463            "aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d"
1464        );
1465    }
1466
1467    #[test]
1468    fn test_sha512sum() {
1469        // SHA-512 of "hello" (first 32 chars)
1470        let result = sha512sum("hello".to_string());
1471        assert!(result.starts_with("9b71d224bd62f3785d96d46ad3ea3d73"));
1472        assert_eq!(result.len(), 128); // SHA-512 produces 128 hex chars
1473    }
1474
1475    #[test]
1476    fn test_md5sum() {
1477        // MD5 of "hello"
1478        assert_eq!(
1479            md5sum("hello".to_string()),
1480            "5d41402abc4b2a76b9719d911017c592"
1481        );
1482    }
1483
1484    // =========================================================================
1485    // String Function Tests
1486    // =========================================================================
1487
1488    #[test]
1489    fn test_repeat_filter() {
1490        assert_eq!(repeat("-".to_string(), 5), "-----");
1491        assert_eq!(repeat("ab".to_string(), 3), "ababab");
1492        assert_eq!(repeat("x".to_string(), 0), "");
1493    }
1494
1495    #[test]
1496    fn test_camelcase_filter() {
1497        assert_eq!(camelcase("foo_bar_baz".to_string()), "fooBarBaz");
1498        assert_eq!(camelcase("foo-bar-baz".to_string()), "fooBarBaz");
1499        assert_eq!(camelcase("FOO_BAR".to_string()), "fooBar");
1500        assert_eq!(camelcase("already".to_string()), "already");
1501    }
1502
1503    #[test]
1504    fn test_pascalcase_filter() {
1505        assert_eq!(pascalcase("foo_bar".to_string()), "FooBar");
1506        assert_eq!(pascalcase("foo-bar-baz".to_string()), "FooBarBaz");
1507        assert_eq!(pascalcase("hello".to_string()), "Hello");
1508    }
1509
1510    #[test]
1511    fn test_substr_filter() {
1512        assert_eq!(substr("hello world".to_string(), 0, Some(5)), "hello");
1513        assert_eq!(substr("hello world".to_string(), 6, None), "world");
1514        assert_eq!(substr("hello".to_string(), 10, Some(5)), "");
1515        assert_eq!(substr("hello".to_string(), 0, Some(100)), "hello");
1516    }
1517
1518    #[test]
1519    fn test_wrap_filter() {
1520        assert_eq!(
1521            wrap("hello world foo bar".to_string(), 10),
1522            "hello\nworld foo\nbar"
1523        );
1524        assert_eq!(wrap("short".to_string(), 20), "short");
1525    }
1526
1527    #[test]
1528    fn test_hasprefix_filter() {
1529        assert!(hasprefix("hello world".to_string(), "hello".to_string()));
1530        assert!(!hasprefix("hello world".to_string(), "world".to_string()));
1531    }
1532
1533    #[test]
1534    fn test_hassuffix_filter() {
1535        assert!(hassuffix("hello.txt".to_string(), ".txt".to_string()));
1536        assert!(!hassuffix("hello.txt".to_string(), ".yaml".to_string()));
1537    }
1538}