Skip to main content

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