sherpack_convert/
transformer.rs

1//! Go template to Jinja2 transformer
2//!
3//! Transforms Go template AST into idiomatic Jinja2 syntax.
4//!
5//! # Philosophy
6//!
7//! This transformer prioritizes **Jinja2 elegance** over Helm compatibility.
8//! Instead of replicating Go template's quirky syntax, we convert to natural
9//! Jinja2 patterns:
10//!
11//! | Helm (Go template)              | Sherpack (Jinja2)                |
12//! |---------------------------------|----------------------------------|
13//! | `{{ index .Values.list 0 }}`    | `{{ values.list[0] }}`           |
14//! | `{{ add 1 2 }}`                 | `{{ 1 + 2 }}`                    |
15//! | `{{ ternary "a" "b" .X }}`      | `{{ "a" if x else "b" }}`        |
16//! | `{{ printf "%s-%s" a b }}`      | `{{ a ~ "-" ~ b }}`              |
17//! | `{{ coalesce .A .B "c" }}`      | `{{ a or b or "c" }}`            |
18
19use crate::ast::*;
20use crate::type_inference::{InferredType, TypeContext, TypeHeuristics};
21use phf::phf_map;
22
23// =============================================================================
24// FILTER MAPPINGS - Direct 1:1 conversions
25// =============================================================================
26
27static FILTER_MAP: phf::Map<&'static str, &'static str> = phf_map! {
28    // Serialization
29    "toYaml" => "toyaml",
30    "toJson" => "tojson",
31    "toPrettyJson" => "tojson_pretty",
32    "fromYaml" => "fromyaml",
33    "fromJson" => "fromjson",
34
35    // Encoding
36    "b64enc" => "b64encode",
37    "b64dec" => "b64decode",
38
39    // Quoting
40    "quote" => "quote",
41    "squote" => "squote",
42
43    // String case
44    "upper" => "upper",
45    "lower" => "lower",
46    "title" => "title",
47    "camelcase" => "camelcase",
48    "snakecase" => "snakecase",
49    "kebabcase" => "kebabcase",
50    "swapcase" => "swapcase",
51
52    // String manipulation
53    "trim" => "trim",
54    "trimPrefix" => "trimprefix",
55    "trimSuffix" => "trimsuffix",
56    "trimAll" => "trim",
57    "trunc" => "trunc",
58    "abbrev" => "trunc",
59    "repeat" => "repeat",
60    "replace" => "replace",
61    "wrap" => "wordwrap",
62    "wrapWith" => "wordwrap",
63
64    // String testing
65    "hasPrefix" => "startswith",
66    "hasSuffix" => "endswith",
67
68    // Indentation
69    "indent" => "indent",
70    "nindent" => "nindent",
71
72    // Lists
73    "first" => "first",
74    "last" => "last",
75    "rest" => "list[1:]",
76    "initial" => "list[:-1]",
77    "reverse" => "reverse",
78    "uniq" => "unique",
79    "sortAlpha" => "sort",
80
81    // Dict
82    "hasKey" => "haskey",
83    "keys" => "keys",
84    "values" => "values",
85    "merge" => "merge",
86    "mergeOverwrite" => "merge",
87    "deepCopy" => "deepcopy",
88
89    // Type conversion
90    "toString" => "string",
91    "toStrings" => "tostrings",
92    "int" => "int",
93    "int64" => "int",
94    "float64" => "float",
95
96    // Validation
97    "required" => "required",
98    "empty" => "empty",
99
100    // Crypto hashes
101    "sha256sum" => "sha256",
102    "sha1sum" => "sha1",
103    "adler32sum" => "adler32",
104
105    // Regex (direct mapping - require sherpack-engine support)
106    "regexMatch" => "regex_match",
107    "regexFind" => "regex_search",
108    "regexFindAll" => "regex_findall",
109    "regexReplaceAll" => "regex_replace",
110    "regexSplit" => "split",
111};
112
113// =============================================================================
114// FEATURES - Categorized by support level
115// =============================================================================
116
117/// Features that convert to native Jinja2 operators (no function needed)
118static NATIVE_OPERATORS: phf::Map<&'static str, &'static str> = phf_map! {
119    // Comparison → native operators
120    "eq" => "==",
121    "ne" => "!=",
122    "lt" => "<",
123    "le" => "<=",
124    "gt" => ">",
125    "ge" => ">=",
126    // Logical → native operators
127    "and" => "and",
128    "or" => "or",
129    // Math → native operators
130    "add" => "+",
131    "sub" => "-",
132    "mul" => "*",
133    "div" => "/",
134    "mod" => "%",
135};
136
137/// Features that are intentionally unsupported (anti-patterns)
138static UNSUPPORTED_FEATURES: phf::Map<&'static str, &'static str> = phf_map! {
139    // Crypto - should be external (cert-manager, external-secrets)
140    "genCA" => "Use cert-manager or pre-generated certificates in values",
141    "genSelfSignedCert" => "Use cert-manager or pre-generated certificates",
142    "genSignedCert" => "Use cert-manager for certificate management",
143    "genPrivateKey" => "Use external secret management",
144    "htpasswd" => "Use external secret management or pre-computed values",
145    "derivePassword" => "Use external secret management",
146    "encryptAES" => "Use external secret management",
147    "decryptAES" => "Use external secret management",
148    "randBytes" => "Use external secret management for random data",
149    // Note: randAlphaNum, randAlpha, randNumeric are now converted to generate_secret()
150    "randAscii" => "Use external secret management",
151
152    // DNS - runtime dependency, breaks GitOps
153    "getHostByName" => "Use explicit IP/hostname in values (GitOps compatible)",
154
155    // Files - complexity, use ConfigMaps instead
156    "Files.Get" => "Embed file content in values.yaml or use ConfigMap",
157    "Files.GetBytes" => "Embed base64 content in values.yaml",
158    "Files.Glob" => "List files explicitly in values.yaml",
159    "Files.Lines" => "Embed content as list in values.yaml",
160    "Files.AsConfig" => "Use native ConfigMap in templates",
161    "Files.AsSecrets" => "Use native Secret in templates",
162
163    // Lookup - anti-GitOps (runtime cluster query)
164    "lookup" => "Returns {} in template mode - use explicit values for GitOps",
165};
166
167// =============================================================================
168// WARNING TYPES
169// =============================================================================
170
171/// Warning severity level
172#[derive(Debug, Clone, Copy, PartialEq, Eq)]
173pub enum WarningSeverity {
174    /// Converted but review recommended
175    Info,
176    /// Manual adjustment may be needed
177    Warning,
178    /// Feature not supported - alternative provided
179    Unsupported,
180}
181
182/// Rich warning with context and alternatives
183#[derive(Debug, Clone)]
184pub struct TransformWarning {
185    /// Warning severity
186    pub severity: WarningSeverity,
187    /// The pattern that triggered the warning
188    pub pattern: String,
189    /// Human-readable message
190    pub message: String,
191    /// Suggested alternative or fix
192    pub suggestion: Option<String>,
193    /// Link to documentation
194    pub doc_link: Option<String>,
195}
196
197impl TransformWarning {
198    pub fn info(pattern: &str, message: &str) -> Self {
199        Self {
200            severity: WarningSeverity::Info,
201            pattern: pattern.to_string(),
202            message: message.to_string(),
203            suggestion: None,
204            doc_link: None,
205        }
206    }
207
208    pub fn warning(pattern: &str, message: &str) -> Self {
209        Self {
210            severity: WarningSeverity::Warning,
211            pattern: pattern.to_string(),
212            message: message.to_string(),
213            suggestion: None,
214            doc_link: None,
215        }
216    }
217
218    pub fn unsupported(pattern: &str, alternative: &str) -> Self {
219        Self {
220            severity: WarningSeverity::Unsupported,
221            pattern: pattern.to_string(),
222            message: format!("'{}' is not supported in Sherpack", pattern),
223            suggestion: Some(alternative.to_string()),
224            doc_link: Some("https://sherpack.dev/docs/helm-migration".to_string()),
225        }
226    }
227
228    pub fn with_suggestion(mut self, suggestion: &str) -> Self {
229        self.suggestion = Some(suggestion.to_string());
230        self
231    }
232}
233
234// =============================================================================
235// TRANSFORMER
236// =============================================================================
237
238/// Block type for tracking nested structures
239#[derive(Debug, Clone)]
240enum BlockType {
241    If,
242    Range,
243    With,
244    Define,
245}
246
247/// Transformer for converting Go template AST to idiomatic Jinja2
248pub struct Transformer {
249    /// Track nested blocks for proper end tag generation
250    block_stack: Vec<BlockType>,
251    /// Warnings generated during transformation
252    #[allow(dead_code)]
253    warnings: Vec<TransformWarning>,
254    /// Chart name prefix to strip from include/template calls
255    chart_prefix: Option<String>,
256    /// Current context variable (for range/with blocks)
257    context_var: Option<String>,
258    /// Type context for smarter conversion (dict vs list detection)
259    type_context: Option<TypeContext>,
260    /// Counter for auto-generated secret names (Cell for interior mutability)
261    secret_counter: std::cell::Cell<usize>,
262}
263
264impl Default for Transformer {
265    fn default() -> Self {
266        Self::new()
267    }
268}
269
270impl Transformer {
271    pub fn new() -> Self {
272        Self {
273            block_stack: Vec::new(),
274            warnings: Vec::new(),
275            chart_prefix: None,
276            context_var: None,
277            type_context: None,
278            secret_counter: std::cell::Cell::new(0),
279        }
280    }
281
282    /// Set the chart name prefix to strip from include calls
283    pub fn with_chart_prefix(mut self, prefix: &str) -> Self {
284        self.chart_prefix = Some(format!("{}.", prefix));
285        self
286    }
287
288    /// Set the type context for smarter dict/list detection
289    pub fn with_type_context(mut self, ctx: TypeContext) -> Self {
290        self.type_context = Some(ctx);
291        self
292    }
293
294    /// Get warnings generated during transformation
295    pub fn warnings(&self) -> &[TransformWarning] {
296        &self.warnings
297    }
298
299    #[allow(dead_code)]
300    fn add_warning(&mut self, warning: TransformWarning) {
301        self.warnings.push(warning);
302    }
303
304    /// Get the next auto-generated secret name
305    fn next_secret_name(&self) -> String {
306        let n = self.secret_counter.get();
307        self.secret_counter.set(n + 1);
308        format!("auto-secret-{}", n + 1)
309    }
310
311    /// Transform a Go template AST to Jinja2 string
312    pub fn transform(&mut self, template: &Template) -> String {
313        template
314            .elements
315            .iter()
316            .map(|e| self.transform_element(e))
317            .collect()
318    }
319
320    fn transform_element(&mut self, element: &Element) -> String {
321        match element {
322            Element::RawText(text) => text.clone(),
323            Element::Action(action) => self.transform_action(action),
324        }
325    }
326
327    fn transform_action(&mut self, action: &Action) -> String {
328        let trim_left = if action.trim_left { "-" } else { "" };
329        let trim_right = if action.trim_right { "-" } else { "" };
330
331        match &action.body {
332            // Comments: {{/* ... */}} → {# ... #}
333            ActionBody::Comment(text) => {
334                format!("{{# {} #}}", text.trim())
335            }
336
337            // If: {{- if .X }} → {%- if x %}
338            ActionBody::If(pipeline) => {
339                self.block_stack.push(BlockType::If);
340                format!(
341                    "{{%{} if {} %}}",
342                    trim_left,
343                    self.transform_pipeline(pipeline)
344                )
345            }
346
347            // Else if: {{- else if .X }} → {%- elif x %}
348            ActionBody::ElseIf(pipeline) => {
349                format!(
350                    "{{%{} elif {} %}}",
351                    trim_left,
352                    self.transform_pipeline(pipeline)
353                )
354            }
355
356            // Else: {{- else }} → {%- else %}
357            ActionBody::Else => {
358                format!("{{%{} else %}}", trim_left)
359            }
360
361            // End: {{- end }} → {%- endif/endfor/endmacro %}
362            ActionBody::End => {
363                let block = self.block_stack.pop();
364                let end_tag = match &block {
365                    Some(BlockType::If) => "endif",
366                    Some(BlockType::Range) => "endfor",
367                    Some(BlockType::With) => "endif",
368                    Some(BlockType::Define) => "endmacro",
369                    None => "endif",
370                };
371
372                // Restore context after with block
373                if let Some(BlockType::With) = &block {
374                    self.context_var = None;
375                }
376
377                // endmacro doesn't support trim on closing
378                if matches!(block, Some(BlockType::Define)) {
379                    format!("{{%{} {} %}}", trim_left, end_tag)
380                } else if trim_right == "-" {
381                    format!("{{%{} {} -%}}", trim_left, end_tag)
382                } else {
383                    format!("{{%{} {} %}}", trim_left, end_tag)
384                }
385            }
386
387            // Range: {{- range $k, $v := .Dict }} → {%- for k, v in dict | dictsort %}
388            //        {{- range $i, $v := .List }} → {%- for v in list %}{#- i = loop.index0 #}
389            ActionBody::Range { vars, pipeline } => {
390                let value_var = vars
391                    .as_ref()
392                    .map(|v| v.value_var.trim_start_matches('$').to_string())
393                    .unwrap_or_else(|| "item".to_string());
394
395                let index_var = vars.as_ref().and_then(|v| {
396                    v.index_var
397                        .as_ref()
398                        .map(|i| i.trim_start_matches('$').to_string())
399                });
400
401                self.block_stack.push(BlockType::Range);
402
403                let collection = self.transform_pipeline(pipeline);
404
405                // Determine if this is a dictionary iteration
406                let is_dict = self.is_dict_type(&collection);
407
408                match (&index_var, is_dict) {
409                    // Dictionary with key variable: for key, value in dict | dictsort
410                    (Some(key_var), true) => {
411                        format!(
412                            "{{%{} for {}, {} in {} | dictsort %}}",
413                            trim_left, key_var, value_var, collection
414                        )
415                    }
416                    // List with index: for value in list, with index comment
417                    (Some(idx), false) => {
418                        format!(
419                            "{{%{} for {} in {} %}}{{#- {} = loop.index0 #}}",
420                            trim_left, value_var, collection, idx
421                        )
422                    }
423                    // No index variable: simple iteration
424                    (None, _) => {
425                        format!("{{%{} for {} in {} %}}", trim_left, value_var, collection)
426                    }
427                }
428            }
429
430            // With: {{- with .X }} → {%- if x %}{%- set _ctx = x %}
431            ActionBody::With(pipeline) => {
432                let ctx_value = self.transform_pipeline(pipeline);
433                let ctx_var = "_with_ctx".to_string();
434
435                self.block_stack.push(BlockType::With);
436                self.context_var = Some(ctx_var.clone());
437
438                // with becomes: if value, set context, use context
439                format!(
440                    "{{%{} if {} %}}{{%- set {} = {} %}}",
441                    trim_left, ctx_value, ctx_var, ctx_value
442                )
443            }
444
445            // Define: {{- define "name" }} → {%- macro name() %}
446            ActionBody::Define(name) => {
447                let macro_name = self.strip_chart_prefix(name);
448                self.block_stack.push(BlockType::Define);
449                format!("{{%{} macro {}() %}}", trim_left, macro_name)
450            }
451
452            // Template/Include: {{ template "name" . }} → {{ name() }}
453            ActionBody::Template { name, .. } => {
454                let macro_name = self.strip_chart_prefix(name);
455                format!("{{{{ {}() }}}}", macro_name)
456            }
457
458            // Block: {{- block "name" . }} → {%- block name %}
459            ActionBody::Block { name, .. } => {
460                let block_name = self.strip_chart_prefix(name);
461                self.block_stack.push(BlockType::Define);
462                format!("{{%{} block {} %}}", trim_left, block_name)
463            }
464
465            // Pipeline: {{ .X | filter }} or {{ $x := value }}
466            ActionBody::Pipeline(pipeline) => {
467                // Variable declaration: $x := value → {% set x = value %}
468                if let Some(ref var_name) = pipeline.decl {
469                    let clean_var = var_name.trim_start_matches('$');
470                    let value = if pipeline.commands.is_empty() {
471                        "none".to_string()
472                    } else {
473                        let pipe_without_decl = Pipeline {
474                            decl: None,
475                            commands: pipeline.commands.clone(),
476                        };
477                        self.transform_pipeline(&pipe_without_decl)
478                    };
479                    format!("{{%{} set {} = {} %}}", trim_left, clean_var, value)
480                } else {
481                    // Regular expression: {{ value | filter }}
482                    format!(
483                        "{{{{{} {} {}}}}}",
484                        trim_left,
485                        self.transform_pipeline(pipeline),
486                        trim_right
487                    )
488                }
489            }
490        }
491    }
492
493    fn transform_pipeline(&self, pipeline: &Pipeline) -> String {
494        let mut parts = Vec::new();
495
496        for (i, cmd) in pipeline.commands.iter().enumerate() {
497            let is_filter = i > 0;
498            parts.push(self.transform_command(cmd, is_filter));
499        }
500
501        let result = parts.join(" | ");
502
503        // Post-process special markers
504        self.post_process_pipeline(&result)
505    }
506
507    fn post_process_pipeline(&self, result: &str) -> String {
508        let mut output = result.to_string();
509
510        // Convert _in_(needle) marker to proper "in" syntax
511        // "haystack | _in_(needle)" → "(needle in haystack)"
512        if let Some(idx) = output.find(" | _in_(") {
513            let haystack = &output[..idx];
514            let rest = &output[idx + 8..]; // Skip " | _in_("
515            if let Some(end) = rest.find(')') {
516                let needle = &rest[..end];
517                return format!("({} in {})", needle, haystack);
518            }
519        }
520
521        // Convert _or_(default) marker to proper "or" syntax for Helm compatibility
522        // "value | _or_(default)" → "(value or default)"
523        // Handles chained defaults: "a | _or_(b) | _or_(c)" → "(a or b or c)"
524        while let Some(idx) = output.find(" | _or_(") {
525            let value = &output[..idx];
526            let rest = &output[idx + 8..]; // Skip " | _or_("
527            if let Some(end) = rest.find(')') {
528                let default_val = &rest[..end];
529                let remaining = &rest[end + 1..];
530                output = format!("({} or {}){}", value, default_val, remaining);
531            } else {
532                break;
533            }
534        }
535
536        output
537    }
538
539    fn transform_command(&self, cmd: &Command, as_filter: bool) -> String {
540        match cmd {
541            Command::Field(field) => self.transform_field(field),
542
543            Command::Variable(name) => {
544                // "." or "" = current context
545                if name == "." || name.is_empty() {
546                    if let Some(ref ctx) = self.context_var {
547                        return ctx.clone();
548                    }
549                    return "item".to_string();
550                }
551                // "$" = root context
552                if name == "$" {
553                    return "values".to_string(); // Access root
554                }
555                // "$var" → "var"
556                name.trim_start_matches('$').to_string()
557            }
558
559            Command::Function { name, args } => self.transform_function(name, args, as_filter),
560
561            Command::Parenthesized(pipeline) => {
562                format!("({})", self.transform_pipeline(pipeline))
563            }
564
565            Command::Literal(lit) => self.transform_literal(lit),
566        }
567    }
568
569    /// Transform a function call - the heart of idiomatic conversion
570    fn transform_function(&self, name: &str, args: &[Argument], as_filter: bool) -> String {
571        // 0. Convert random functions to generate_secret()
572        //    randAlphaNum(16) → generate_secret("auto-secret-N", 16)
573        //    randAlpha(16) → generate_secret("auto-secret-N", 16, "alpha")
574        //    randNumeric(6) → generate_secret("auto-secret-N", 6, "numeric")
575        if let Some(charset) = match name {
576            "randAlphaNum" => Some(None),       // Default charset (alphanumeric)
577            "randAlpha" => Some(Some("alpha")), // Alpha only
578            "randNumeric" => Some(Some("numeric")), // Numeric only
579            _ => None,
580        } {
581            if let Some(length_arg) = args.first() {
582                let length = self.transform_argument(length_arg);
583                let secret_name = self.next_secret_name();
584                return match charset {
585                    None => format!(
586                        "generate_secret(\"{}\", {}) {{# RENAME: give meaningful name #}}",
587                        secret_name, length
588                    ),
589                    Some(cs) => format!(
590                        "generate_secret(\"{}\", {}, \"{}\") {{# RENAME: give meaningful name #}}",
591                        secret_name, length, cs
592                    ),
593                };
594            }
595        }
596
597        // 1. Check for unsupported features first
598        if let Some(alternative) = UNSUPPORTED_FEATURES.get(name) {
599            // Return a placeholder with comment
600            return format!(
601                "__UNSUPPORTED_{}__ {{# {} #}}",
602                name.to_uppercase(),
603                alternative
604            );
605        }
606
607        // 2. Native Jinja2 operators (most elegant conversion)
608        if let Some(result) = self.transform_to_native_operator(name, args) {
609            return result;
610        }
611
612        // 3. Special function handling
613        if let Some(result) = self.transform_special_function(name, args) {
614            return result;
615        }
616
617        // 4. Filter transformation
618        if as_filter {
619            return self.transform_as_filter(name, args);
620        }
621
622        // 5. Regular function call
623        self.transform_as_function(name, args)
624    }
625
626    /// Convert to native Jinja2 operators - the most elegant transformations
627    fn transform_to_native_operator(&self, name: &str, args: &[Argument]) -> Option<String> {
628        // Comparison and math operators
629        if let Some(&op) = NATIVE_OPERATORS.get(name) {
630            if args.len() >= 2 {
631                let left = self.transform_argument(&args[0]);
632                let right = self.transform_argument(&args[1]);
633                return Some(format!("({} {} {})", left, op, right));
634            } else if args.len() == 1 {
635                // Single arg - just return it (for truthiness check)
636                return Some(self.transform_argument(&args[0]));
637            }
638        }
639
640        // not(x) → not x
641        if name == "not"
642            && let Some(arg) = args.first()
643        {
644            return Some(format!("not {}", self.transform_argument(arg)));
645        }
646
647        // index(collection, key) → collection[key]
648        if name == "index" && args.len() >= 2 {
649            let base = self.transform_argument(&args[0]);
650            let indices: Vec<String> = args[1..]
651                .iter()
652                .map(|a| format!("[{}]", self.transform_argument(a)))
653                .collect();
654            return Some(format!("{}{}", base, indices.join("")));
655        }
656
657        // printf("%s-%s", a, b) → (a ~ "-" ~ b)
658        if name == "printf" {
659            return Some(self.transform_printf(args));
660        }
661
662        // ternary("yes", "no", condition) → ("yes" if condition else "no")
663        if name == "ternary" && args.len() >= 3 {
664            let yes = self.transform_argument(&args[0]);
665            let no = self.transform_argument(&args[1]);
666            let cond = self.transform_argument(&args[2]);
667            return Some(format!("({} if {} else {})", yes, cond, no));
668        }
669
670        // coalesce(a, b, c) → (a or b or c)
671        if name == "coalesce" && !args.is_empty() {
672            let parts: Vec<String> = args.iter().map(|a| self.transform_argument(a)).collect();
673            return Some(format!("({})", parts.join(" or ")));
674        }
675
676        // contains(needle, haystack) → (needle in haystack)
677        if name == "contains" && args.len() >= 2 {
678            let needle = self.transform_argument(&args[0]);
679            let haystack = self.transform_argument(&args[1]);
680            return Some(format!("({} in {})", needle, haystack));
681        }
682
683        // default(value, default) - Use `or` for Helm compatibility
684        // Helm: default "value" .X → X if X else "value"
685        // Jinja2's `default` only triggers on undefined, not empty strings
686        // Using `or` matches Helm's behavior (falsy values trigger default)
687        if name == "default" && args.len() >= 2 {
688            let default_val = self.transform_argument(&args[0]);
689            let actual_val = self.transform_argument(&args[1]);
690            return Some(format!("({} or {})", actual_val, default_val));
691        }
692
693        // len(x) → x | length
694        if name == "len" && args.len() == 1 {
695            let val = self.transform_argument(&args[0]);
696            return Some(format!("{} | length", val));
697        }
698
699        // list(a, b, c) → [a, b, c]
700        if name == "list" {
701            let items: Vec<String> = args.iter().map(|a| self.transform_argument(a)).collect();
702            return Some(format!("[{}]", items.join(", ")));
703        }
704
705        // dict("k1", v1, "k2", v2) → {"k1": v1, "k2": v2}
706        if name == "dict" {
707            return Some(self.transform_dict(args));
708        }
709
710        // join(sep, list) → list | join(sep)
711        if name == "join" && args.len() >= 2 {
712            let sep = self.transform_argument(&args[0]);
713            let list = self.transform_argument(&args[1]);
714            return Some(format!("{} | join({})", list, sep));
715        }
716
717        // split(sep, str) → str | split(sep)
718        if (name == "split" || name == "splitList") && args.len() >= 2 {
719            let sep = self.transform_argument(&args[0]);
720            let string = self.transform_argument(&args[1]);
721            return Some(format!("{} | split({})", string, sep));
722        }
723
724        // until(n) → range(n)
725        if name == "until" && !args.is_empty() {
726            let n = self.transform_argument(&args[0]);
727            return Some(format!("range({})", n));
728        }
729
730        // untilStep(start, end, step) → range(start, end, step)
731        if name == "untilStep" && args.len() >= 3 {
732            let start = self.transform_argument(&args[0]);
733            let end = self.transform_argument(&args[1]);
734            let step = self.transform_argument(&args[2]);
735            return Some(format!("range({}, {}, {})", start, end, step));
736        }
737
738        // seq(n) → range(1, n+1)
739        if name == "seq" && !args.is_empty() {
740            let n = self.transform_argument(&args[0]);
741            return Some(format!("range(1, {} + 1)", n));
742        }
743
744        // max/min with multiple args
745        if name == "max" && args.len() >= 2 {
746            let vals: Vec<String> = args.iter().map(|a| self.transform_argument(a)).collect();
747            return Some(format!("[{}] | max", vals.join(", ")));
748        }
749        if name == "min" && args.len() >= 2 {
750            let vals: Vec<String> = args.iter().map(|a| self.transform_argument(a)).collect();
751            return Some(format!("[{}] | min", vals.join(", ")));
752        }
753
754        // merge(a, b) → a | merge(b)
755        if name == "merge" && args.len() >= 2 {
756            let base = self.transform_argument(&args[0]);
757            let overlay = self.transform_argument(&args[1]);
758            return Some(format!("({} | merge({}))", base, overlay));
759        }
760
761        // semverCompare(constraint, version) → version | semverCompare(constraint)
762        // Note: semverCompare is a Sherpack function that we need to implement
763        if name == "semverCompare" && args.len() >= 2 {
764            let constraint = self.transform_argument(&args[0]);
765            let version = self.transform_argument(&args[1]);
766            return Some(format!("({} | semver_match({}))", version, constraint));
767        }
768
769        // Type conversion functions → filters
770        // int(x) → (x | int), int64(x) → (x | int), float64(x) → (x | float)
771        if (name == "int" || name == "int64") && args.len() == 1 {
772            let val = self.transform_argument(&args[0]);
773            return Some(format!("({} | int)", val));
774        }
775        if name == "float64" && args.len() == 1 {
776            let val = self.transform_argument(&args[0]);
777            return Some(format!("({} | float)", val));
778        }
779
780        None
781    }
782
783    /// Handle special functions that need custom transformation
784    fn transform_special_function(&self, name: &str, args: &[Argument]) -> Option<String> {
785        // include("name", context) → name()
786        if name == "include" || name == "template" {
787            return Some(self.transform_include(args));
788        }
789
790        // toYaml/toJson as functions (not filters)
791        if name == "toYaml" && args.len() == 1 {
792            let val = self.transform_argument(&args[0]);
793            return Some(format!("{} | toyaml", val));
794        }
795        if name == "toJson" && args.len() == 1 {
796            let val = self.transform_argument(&args[0]);
797            return Some(format!("{} | tojson", val));
798        }
799
800        // tpl(template, context) → tpl(template)
801        if name == "tpl" && !args.is_empty() {
802            let template = self.transform_argument(&args[0]);
803            return Some(format!("tpl({})", template));
804        }
805
806        // lookup returns {} - add warning
807        if name == "lookup" {
808            // Lookup returns empty dict in template mode (GitOps compatible)
809            return Some("{}".to_string());
810        }
811
812        // print(x) → x (Go's print just outputs the value)
813        if name == "print" && args.len() == 1 {
814            return Some(self.transform_argument(&args[0]));
815        }
816
817        // now() → now
818        if name == "now" {
819            return Some("now()".to_string());
820        }
821
822        // uuidv4() → uuidv4()
823        if name == "uuidv4" {
824            return Some("uuidv4()".to_string());
825        }
826
827        // fail(msg) → fail(msg)
828        if name == "fail" && !args.is_empty() {
829            let msg = self.transform_argument(&args[0]);
830            return Some(format!("fail({})", msg));
831        }
832
833        // get(dict, key) → dict[key] | default(none)
834        if name == "get" && args.len() >= 2 {
835            let dict = self.transform_argument(&args[0]);
836            let key = self.transform_argument(&args[1]);
837            return Some(format!("{}[{}]", dict, key));
838        }
839
840        // hasKey(dict, key) → key in dict
841        if name == "hasKey" && args.len() >= 2 {
842            let dict = self.transform_argument(&args[0]);
843            let key = self.transform_argument(&args[1]);
844            return Some(format!("({} in {})", key, dict));
845        }
846
847        // dig("a", "b", "c", default, dict) → dict.a.b.c | default(default)
848        if name == "dig" && args.len() >= 2 {
849            // Last arg is the dict, second-to-last is default
850            let dict = self.transform_argument(&args[args.len() - 1]);
851            let default = if args.len() >= 3 {
852                self.transform_argument(&args[args.len() - 2])
853            } else {
854                "none".to_string()
855            };
856            let keys: Vec<String> = args[..args.len().saturating_sub(2)]
857                .iter()
858                .filter_map(|a| {
859                    if let Argument::Literal(Literal::String(s)) = a {
860                        Some(s.clone())
861                    } else {
862                        None
863                    }
864                })
865                .collect();
866            if keys.is_empty() {
867                return Some(format!("{} | default({})", dict, default));
868            }
869            return Some(format!(
870                "{}.{} | default({})",
871                dict,
872                keys.join("."),
873                default
874            ));
875        }
876
877        // empty(x) - check if value is empty
878        if name == "empty" && args.len() == 1 {
879            let val = self.transform_argument(&args[0]);
880            return Some(format!("{} | empty", val));
881        }
882
883        None
884    }
885
886    /// Transform printf to string concatenation
887    fn transform_printf(&self, args: &[Argument]) -> String {
888        if args.is_empty() {
889            return "\"\"".to_string();
890        }
891
892        // Get format string
893        let format_str = match &args[0] {
894            Argument::Literal(Literal::String(s)) => s.clone(),
895            _ => return self.transform_argument(&args[0]),
896        };
897
898        // Simple case: no format specifiers
899        if !format_str.contains('%') {
900            return format!("\"{}\"", format_str);
901        }
902
903        // Get format arguments
904        let format_args: Vec<String> = args[1..]
905            .iter()
906            .map(|a| self.transform_argument(a))
907            .collect();
908
909        // Split by %s, %d, %v, etc. and rebuild with ~
910        let mut result = String::new();
911        let mut arg_idx = 0;
912        let mut chars = format_str.chars().peekable();
913        let mut current_literal = String::new();
914
915        while let Some(c) = chars.next() {
916            if c == '%' {
917                if let Some(&next) = chars.peek() {
918                    match next {
919                        's' | 'd' | 'v' | 'f' | 'g' | 't' => {
920                            chars.next(); // consume the format char
921
922                            // Add accumulated literal
923                            if !current_literal.is_empty() {
924                                if !result.is_empty() {
925                                    result.push_str(" ~ ");
926                                }
927                                result.push_str(&format!("\"{}\"", current_literal));
928                                current_literal.clear();
929                            }
930
931                            // Add argument
932                            if arg_idx < format_args.len() {
933                                if !result.is_empty() {
934                                    result.push_str(" ~ ");
935                                }
936                                result.push_str(&format_args[arg_idx]);
937                                arg_idx += 1;
938                            }
939                        }
940                        '%' => {
941                            chars.next();
942                            current_literal.push('%');
943                        }
944                        _ => {
945                            current_literal.push(c);
946                        }
947                    }
948                } else {
949                    current_literal.push(c);
950                }
951            } else {
952                current_literal.push(c);
953            }
954        }
955
956        // Add remaining literal
957        if !current_literal.is_empty() {
958            if !result.is_empty() {
959                result.push_str(" ~ ");
960            }
961            result.push_str(&format!("\"{}\"", current_literal));
962        }
963
964        if result.is_empty() {
965            "\"\"".to_string()
966        } else {
967            format!("({})", result)
968        }
969    }
970
971    /// Transform dict("k1", v1, "k2", v2) to {"k1": v1, "k2": v2}
972    fn transform_dict(&self, args: &[Argument]) -> String {
973        let mut pairs = Vec::new();
974        let mut i = 0;
975
976        while i + 1 < args.len() {
977            let key = self.transform_argument(&args[i]);
978            let value = self.transform_argument(&args[i + 1]);
979            pairs.push(format!("{}: {}", key, value));
980            i += 2;
981        }
982
983        format!("{{{}}}", pairs.join(", "))
984    }
985
986    /// Transform as a Jinja2 filter
987    fn transform_as_filter(&self, name: &str, args: &[Argument]) -> String {
988        // Special case: contains as filter (piped)
989        if name == "contains"
990            && let Some(arg) = args.first()
991        {
992            let needle = self.transform_argument(arg);
993            return format!("_in_({})", needle);
994        }
995
996        // Special case: default filter uses `or` for Helm compatibility
997        // {{ .X | default "value" }} → (x or "value")
998        // Returns a marker that transform_pipeline will handle
999        if name == "default" {
1000            if let Some(arg) = args.first() {
1001                let default_val = self.transform_argument(arg);
1002                return format!("_or_({})", default_val);
1003            }
1004        }
1005
1006        // Look up filter name mapping
1007        let filter_name = FILTER_MAP.get(name).copied().unwrap_or(name);
1008
1009        if args.is_empty() {
1010            filter_name.to_string()
1011        } else {
1012            let args_str: Vec<String> = args.iter().map(|a| self.transform_argument(a)).collect();
1013            format!("{}({})", filter_name, args_str.join(", "))
1014        }
1015    }
1016
1017    /// Transform as a function call
1018    fn transform_as_function(&self, name: &str, args: &[Argument]) -> String {
1019        let args_str: Vec<String> = args.iter().map(|a| self.transform_argument(a)).collect();
1020        format!("{}({})", name, args_str.join(", "))
1021    }
1022
1023    fn transform_argument(&self, arg: &Argument) -> String {
1024        match arg {
1025            Argument::Field(field) => self.transform_field(field),
1026            Argument::Variable(name) => {
1027                if name == "." || name.is_empty() {
1028                    if let Some(ref ctx) = self.context_var {
1029                        return ctx.clone();
1030                    }
1031                    return "item".to_string();
1032                }
1033                if name == "$" {
1034                    return "values".to_string();
1035                }
1036                name.trim_start_matches('$').to_string()
1037            }
1038            Argument::Literal(lit) => self.transform_literal(lit),
1039            Argument::Pipeline(pipeline) => {
1040                format!("({})", self.transform_pipeline(pipeline))
1041            }
1042        }
1043    }
1044
1045    fn transform_field(&self, field: &FieldAccess) -> String {
1046        // Handle root marker ($)
1047        let is_root = field.is_root;
1048
1049        if field.path.is_empty() {
1050            // Just "." - refers to current context
1051            if let Some(ref ctx) = self.context_var {
1052                return ctx.clone();
1053            }
1054            return "item".to_string();
1055        }
1056
1057        let first = field.path[0].as_str();
1058        let rest: Vec<&str> = field.path[1..].iter().map(|s| s.as_str()).collect();
1059
1060        // Note: is_root is currently unused but kept for future root context handling
1061        let _ = is_root;
1062        let prefix = "";
1063
1064        match first {
1065            "Values" => {
1066                if rest.is_empty() {
1067                    format!("{}values", prefix)
1068                } else {
1069                    format!("{}values.{}", prefix, rest.join("."))
1070                }
1071            }
1072            "Release" => {
1073                let prop = rest.first().copied().unwrap_or("");
1074                match prop {
1075                    "Name" => format!("{}release.name", prefix),
1076                    "Namespace" => format!("{}release.namespace", prefix),
1077                    "Service" => "\"Sherpack\"".to_string(),
1078                    "IsInstall" => format!("{}release.is_install", prefix),
1079                    "IsUpgrade" => format!("{}release.is_upgrade", prefix),
1080                    "Revision" => format!("{}release.revision", prefix),
1081                    _ if prop.is_empty() => format!("{}release", prefix),
1082                    _ => format!("{}release.{}", prefix, to_snake_case(prop)),
1083                }
1084            }
1085            "Chart" => {
1086                let prop = rest.first().copied().unwrap_or("");
1087                match prop {
1088                    "Name" => format!("{}pack.name", prefix),
1089                    "Version" => format!("{}pack.version", prefix),
1090                    "AppVersion" => format!("{}pack.appVersion", prefix),
1091                    "Description" => format!("{}pack.description", prefix),
1092                    _ if prop.is_empty() => format!("{}pack", prefix),
1093                    _ => format!("{}pack.{}", prefix, to_snake_case(prop)),
1094                }
1095            }
1096            "Capabilities" => {
1097                let prop = rest.first().copied().unwrap_or("");
1098                match prop {
1099                    "KubeVersion" => {
1100                        if rest.len() > 1 {
1101                            let sub = rest[1];
1102                            match sub {
1103                                "Version" | "GitVersion" => {
1104                                    format!("{}capabilities.kubeVersion.version", prefix)
1105                                }
1106                                "Major" => format!("{}capabilities.kubeVersion.major", prefix),
1107                                "Minor" => format!("{}capabilities.kubeVersion.minor", prefix),
1108                                _ => format!(
1109                                    "{}capabilities.kubeVersion.{}",
1110                                    prefix,
1111                                    to_snake_case(sub)
1112                                ),
1113                            }
1114                        } else {
1115                            format!("{}capabilities.kubeVersion", prefix)
1116                        }
1117                    }
1118                    "APIVersions" => format!("{}capabilities.apiVersions", prefix),
1119                    _ if prop.is_empty() => format!("{}capabilities", prefix),
1120                    _ => format!("{}capabilities.{}", prefix, to_snake_case(prop)),
1121                }
1122            }
1123            "Template" => {
1124                let prop = rest.first().copied().unwrap_or("");
1125                match prop {
1126                    "Name" => format!("{}template.name", prefix),
1127                    "BasePath" => format!("{}template.basePath", prefix),
1128                    _ if prop.is_empty() => format!("{}template", prefix),
1129                    _ => format!("{}template.{}", prefix, prop),
1130                }
1131            }
1132            "Files" => {
1133                // Files access is unsupported - will be caught by function handling
1134                let full_path = std::iter::once(first)
1135                    .chain(rest.iter().copied())
1136                    .collect::<Vec<_>>()
1137                    .join(".");
1138                format!("__UNSUPPORTED_FILES__ {{# {} #}}", full_path)
1139            }
1140            _ => {
1141                // Generic field access - could be inside a range/with block
1142                let full_path = std::iter::once(first)
1143                    .chain(rest.iter().copied())
1144                    .collect::<Vec<_>>()
1145                    .join(".");
1146
1147                // If we're in a with context, prefix with context var
1148                if let Some(ref ctx) = self.context_var {
1149                    format!("{}.{}", ctx, full_path)
1150                } else {
1151                    full_path
1152                }
1153            }
1154        }
1155    }
1156
1157    fn transform_literal(&self, lit: &Literal) -> String {
1158        match lit {
1159            Literal::String(s) => format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")),
1160            Literal::Char(c) => format!("\"{}\"", c),
1161            Literal::Int(n) => n.to_string(),
1162            Literal::Float(n) => n.to_string(),
1163            Literal::Bool(b) => if *b { "true" } else { "false" }.to_string(),
1164            Literal::Nil => "none".to_string(),
1165        }
1166    }
1167
1168    fn transform_include(&self, args: &[Argument]) -> String {
1169        if args.is_empty() {
1170            return "MISSING_INCLUDE_NAME()".to_string();
1171        }
1172
1173        // First arg is the template name
1174        let name = match &args[0] {
1175            Argument::Literal(Literal::String(s)) => self.strip_chart_prefix(s),
1176            _ => "DYNAMIC_INCLUDE".to_string(),
1177        };
1178
1179        format!("{}()", name)
1180    }
1181
1182    fn strip_chart_prefix(&self, name: &str) -> String {
1183        let stripped = if let Some(ref prefix) = self.chart_prefix {
1184            name.strip_prefix(prefix.as_str()).unwrap_or(name)
1185        } else {
1186            name
1187        };
1188
1189        // Convert dots to underscores for valid Jinja2 macro names
1190        stripped.trim_matches('"').replace(['.', '-'], "_")
1191    }
1192
1193    /// Determines if a collection path refers to a dictionary type
1194    ///
1195    /// Uses type context from values.yaml if available, otherwise falls back
1196    /// to heuristics based on common Helm naming patterns.
1197    fn is_dict_type(&self, collection: &str) -> bool {
1198        // First, try the type context if available
1199        if let Some(ref ctx) = self.type_context {
1200            match ctx.get_type(collection) {
1201                InferredType::Dict => return true,
1202                InferredType::List => return false,
1203                InferredType::Scalar => return false,
1204                InferredType::Unknown => {
1205                    // Fall through to heuristics
1206                }
1207            }
1208        }
1209
1210        // Fall back to heuristics based on common naming patterns
1211        TypeHeuristics::guess_type(collection)
1212            .map(|t| t == InferredType::Dict)
1213            .unwrap_or(false)
1214    }
1215}
1216
1217/// Convert PascalCase/camelCase to snake_case
1218fn to_snake_case(s: &str) -> String {
1219    let mut result = String::new();
1220    for (i, c) in s.chars().enumerate() {
1221        if c.is_uppercase() {
1222            if i > 0 {
1223                result.push('_');
1224            }
1225            result.push(c.to_lowercase().next().unwrap());
1226        } else {
1227            result.push(c);
1228        }
1229    }
1230    result
1231}
1232
1233// =============================================================================
1234// TESTS
1235// =============================================================================
1236
1237#[cfg(test)]
1238mod tests {
1239    use super::*;
1240    use crate::parser;
1241
1242    fn transform(input: &str) -> String {
1243        let ast = parser::parse(input).expect("Failed to parse");
1244        let mut transformer = Transformer::new();
1245        transformer.transform(&ast)
1246    }
1247
1248    fn transform_with_prefix(input: &str, prefix: &str) -> String {
1249        let ast = parser::parse(input).expect("Failed to parse");
1250        let mut transformer = Transformer::new().with_chart_prefix(prefix);
1251        transformer.transform(&ast)
1252    }
1253
1254    // =========================================================================
1255    // Basic syntax
1256    // =========================================================================
1257
1258    #[test]
1259    fn test_raw_text() {
1260        assert_eq!(transform("hello world"), "hello world");
1261    }
1262
1263    #[test]
1264    fn test_comment() {
1265        assert_eq!(
1266            transform("{{/* This is a comment */}}"),
1267            "{# This is a comment #}"
1268        );
1269    }
1270
1271    #[test]
1272    fn test_simple_variable() {
1273        assert_eq!(transform("{{ .Values.name }}"), "{{ values.name }}");
1274    }
1275
1276    #[test]
1277    fn test_trim_whitespace() {
1278        assert_eq!(transform("{{- .Values.name -}}"), "{{- values.name -}}");
1279    }
1280
1281    // =========================================================================
1282    // Native operators (idiomatic Jinja2)
1283    // =========================================================================
1284
1285    #[test]
1286    fn test_comparison_eq() {
1287        assert_eq!(
1288            transform("{{ eq .Values.a .Values.b }}"),
1289            "{{ (values.a == values.b) }}"
1290        );
1291    }
1292
1293    #[test]
1294    fn test_comparison_ne() {
1295        assert_eq!(
1296            transform("{{ ne .Values.a \"test\" }}"),
1297            "{{ (values.a != \"test\") }}"
1298        );
1299    }
1300
1301    #[test]
1302    fn test_math_add() {
1303        assert_eq!(transform("{{ add 1 2 }}"), "{{ (1 + 2) }}");
1304    }
1305
1306    #[test]
1307    fn test_math_operations() {
1308        assert_eq!(transform("{{ sub 10 5 }}"), "{{ (10 - 5) }}");
1309        assert_eq!(transform("{{ mul 3 4 }}"), "{{ (3 * 4) }}");
1310        assert_eq!(transform("{{ div 10 2 }}"), "{{ (10 / 2) }}");
1311        assert_eq!(transform("{{ mod 10 3 }}"), "{{ (10 % 3) }}");
1312    }
1313
1314    #[test]
1315    fn test_ternary() {
1316        assert_eq!(
1317            transform("{{ ternary \"yes\" \"no\" .Values.enabled }}"),
1318            "{{ (\"yes\" if values.enabled else \"no\") }}"
1319        );
1320    }
1321
1322    #[test]
1323    fn test_coalesce() {
1324        assert_eq!(
1325            transform("{{ coalesce .Values.a .Values.b \"default\" }}"),
1326            "{{ (values.a or values.b or \"default\") }}"
1327        );
1328    }
1329
1330    #[test]
1331    fn test_default_function() {
1332        // default as function: default "fallback" .X → (X or "fallback")
1333        assert_eq!(
1334            transform("{{ default \"fallback\" .Values.x }}"),
1335            "{{ (values.x or \"fallback\") }}"
1336        );
1337    }
1338
1339    #[test]
1340    fn test_default_filter() {
1341        // default as filter: .X | default "fallback" → (X or "fallback")
1342        assert_eq!(
1343            transform("{{ .Values.x | default \"fallback\" }}"),
1344            "{{ (values.x or \"fallback\") }}"
1345        );
1346    }
1347
1348    #[test]
1349    fn test_default_chained() {
1350        // Chained defaults: .a | default .b | default "c"
1351        assert_eq!(
1352            transform("{{ .Values.a | default .Values.b | default \"c\" }}"),
1353            "{{ ((values.a or values.b) or \"c\") }}"
1354        );
1355    }
1356
1357    #[test]
1358    fn test_index_list() {
1359        assert_eq!(
1360            transform("{{ index .Values.list 0 }}"),
1361            "{{ values.list[0] }}"
1362        );
1363    }
1364
1365    #[test]
1366    fn test_index_map() {
1367        assert_eq!(
1368            transform("{{ index .Values.map \"key\" }}"),
1369            "{{ values.map[\"key\"] }}"
1370        );
1371    }
1372
1373    #[test]
1374    fn test_index_nested() {
1375        assert_eq!(
1376            transform("{{ index .Values.data \"a\" \"b\" }}"),
1377            "{{ values.data[\"a\"][\"b\"] }}"
1378        );
1379    }
1380
1381    // =========================================================================
1382    // Printf → string concatenation
1383    // =========================================================================
1384
1385    #[test]
1386    fn test_printf_simple() {
1387        assert_eq!(
1388            transform("{{ printf \"%s-%s\" .Release.Name .Chart.Name }}"),
1389            "{{ (release.name ~ \"-\" ~ pack.name) }}"
1390        );
1391    }
1392
1393    #[test]
1394    fn test_printf_complex() {
1395        assert_eq!(
1396            transform("{{ printf \"prefix-%s-suffix\" .Values.name }}"),
1397            "{{ (\"prefix-\" ~ values.name ~ \"-suffix\") }}"
1398        );
1399    }
1400
1401    // =========================================================================
1402    // Contains → in operator
1403    // =========================================================================
1404
1405    #[test]
1406    fn test_contains_function() {
1407        assert_eq!(
1408            transform("{{ contains \"needle\" .Values.haystack }}"),
1409            "{{ (\"needle\" in values.haystack) }}"
1410        );
1411    }
1412
1413    #[test]
1414    fn test_contains_in_if() {
1415        assert_eq!(
1416            transform("{{- if contains $name .Release.Name }}yes{{- end }}"),
1417            "{%- if (name in release.name) %}yes{%- endif %}"
1418        );
1419    }
1420
1421    // =========================================================================
1422    // Control structures
1423    // =========================================================================
1424
1425    #[test]
1426    fn test_if_else() {
1427        assert_eq!(
1428            transform("{{- if .Values.x }}yes{{- else }}no{{- end }}"),
1429            "{%- if values.x %}yes{%- else %}no{%- endif %}"
1430        );
1431    }
1432
1433    #[test]
1434    fn test_if_elif() {
1435        assert_eq!(
1436            transform("{{- if .Values.a }}a{{- else if .Values.b }}b{{- end }}"),
1437            "{%- if values.a %}a{%- elif values.b %}b{%- endif %}"
1438        );
1439    }
1440
1441    #[test]
1442    fn test_range() {
1443        assert_eq!(
1444            transform("{{- range .Values.items }}{{ . }}{{- end }}"),
1445            "{%- for item in values.items %}{{ item }}{%- endfor %}"
1446        );
1447    }
1448
1449    #[test]
1450    fn test_range_with_variable() {
1451        assert_eq!(
1452            transform("{{- range $item := .Values.list }}{{ $item }}{{- end }}"),
1453            "{%- for item in values.list %}{{ item }}{%- endfor %}"
1454        );
1455    }
1456
1457    // =========================================================================
1458    // Variable declarations
1459    // =========================================================================
1460
1461    #[test]
1462    fn test_variable_declaration() {
1463        assert_eq!(
1464            transform("{{- $name := .Values.name }}{{ $name }}"),
1465            "{%- set name = values.name %}{{ name }}"
1466        );
1467    }
1468
1469    // =========================================================================
1470    // Include/define
1471    // =========================================================================
1472
1473    #[test]
1474    fn test_define() {
1475        assert_eq!(
1476            transform("{{- define \"myapp.name\" }}test{{- end }}"),
1477            "{%- macro myapp_name() %}test{%- endmacro %}"
1478        );
1479    }
1480
1481    #[test]
1482    fn test_include() {
1483        assert_eq!(
1484            transform_with_prefix("{{ include \"myapp.fullname\" . }}", "myapp"),
1485            "{{ fullname() }}"
1486        );
1487    }
1488
1489    // =========================================================================
1490    // Filters
1491    // =========================================================================
1492
1493    #[test]
1494    fn test_filter_pipeline() {
1495        assert_eq!(
1496            transform("{{ .Values.name | quote }}"),
1497            "{{ values.name | quote }}"
1498        );
1499    }
1500
1501    #[test]
1502    fn test_filter_with_arg() {
1503        assert_eq!(
1504            transform("{{ .Values.text | indent 4 }}"),
1505            "{{ values.text | indent(4) }}"
1506        );
1507    }
1508
1509    #[test]
1510    fn test_filter_chain() {
1511        assert_eq!(
1512            transform("{{ .Values.config | toYaml | nindent 4 }}"),
1513            "{{ values.config | toyaml | nindent(4) }}"
1514        );
1515    }
1516
1517    // =========================================================================
1518    // List/Dict functions
1519    // =========================================================================
1520
1521    #[test]
1522    fn test_list() {
1523        assert_eq!(transform("{{ list 1 2 3 }}"), "{{ [1, 2, 3] }}");
1524    }
1525
1526    #[test]
1527    fn test_dict() {
1528        assert_eq!(
1529            transform("{{ dict \"key1\" .Values.a \"key2\" .Values.b }}"),
1530            "{{ {\"key1\": values.a, \"key2\": values.b} }}"
1531        );
1532    }
1533
1534    // =========================================================================
1535    // Range generation
1536    // =========================================================================
1537
1538    #[test]
1539    fn test_until() {
1540        assert_eq!(transform("{{ until 5 }}"), "{{ range(5) }}");
1541    }
1542
1543    #[test]
1544    fn test_until_step() {
1545        assert_eq!(transform("{{ untilStep 0 10 2 }}"), "{{ range(0, 10, 2) }}");
1546    }
1547
1548    // =========================================================================
1549    // Field mappings
1550    // =========================================================================
1551
1552    #[test]
1553    fn test_release_service() {
1554        assert_eq!(transform("{{ .Release.Service }}"), "{{ \"Sherpack\" }}");
1555    }
1556
1557    #[test]
1558    fn test_chart_appversion() {
1559        assert_eq!(
1560            transform("{{ .Chart.AppVersion }}"),
1561            "{{ pack.appVersion }}"
1562        );
1563    }
1564
1565    #[test]
1566    fn test_capabilities() {
1567        assert_eq!(
1568            transform("{{ .Capabilities.KubeVersion.Version }}"),
1569            "{{ capabilities.kubeVersion.version }}"
1570        );
1571    }
1572
1573    // =========================================================================
1574    // Dictionary iteration with TypeContext
1575    // =========================================================================
1576
1577    #[test]
1578    fn test_range_dict_with_type_context() {
1579        use crate::type_inference::TypeContext;
1580
1581        let yaml = r#"
1582controller:
1583  containerPort:
1584    http: 80
1585    https: 443
1586"#;
1587        let ctx = TypeContext::from_yaml(yaml).unwrap();
1588        let mut transformer = Transformer::new().with_type_context(ctx);
1589
1590        let input = crate::parser::parse("{{- range $key, $value := .Values.controller.containerPort }}{{ $key }}: {{ $value }}{{- end }}").unwrap();
1591        let result = transformer.transform(&input);
1592
1593        assert_eq!(
1594            result,
1595            "{%- for key, value in values.controller.containerPort | dictsort %}{{ key }}: {{ value }}{%- endfor %}"
1596        );
1597    }
1598
1599    #[test]
1600    fn test_range_list_with_type_context() {
1601        use crate::type_inference::TypeContext;
1602
1603        let yaml = r#"
1604controller:
1605  extraEnvs:
1606    - name: FOO
1607      value: bar
1608"#;
1609        let ctx = TypeContext::from_yaml(yaml).unwrap();
1610        let mut transformer = Transformer::new().with_type_context(ctx);
1611
1612        let input = crate::parser::parse(
1613            "{{- range $i, $env := .Values.controller.extraEnvs }}{{ $env }}{{- end }}",
1614        )
1615        .unwrap();
1616        let result = transformer.transform(&input);
1617
1618        // List iteration should NOT use dictsort
1619        assert_eq!(
1620            result,
1621            "{%- for env in values.controller.extraEnvs %}{#- i = loop.index0 #}{{ env }}{%- endfor %}"
1622        );
1623    }
1624
1625    #[test]
1626    fn test_range_dict_heuristic() {
1627        // Without type context, should use heuristics
1628        let mut transformer = Transformer::new();
1629
1630        // "containerPort" is in DICT_SUFFIXES
1631        let input = crate::parser::parse(
1632            "{{- range $key, $value := .Values.controller.containerPort }}{{ $key }}{{- end }}",
1633        )
1634        .unwrap();
1635        let result = transformer.transform(&input);
1636
1637        assert_eq!(
1638            result,
1639            "{%- for key, value in values.controller.containerPort | dictsort %}{{ key }}{%- endfor %}"
1640        );
1641    }
1642
1643    #[test]
1644    fn test_range_annotations_heuristic() {
1645        let mut transformer = Transformer::new();
1646
1647        // "annotations" is in DICT_SUFFIXES
1648        let input = crate::parser::parse("{{- range $k, $v := .Values.podAnnotations }}{{- end }}")
1649            .unwrap();
1650        let result = transformer.transform(&input);
1651
1652        assert_eq!(
1653            result,
1654            "{%- for k, v in values.podAnnotations | dictsort %}{%- endfor %}"
1655        );
1656    }
1657}