sherpack_engine/
functions.rs

1//! Template functions (global functions available in templates)
2
3use minijinja::value::{Object, Rest};
4use minijinja::{Error, ErrorKind, State, Value};
5use std::sync::Arc;
6use std::sync::atomic::{AtomicUsize, Ordering};
7
8/// Maximum recursion depth for tpl function (prevents infinite loops)
9const MAX_TPL_DEPTH: usize = 10;
10
11/// Key for storing tpl recursion depth in State's temp storage
12const TPL_DEPTH_KEY: &str = "__sherpack_tpl_depth";
13
14/// Counter object for tracking tpl recursion depth
15/// Implements Object trait so it can be stored in State's temp storage
16#[derive(Debug, Default)]
17struct TplDepthCounter(AtomicUsize);
18
19impl Object for TplDepthCounter {
20    fn repr(self: &Arc<Self>) -> minijinja::value::ObjectRepr {
21        minijinja::value::ObjectRepr::Plain
22    }
23}
24
25impl TplDepthCounter {
26    fn increment(&self) -> usize {
27        self.0.fetch_add(1, Ordering::SeqCst) + 1
28    }
29
30    fn decrement(&self) {
31        self.0.fetch_sub(1, Ordering::SeqCst);
32    }
33}
34
35/// Fail with a custom error message
36///
37/// Usage: {{ fail("Something went wrong") }}
38pub fn fail(message: String) -> Result<Value, Error> {
39    Err(Error::new(ErrorKind::InvalidOperation, message))
40}
41
42/// Create a dict from key-value pairs
43///
44/// Usage: {{ dict("key1", value1, "key2", value2) }}
45pub fn dict(args: Vec<Value>) -> Result<Value, Error> {
46    if !args.len().is_multiple_of(2) {
47        return Err(Error::new(
48            ErrorKind::InvalidOperation,
49            "dict requires an even number of arguments (key-value pairs)",
50        ));
51    }
52
53    let mut map = serde_json::Map::new();
54
55    for chunk in args.chunks(2) {
56        let key = chunk[0]
57            .as_str()
58            .ok_or_else(|| Error::new(ErrorKind::InvalidOperation, "dict keys must be strings"))?;
59        let value: serde_json::Value = serde_json::to_value(&chunk[1])
60            .map_err(|e| Error::new(ErrorKind::InvalidOperation, e.to_string()))?;
61        map.insert(key.to_string(), value);
62    }
63
64    Ok(Value::from_serialize(serde_json::Value::Object(map)))
65}
66
67/// Create a list from values
68///
69/// Usage: {{ list("a", "b", "c") }}
70pub fn list(args: Vec<Value>) -> Value {
71    Value::from(args)
72}
73
74/// Get a value with a default if undefined
75///
76/// Usage: {{ get(values, "key", "default") }}
77pub fn get(obj: Value, key: String, default: Option<Value>) -> Value {
78    match obj.get_attr(&key) {
79        Ok(v) if !v.is_undefined() => v,
80        _ => default.unwrap_or(Value::UNDEFINED),
81    }
82}
83
84/// Set a key in a dict (returns new dict, original unchanged)
85///
86/// Usage: {{ set(mydict, "newkey", "newvalue") }}
87pub fn set(dict: Value, key: String, val: Value) -> Result<Value, Error> {
88    use minijinja::value::ValueKind;
89
90    match dict.kind() {
91        ValueKind::Map => {
92            let mut result = indexmap::IndexMap::new();
93
94            // Copy existing entries
95            if let Ok(iter) = dict.try_iter() {
96                for k in iter {
97                    if let Some(k_str) = k.as_str()
98                        && let Ok(v) = dict.get_item(&k)
99                    {
100                        result.insert(k_str.to_string(), v);
101                    }
102                }
103            }
104
105            // Set the new value
106            result.insert(key, val);
107            Ok(Value::from_iter(result))
108        }
109        _ => Err(Error::new(
110            ErrorKind::InvalidOperation,
111            format!("set requires a dict, got {:?}", dict.kind()),
112        )),
113    }
114}
115
116/// Remove a key from a dict (returns new dict, original unchanged)
117///
118/// Usage: {{ unset(mydict, "keytoremove") }}
119pub fn unset(dict: Value, key: String) -> Result<Value, Error> {
120    use minijinja::value::ValueKind;
121
122    match dict.kind() {
123        ValueKind::Map => {
124            let mut result = indexmap::IndexMap::new();
125
126            if let Ok(iter) = dict.try_iter() {
127                for k in iter {
128                    if let Some(k_str) = k.as_str()
129                        && k_str != key
130                        && let Ok(v) = dict.get_item(&k)
131                    {
132                        result.insert(k_str.to_string(), v);
133                    }
134                }
135            }
136
137            Ok(Value::from_iter(result))
138        }
139        _ => Err(Error::new(
140            ErrorKind::InvalidOperation,
141            format!("unset requires a dict, got {:?}", dict.kind()),
142        )),
143    }
144}
145
146/// Deep get with path and default value
147///
148/// Usage: {{ dig(mydict, "a", "b", "c", "default") }}
149/// Equivalent to mydict.a.b.c with fallback to default if any key is missing
150pub fn dig(dict: Value, keys_and_default: Rest<Value>) -> Result<Value, Error> {
151    let args: &[Value] = &keys_and_default;
152
153    if args.is_empty() {
154        return Err(Error::new(
155            ErrorKind::InvalidOperation,
156            "dig requires at least one key and a default value",
157        ));
158    }
159
160    // Last argument is the default value, rest are keys
161    let (keys, default_slice) = args.split_at(args.len() - 1);
162    let default = default_slice.first().cloned().unwrap_or(Value::UNDEFINED);
163
164    if keys.is_empty() {
165        // Only default was provided, return the dict itself
166        return Ok(dict);
167    }
168
169    // Traverse the path
170    let mut current = dict;
171    for key in keys {
172        match key.as_str() {
173            Some(k) => match current.get_attr(k) {
174                Ok(v) if !v.is_undefined() => current = v,
175                _ => return Ok(default),
176            },
177            None => {
178                // Handle integer keys for lists
179                if let Some(idx) = key.as_i64() {
180                    match current.get_item(&Value::from(idx)) {
181                        Ok(v) if !v.is_undefined() => current = v,
182                        _ => return Ok(default),
183                    }
184                } else {
185                    return Ok(default);
186                }
187            }
188        }
189    }
190
191    Ok(current)
192}
193
194/// Return first non-empty value
195///
196/// Usage: {{ coalesce(a, b, c) }}
197pub fn coalesce(args: Vec<Value>) -> Value {
198    for arg in args {
199        if !arg.is_undefined() && !arg.is_none() {
200            if let Some(s) = arg.as_str() {
201                if !s.is_empty() {
202                    return arg;
203                }
204            } else {
205                return arg;
206            }
207        }
208    }
209    Value::UNDEFINED
210}
211
212/// Ternary operator
213///
214/// Usage: {{ ternary(true_value, false_value, condition) }}
215pub fn ternary(true_val: Value, false_val: Value, condition: Value) -> Value {
216    if condition.is_true() {
217        true_val
218    } else {
219        false_val
220    }
221}
222
223/// Generate a UUID (v4)
224///
225/// Usage: {{ uuidv4() }}
226pub fn uuidv4() -> String {
227    // Simple UUID v4 generation without external dependency
228    use std::time::{SystemTime, UNIX_EPOCH};
229
230    let timestamp = SystemTime::now()
231        .duration_since(UNIX_EPOCH)
232        .unwrap_or_default()
233        .as_nanos();
234
235    let random_part = timestamp ^ (timestamp >> 32);
236
237    format!(
238        "{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}",
239        (random_part & 0xFFFFFFFF) as u32,
240        ((random_part >> 32) & 0xFFFF) as u16,
241        ((random_part >> 48) & 0x0FFF) as u16,
242        (((random_part >> 60) & 0x3F) | 0x80) as u16 | ((random_part & 0xFF) << 8) as u16,
243        (random_part ^ (random_part >> 16)) & 0xFFFFFFFFFFFF
244    )
245}
246
247/// Convert a value to a string representation
248///
249/// Usage: {{ tostring(value) }}
250pub fn tostring(value: Value) -> String {
251    if let Some(s) = value.as_str() {
252        s.to_string()
253    } else {
254        value.to_string()
255    }
256}
257
258/// Convert a value to an integer
259///
260/// Usage: {{ toint(value) }}
261pub fn toint(value: Value) -> Result<i64, Error> {
262    if let Some(n) = value.as_i64() {
263        Ok(n)
264    } else if let Some(s) = value.as_str() {
265        s.parse::<i64>().map_err(|_| {
266            Error::new(
267                ErrorKind::InvalidOperation,
268                format!("cannot convert '{}' to int", s),
269            )
270        })
271    } else {
272        Err(Error::new(
273            ErrorKind::InvalidOperation,
274            format!("cannot convert {:?} to int", value),
275        ))
276    }
277}
278
279/// Convert a value to a float
280///
281/// Usage: {{ tofloat(value) }}
282pub fn tofloat(value: Value) -> Result<f64, Error> {
283    if let Some(n) = value.as_i64() {
284        Ok(n as f64)
285    } else if let Some(s) = value.as_str() {
286        s.parse::<f64>().map_err(|_| {
287            Error::new(
288                ErrorKind::InvalidOperation,
289                format!("cannot convert '{}' to float", s),
290            )
291        })
292    } else {
293        Err(Error::new(
294            ErrorKind::InvalidOperation,
295            format!("cannot convert {:?} to float", value),
296        ))
297    }
298}
299
300/// Get current timestamp
301///
302/// Usage: {{ now() }}
303pub fn now() -> String {
304    chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
305}
306
307/// Printf-style formatting
308///
309/// Usage: {{ printf("%s-%d", name, count) }}
310///
311/// Supports format specifiers: %s, %d, %f, %v, %%
312pub fn printf(format: String, args: Vec<Value>) -> Result<String, Error> {
313    // Pre-allocate with estimated size
314    let mut result = String::with_capacity(format.len() + args.len() * 10);
315    let mut chars = format.chars().peekable();
316    let mut arg_idx = 0;
317
318    while let Some(c) = chars.next() {
319        if c != '%' {
320            result.push(c);
321            continue;
322        }
323
324        // Handle format specifier
325        let format_char = match chars.next() {
326            Some(fc) => fc,
327            None => {
328                // Trailing % at end of string
329                result.push('%');
330                break;
331            }
332        };
333
334        // Handle escaped %%
335        if format_char == '%' {
336            result.push('%');
337            continue;
338        }
339
340        // Need an argument for this specifier
341        if arg_idx >= args.len() {
342            return Err(Error::new(
343                ErrorKind::InvalidOperation,
344                "not enough arguments for format string",
345            ));
346        }
347
348        let arg = &args[arg_idx];
349        match format_char {
350            's' | 'v' => result.push_str(&arg.to_string()),
351            'd' => {
352                if let Some(n) = arg.as_i64() {
353                    result.push_str(&n.to_string());
354                } else {
355                    result.push_str(&arg.to_string());
356                }
357            }
358            'f' => {
359                if let Some(n) = arg.as_i64() {
360                    result.push_str(&(n as f64).to_string());
361                } else {
362                    result.push_str(&arg.to_string());
363                }
364            }
365            _ => {
366                // Unknown format specifier, treat as %v
367                result.push_str(&arg.to_string());
368            }
369        }
370        arg_idx += 1;
371    }
372
373    Ok(result)
374}
375
376/// Evaluate a string as a template (Helm's tpl function)
377///
378/// Usage: {{ tpl(values.dynamicTemplate, ctx) }}
379///
380/// This allows template strings stored in values to contain Jinja expressions.
381/// The context parameter provides the variables available to the nested template.
382///
383/// ## Security Features (Sherpack improvements over Helm)
384///
385/// - **Recursion limit**: Maximum depth of 10 to prevent infinite loops
386/// - **Source tracking**: Better error messages showing template origin
387///
388/// ## Example
389///
390/// In values.yaml:
391/// ```yaml
392/// host: "{{ release.name }}.example.com"
393/// ```
394///
395/// Then in template:
396/// ```jinja
397/// host: {{ tpl(values.host, {"release": release}) }}
398/// ```
399/// Result: `host: myrelease.example.com`
400pub fn tpl(state: &State, template: String, context: Value) -> Result<String, Error> {
401    // Skip if no template markers present (optimization)
402    if !template.contains("{{") && !template.contains("{%") {
403        return Ok(template);
404    }
405
406    // Check recursion depth to prevent infinite loops
407    let depth = increment_tpl_depth(state)?;
408
409    // Render the template string using the current environment
410    let result = state.env().render_str(&template, context).map_err(|e| {
411        // Enhance error message with tpl context
412        Error::new(
413            ErrorKind::InvalidOperation,
414            format!(
415                "tpl error (depth {}): {}\n  Template: \"{}\"",
416                depth,
417                e,
418                truncate_for_error(&template, 60)
419            ),
420        )
421    });
422
423    // Decrement depth after rendering (for sibling tpl calls)
424    decrement_tpl_depth(state);
425
426    result
427}
428
429/// Increment tpl recursion depth, returning error if limit exceeded
430fn increment_tpl_depth(state: &State) -> Result<usize, Error> {
431    let counter = state.get_or_set_temp_object(TPL_DEPTH_KEY, TplDepthCounter::default);
432    let depth = counter.increment();
433
434    if depth > MAX_TPL_DEPTH {
435        Err(Error::new(
436            ErrorKind::InvalidOperation,
437            format!(
438                "tpl recursion depth {} exceeded maximum {} - possible infinite loop in values. \
439                 Check for circular references in template strings.",
440                depth, MAX_TPL_DEPTH
441            ),
442        ))
443    } else {
444        Ok(depth)
445    }
446}
447
448/// Decrement tpl recursion depth
449fn decrement_tpl_depth(state: &State) {
450    let counter = state.get_or_set_temp_object(TPL_DEPTH_KEY, TplDepthCounter::default);
451    counter.decrement();
452}
453
454/// Truncate string for error messages
455fn truncate_for_error(s: &str, max_len: usize) -> String {
456    if s.len() <= max_len {
457        s.to_string()
458    } else {
459        format!("{}...", &s[..max_len])
460    }
461}
462
463/// Kubernetes resource lookup (Helm-compatible)
464///
465/// Usage: {{ lookup("v1", "Secret", "default", "my-secret") }}
466///
467/// **IMPORTANT:** In template-only mode (sherpack template), this function
468/// always returns an empty object, matching Helm's behavior.
469///
470/// Parameters:
471/// - apiVersion: API version (e.g., "v1", "apps/v1")
472/// - kind: Resource kind (e.g., "Secret", "ConfigMap", "Deployment")
473/// - namespace: Namespace (empty string "" for cluster-scoped resources)
474/// - name: Resource name (empty string "" to list all resources)
475///
476/// Return values:
477/// - Single resource: Returns the resource as a dict
478/// - List (name=""): Returns {"items": [...]} dict
479/// - Not found / template mode: Returns empty dict {}
480///
481/// ## Why lookup returns empty in template mode
482///
483/// Like Helm, Sherpack separates template rendering from cluster operations:
484/// - `sherpack template`: Pure rendering, no cluster access → lookup returns {}
485/// - `sherpack install/upgrade`: Cluster access for apply, but lookup still empty
486///
487/// ## Alternatives to lookup
488///
489/// Instead of using lookup, consider these Sherpack patterns:
490///
491/// 1. **Check if resource exists**: Use sync-waves to create dependencies
492///    ```yaml
493///    sherpack.io/sync-wave: "0"  # Create first
494///    ---
495///    sherpack.io/sync-wave: "1"  # Created after wave 0 is ready
496///    ```
497///
498/// 2. **Reuse existing secrets**: Use external-secrets or hooks
499///    ```yaml
500///    sherpack.io/hook: pre-install
501///    sherpack.io/hook-weight: "-5"
502///    ```
503///
504/// 3. **Conditional resources**: Use values-based conditions
505///    ```jinja
506///    {%- if values.existingSecret %}
507///    secretName: {{ values.existingSecret }}
508///    {%- else %}
509///    secretName: {{ release.name }}-secret
510///    {%- endif %}
511///    ```
512pub fn lookup(api_version: String, kind: String, namespace: String, name: String) -> Value {
513    // Log what was requested (useful for debugging/migration from Helm)
514    // In template mode, this always returns empty - matching Helm behavior
515    let _ = (api_version, kind, namespace, name); // Acknowledge params
516
517    // Return empty dict - same as Helm's `helm template` behavior
518    // This ensures charts work in GitOps workflows and CI/CD pipelines
519    Value::from_serialize(serde_json::json!({}))
520}
521
522/// Evaluate a string as a template with full context (convenience version)
523///
524/// Usage: {{ tpl_ctx(values.dynamicTemplate) }}
525///
526/// This version automatically passes the full template context (values, release, pack, etc.)
527/// to the nested template, similar to Helm's `tpl $str .` pattern.
528///
529/// ## Security Features
530///
531/// - **Recursion limit**: Shares depth counter with `tpl()`, max depth 10
532/// - **Full context**: Passes values, release, pack, capabilities, template
533pub fn tpl_ctx(state: &State, template: String) -> Result<String, Error> {
534    // Skip if no template markers present (optimization)
535    if !template.contains("{{") && !template.contains("{%") {
536        return Ok(template);
537    }
538
539    // Check recursion depth to prevent infinite loops
540    let depth = increment_tpl_depth(state)?;
541
542    // Build context from all available variables
543    let mut ctx = serde_json::Map::new();
544
545    // Try to lookup and add standard context variables
546    for var in ["values", "release", "pack", "capabilities", "template"] {
547        if let Some(v) = state.lookup(var)
548            && !v.is_undefined()
549            && let Ok(json_val) = serde_json::to_value(&v)
550        {
551            ctx.insert(var.to_string(), json_val);
552        }
553    }
554
555    let context = Value::from_serialize(serde_json::Value::Object(ctx));
556
557    let result = state.env().render_str(&template, context).map_err(|e| {
558        Error::new(
559            ErrorKind::InvalidOperation,
560            format!(
561                "tpl_ctx error (depth {}): {}\n  Template: \"{}\"",
562                depth,
563                e,
564                truncate_for_error(&template, 60)
565            ),
566        )
567    });
568
569    // Decrement depth after rendering
570    decrement_tpl_depth(state);
571
572    result
573}
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578
579    #[test]
580    fn test_dict() {
581        let result = dict(vec![
582            Value::from("key1"),
583            Value::from("value1"),
584            Value::from("key2"),
585            Value::from(42),
586        ])
587        .unwrap();
588
589        assert_eq!(result.get_attr("key1").unwrap().as_str(), Some("value1"));
590    }
591
592    #[test]
593    fn test_list() {
594        let result = list(vec![Value::from("a"), Value::from("b"), Value::from("c")]);
595        assert_eq!(result.len(), Some(3));
596    }
597
598    #[test]
599    fn test_ternary() {
600        assert_eq!(
601            ternary(Value::from("yes"), Value::from("no"), Value::from(true)).as_str(),
602            Some("yes")
603        );
604        assert_eq!(
605            ternary(Value::from("yes"), Value::from("no"), Value::from(false)).as_str(),
606            Some("no")
607        );
608    }
609
610    #[test]
611    fn test_printf() {
612        let result = printf(
613            "Hello %s, you have %d messages".to_string(),
614            vec![Value::from("Alice"), Value::from(5)],
615        )
616        .unwrap();
617        assert_eq!(result, "Hello Alice, you have 5 messages");
618    }
619
620    #[test]
621    fn test_tpl_integration() {
622        use minijinja::Environment;
623
624        // Test tpl via full environment (since it needs State)
625        let mut env = Environment::new();
626        env.add_function("tpl", super::tpl);
627
628        let template = r#"{{ tpl("Hello {{ name }}!", {"name": "World"}) }}"#;
629        let result = env.render_str(template, ()).unwrap();
630        assert_eq!(result, "Hello World!");
631    }
632
633    #[test]
634    fn test_tpl_no_markers() {
635        use minijinja::Environment;
636
637        // Plain string without template markers should be returned as-is
638        let mut env = Environment::new();
639        env.add_function("tpl", super::tpl);
640
641        let template = r#"{{ tpl("plain text", {}) }}"#;
642        let result = env.render_str(template, ()).unwrap();
643        assert_eq!(result, "plain text");
644    }
645
646    #[test]
647    fn test_tpl_complex() {
648        use minijinja::Environment;
649
650        let mut env = Environment::new();
651        env.add_function("tpl", super::tpl);
652
653        // Test with conditional
654        let template =
655            r#"{{ tpl("{% if enabled %}yes{% else %}no{% endif %}", {"enabled": true}) }}"#;
656        let result = env.render_str(template, ()).unwrap();
657        assert_eq!(result, "yes");
658    }
659
660    #[test]
661    fn test_tpl_recursion_limit() {
662        use minijinja::Environment;
663
664        let mut env = Environment::new();
665        env.add_function("tpl", super::tpl);
666
667        // Create a deeply nested tpl call that would exceed MAX_TPL_DEPTH
668        // Each nested tpl increases depth by 1
669        let template = r#"{{ tpl("{{ tpl(\"{{ tpl(\\\"{{ tpl(\\\\\\\"{{ tpl(\\\\\\\\\\\\\\\"{{ tpl(\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\"{{ tpl(\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\"{{ tpl(\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\"{{ tpl(\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\"done\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\", {}) }}\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\", {}) }}\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\", {}) }}\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\", {}) }}\\\\\\\\\\\\\\\", {}) }}\\\\\\\"  , {}) }}\\\\\\\", {}) }}\\\", {}) }}\", {}) }}", {}) }}"#;
670
671        let result = env.render_str(template, ());
672
673        // Should fail with recursion limit error
674        assert!(result.is_err());
675        let err = result.unwrap_err();
676        assert!(
677            err.to_string().contains("recursion") || err.to_string().contains("depth"),
678            "Expected recursion error, got: {}",
679            err
680        );
681    }
682
683    #[test]
684    fn test_tpl_nested_valid() {
685        use minijinja::Environment;
686
687        let mut env = Environment::new();
688        env.add_function("tpl", super::tpl);
689
690        // 3 levels of nesting should work fine
691        let template = r#"{{ tpl("{{ tpl(\"{{ tpl(\\\"level3\\\", {}) }}\", {}) }}", {}) }}"#;
692        let result = env.render_str(template, ()).unwrap();
693        assert_eq!(result, "level3");
694    }
695
696    #[test]
697    fn test_truncate_for_error() {
698        assert_eq!(truncate_for_error("short", 10), "short");
699        assert_eq!(
700            truncate_for_error("this is a longer string", 10),
701            "this is a ..."
702        );
703    }
704
705    #[test]
706    fn test_lookup_returns_empty() {
707        // lookup should return empty dict in template mode
708        let result = lookup(
709            "v1".to_string(),
710            "Secret".to_string(),
711            "default".to_string(),
712            "my-secret".to_string(),
713        );
714
715        // Should be an empty object, not undefined
716        assert!(!result.is_undefined());
717        // Should be iterable (dict)
718        assert!(result.try_iter().is_ok());
719    }
720
721    #[test]
722    fn test_lookup_in_template() {
723        use minijinja::Environment;
724
725        let mut env = Environment::new();
726        env.add_function("lookup", super::lookup);
727
728        // Common Helm pattern: check if secret exists
729        let template = r#"{% set secret = lookup("v1", "Secret", "default", "my-secret") %}{% if secret %}secret exists{% else %}no secret{% endif %}"#;
730        let result = env.render_str(template, ()).unwrap();
731        // Empty dict is falsy, so we get "no secret"
732        assert_eq!(result, "no secret");
733    }
734
735    #[test]
736    fn test_lookup_conditional_pattern() {
737        use minijinja::Environment;
738
739        let mut env = Environment::new();
740        env.add_function("lookup", super::lookup);
741
742        // Recommended pattern: check if lookup result exists before accessing properties
743        let template = r#"{% set secret = lookup("v1", "Secret", "ns", "s") %}{% if secret.data is defined %}{{ secret.data.password }}{% else %}generated{% endif %}"#;
744        let result = env.render_str(template, ()).unwrap();
745        assert_eq!(result, "generated");
746    }
747
748    #[test]
749    fn test_lookup_safe_pattern() {
750        use crate::filters::tojson;
751        use minijinja::Environment;
752
753        let mut env = Environment::new();
754        env.add_function("lookup", super::lookup);
755        env.add_function("get", super::get);
756        env.add_filter("tojson", tojson);
757
758        // Safe pattern for strict mode: use get() with default
759        let template = r#"{% set secret = lookup("v1", "Secret", "ns", "s") %}{{ get(secret, "data", {}) | tojson }}"#;
760        let result = env.render_str(template, ()).unwrap();
761        assert_eq!(result, "{}");
762    }
763
764    #[test]
765    fn test_set_function() {
766        use minijinja::Environment;
767
768        let mut env = Environment::new();
769        env.add_function("set", super::set);
770
771        let template = r#"{% set d = {"a": 1} %}{{ set(d, "b", 2) }}"#;
772        let result = env.render_str(template, ()).unwrap();
773        assert!(result.contains("a") && result.contains("b"));
774    }
775
776    #[test]
777    fn test_unset_function() {
778        use minijinja::Environment;
779
780        let mut env = Environment::new();
781        env.add_function("unset", super::unset);
782
783        let template = r#"{% set d = {"a": 1, "b": 2} %}{{ unset(d, "a") }}"#;
784        let result = env.render_str(template, ()).unwrap();
785        assert!(!result.contains("a") && result.contains("b"));
786    }
787
788    #[test]
789    fn test_dig_function() {
790        use minijinja::Environment;
791
792        let mut env = Environment::new();
793        env.add_function("dig", super::dig);
794
795        // Test deep access that exists
796        let template =
797            r#"{% set d = {"a": {"b": {"c": "found"}}} %}{{ dig(d, "a", "b", "c", "default") }}"#;
798        let result = env.render_str(template, ()).unwrap();
799        assert_eq!(result, "found");
800
801        // Test deep access that doesn't exist (returns default)
802        let template2 = r#"{% set d = {"a": {"b": {}}} %}{{ dig(d, "a", "b", "c", "default") }}"#;
803        let result2 = env.render_str(template2, ()).unwrap();
804        assert_eq!(result2, "default");
805    }
806}