sherpack_convert/
type_inference.rs

1#![allow(clippy::collapsible_if)]
2#![allow(clippy::collapsible_match)]
3#![allow(clippy::or_fun_call)]
4
5//! Type inference from values.yaml
6//!
7//! Analyzes the structure of values.yaml to infer types for template variables.
8//! This enables smarter conversion of Go templates to Jinja2, particularly for
9//! distinguishing between list and dictionary iteration.
10//!
11//! # Example
12//!
13//! ```rust
14//! use sherpack_convert::{TypeContext, InferredType};
15//!
16//! let yaml = r#"
17//! controller:
18//!   containerPort:
19//!     http: 80
20//!     https: 443
21//!   replicas: 3
22//!   labels:
23//!     - app
24//!     - version
25//! "#;
26//!
27//! let ctx = TypeContext::from_yaml(yaml).unwrap();
28//! assert_eq!(ctx.get_type("controller.containerPort"), InferredType::Dict);
29//! assert_eq!(ctx.get_type("controller.replicas"), InferredType::Scalar);
30//! assert_eq!(ctx.get_type("controller.labels"), InferredType::List);
31//! ```
32
33use serde_yaml::Value;
34use std::collections::HashMap;
35
36// =============================================================================
37// INFERRED TYPES
38// =============================================================================
39
40/// Types that can be inferred from values.yaml structure
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
42pub enum InferredType {
43    /// Scalar value: string, number, boolean, or null
44    Scalar,
45    /// Sequence/Array: `[]`
46    List,
47    /// Mapping/Dictionary: `{}`
48    Dict,
49    /// Type could not be determined (path not found or ambiguous)
50    Unknown,
51}
52
53impl InferredType {
54    /// Returns true if this type represents a collection (List or Dict)
55    #[inline]
56    pub fn is_collection(&self) -> bool {
57        matches!(self, Self::List | Self::Dict)
58    }
59
60    /// Returns true if this is a dictionary type
61    #[inline]
62    pub fn is_dict(&self) -> bool {
63        matches!(self, Self::Dict)
64    }
65
66    /// Returns true if this is a list type
67    #[inline]
68    pub fn is_list(&self) -> bool {
69        matches!(self, Self::List)
70    }
71}
72
73impl std::fmt::Display for InferredType {
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        match self {
76            Self::Scalar => write!(f, "scalar"),
77            Self::List => write!(f, "list"),
78            Self::Dict => write!(f, "dict"),
79            Self::Unknown => write!(f, "unknown"),
80        }
81    }
82}
83
84// =============================================================================
85// TYPE CONTEXT
86// =============================================================================
87
88/// Context holding inferred types for all paths in values.yaml
89///
90/// Built by traversing the values.yaml structure and recording the type
91/// of each path. Used by the transformer to make smarter conversion decisions.
92#[derive(Debug, Default, Clone)]
93pub struct TypeContext {
94    /// Map of dot-separated paths to their inferred types
95    /// e.g., "controller.containerPort" -> Dict
96    types: HashMap<String, InferredType>,
97}
98
99impl TypeContext {
100    /// Creates an empty type context
101    pub fn new() -> Self {
102        Self::default()
103    }
104
105    /// Builds a type context from a YAML string
106    ///
107    /// # Errors
108    ///
109    /// Returns an error if the YAML cannot be parsed
110    pub fn from_yaml(yaml: &str) -> Result<Self, serde_yaml::Error> {
111        let value: Value = serde_yaml::from_str(yaml)?;
112        Ok(Self::from_value(&value))
113    }
114
115    /// Builds a type context from a parsed YAML value
116    pub fn from_value(value: &Value) -> Self {
117        let mut ctx = Self::new();
118        ctx.collect_types_recursive("", value);
119        ctx
120    }
121
122    /// Recursively collects types from the YAML structure
123    fn collect_types_recursive(&mut self, prefix: &str, value: &Value) {
124        match value {
125            Value::Mapping(map) => {
126                // This node is a dictionary
127                if !prefix.is_empty() {
128                    self.types.insert(prefix.to_string(), InferredType::Dict);
129                }
130
131                // Recurse into children
132                for (key, child) in map {
133                    if let Some(key_str) = key.as_str() {
134                        let child_path = if prefix.is_empty() {
135                            key_str.to_string()
136                        } else {
137                            format!("{}.{}", prefix, key_str)
138                        };
139                        self.collect_types_recursive(&child_path, child);
140                    }
141                }
142            }
143            Value::Sequence(seq) => {
144                // This node is a list
145                if !prefix.is_empty() {
146                    self.types.insert(prefix.to_string(), InferredType::List);
147                }
148
149                // Optionally analyze list item structure for nested types
150                // For now, we don't recurse into list items as they may be heterogeneous
151                if let Some(first) = seq.first() {
152                    if let Value::Mapping(_) = first {
153                        // List of objects - could extract common structure
154                        // but for now just mark as List
155                    }
156                }
157            }
158            _ => {
159                // Scalar value (string, number, bool, null)
160                if !prefix.is_empty() {
161                    self.types.insert(prefix.to_string(), InferredType::Scalar);
162                }
163            }
164        }
165    }
166
167    /// Gets the inferred type for a path
168    ///
169    /// The path can be in various formats:
170    /// - `"controller.containerPort"` (plain)
171    /// - `"values.controller.containerPort"` (with values prefix)
172    /// - `".Values.controller.containerPort"` (Go template style)
173    ///
174    /// All formats are normalized before lookup.
175    pub fn get_type(&self, path: &str) -> InferredType {
176        let normalized = Self::normalize_path(path);
177        self.types
178            .get(&normalized)
179            .copied()
180            .unwrap_or(InferredType::Unknown)
181    }
182
183    /// Checks if a path exists in the context
184    pub fn contains(&self, path: &str) -> bool {
185        let normalized = Self::normalize_path(path);
186        self.types.contains_key(&normalized)
187    }
188
189    /// Returns all known paths and their types
190    pub fn all_types(&self) -> impl Iterator<Item = (&str, InferredType)> {
191        self.types.iter().map(|(k, v)| (k.as_str(), *v))
192    }
193
194    /// Returns the number of paths in the context
195    pub fn len(&self) -> usize {
196        self.types.len()
197    }
198
199    /// Returns true if no types have been collected
200    pub fn is_empty(&self) -> bool {
201        self.types.is_empty()
202    }
203
204    /// Normalizes a path by removing common prefixes
205    ///
206    /// - `.Values.x.y` -> `x.y`
207    /// - `values.x.y` -> `x.y`
208    /// - `x.y` -> `x.y`
209    fn normalize_path(path: &str) -> String {
210        let path = path.trim();
211
212        // Remove leading dot
213        let path = path.strip_prefix('.').unwrap_or(path);
214
215        // Remove "Values." or "values." prefix
216        let path = path
217            .strip_prefix("Values.")
218            .or_else(|| path.strip_prefix("values."))
219            .unwrap_or(path);
220
221        path.to_string()
222    }
223}
224
225// =============================================================================
226// HEURISTICS FOR UNKNOWN TYPES
227// =============================================================================
228
229/// Heuristics for guessing types when not found in values.yaml
230///
231/// These are patterns commonly seen in Helm charts that strongly suggest
232/// a particular type.
233pub struct TypeHeuristics;
234
235impl TypeHeuristics {
236    /// Common suffixes that indicate a dictionary type
237    const DICT_SUFFIXES: &'static [&'static str] = &[
238        "annotations",
239        "labels",
240        "selector",
241        "matchLabels",
242        "nodeSelector",
243        "config",
244        "configMap",
245        "data",
246        "stringData",
247        "env",
248        "ports",
249        "containerPort",
250        "hostPort",
251        "resources",
252        "limits",
253        "requests",
254        "securityContext",
255        "podSecurityContext",
256        "affinity",
257        "tolerations",
258        "headers",
259        "proxyHeaders",
260        "extraArgs",
261    ];
262
263    /// Common suffixes that indicate a list type
264    const LIST_SUFFIXES: &'static [&'static str] = &[
265        "items",
266        "containers",
267        "initContainers",
268        "volumes",
269        "volumeMounts",
270        "envFrom",
271        "imagePullSecrets",
272        "hosts",
273        "rules",
274        "paths",
275        "tls",
276        "extraVolumes",
277        "extraVolumeMounts",
278        "extraContainers",
279        "extraInitContainers",
280        "extraEnvs",
281    ];
282
283    /// Guesses the type based on the path name using heuristics
284    ///
285    /// Returns `None` if no heuristic matches.
286    pub fn guess_type(path: &str) -> Option<InferredType> {
287        let last_segment = path.rsplit('.').next().unwrap_or(path);
288        let lower = last_segment.to_ascii_lowercase();
289
290        // Check dict patterns (exact match or ends with)
291        for suffix in Self::DICT_SUFFIXES {
292            let suffix_lower = suffix.to_ascii_lowercase();
293            if lower == suffix_lower || lower.ends_with(&suffix_lower) {
294                return Some(InferredType::Dict);
295            }
296        }
297
298        // Check list patterns (exact match or ends with)
299        for suffix in Self::LIST_SUFFIXES {
300            let suffix_lower = suffix.to_ascii_lowercase();
301            if lower == suffix_lower || lower.ends_with(&suffix_lower) {
302                return Some(InferredType::List);
303            }
304        }
305
306        None
307    }
308}
309
310// =============================================================================
311// TESTS
312// =============================================================================
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    #[test]
319    fn test_basic_types() {
320        let yaml = r#"
321controller:
322  replicas: 3
323  enabled: true
324  name: nginx
325"#;
326        let ctx = TypeContext::from_yaml(yaml).unwrap();
327
328        assert_eq!(ctx.get_type("controller"), InferredType::Dict);
329        assert_eq!(ctx.get_type("controller.replicas"), InferredType::Scalar);
330        assert_eq!(ctx.get_type("controller.enabled"), InferredType::Scalar);
331        assert_eq!(ctx.get_type("controller.name"), InferredType::Scalar);
332    }
333
334    #[test]
335    fn test_nested_dict() {
336        let yaml = r#"
337controller:
338  containerPort:
339    http: 80
340    https: 443
341  image:
342    repository: nginx
343    tag: latest
344"#;
345        let ctx = TypeContext::from_yaml(yaml).unwrap();
346
347        assert_eq!(ctx.get_type("controller"), InferredType::Dict);
348        assert_eq!(ctx.get_type("controller.containerPort"), InferredType::Dict);
349        assert_eq!(
350            ctx.get_type("controller.containerPort.http"),
351            InferredType::Scalar
352        );
353        assert_eq!(ctx.get_type("controller.image"), InferredType::Dict);
354        assert_eq!(
355            ctx.get_type("controller.image.repository"),
356            InferredType::Scalar
357        );
358    }
359
360    #[test]
361    fn test_list_types() {
362        let yaml = r#"
363controller:
364  extraEnvs:
365    - name: FOO
366      value: bar
367    - name: BAZ
368      value: qux
369  labels:
370    - app
371    - version
372"#;
373        let ctx = TypeContext::from_yaml(yaml).unwrap();
374
375        assert_eq!(ctx.get_type("controller.extraEnvs"), InferredType::List);
376        assert_eq!(ctx.get_type("controller.labels"), InferredType::List);
377    }
378
379    #[test]
380    fn test_path_normalization() {
381        let yaml = r#"
382controller:
383  replicas: 3
384"#;
385        let ctx = TypeContext::from_yaml(yaml).unwrap();
386
387        // All these should resolve to the same path
388        assert_eq!(ctx.get_type("controller.replicas"), InferredType::Scalar);
389        assert_eq!(
390            ctx.get_type("values.controller.replicas"),
391            InferredType::Scalar
392        );
393        assert_eq!(
394            ctx.get_type(".Values.controller.replicas"),
395            InferredType::Scalar
396        );
397        assert_eq!(
398            ctx.get_type("Values.controller.replicas"),
399            InferredType::Scalar
400        );
401    }
402
403    #[test]
404    fn test_unknown_path() {
405        let yaml = r#"
406controller:
407  replicas: 3
408"#;
409        let ctx = TypeContext::from_yaml(yaml).unwrap();
410
411        assert_eq!(ctx.get_type("nonexistent"), InferredType::Unknown);
412        assert_eq!(ctx.get_type("controller.unknown"), InferredType::Unknown);
413    }
414
415    #[test]
416    fn test_heuristics_dict() {
417        assert_eq!(
418            TypeHeuristics::guess_type("controller.annotations"),
419            Some(InferredType::Dict)
420        );
421        assert_eq!(
422            TypeHeuristics::guess_type("controller.labels"),
423            Some(InferredType::Dict)
424        );
425        assert_eq!(
426            TypeHeuristics::guess_type("pod.nodeSelector"),
427            Some(InferredType::Dict)
428        );
429        assert_eq!(
430            TypeHeuristics::guess_type("controller.containerPort"),
431            Some(InferredType::Dict)
432        );
433    }
434
435    #[test]
436    fn test_heuristics_list() {
437        assert_eq!(
438            TypeHeuristics::guess_type("spec.containers"),
439            Some(InferredType::List)
440        );
441        assert_eq!(
442            TypeHeuristics::guess_type("controller.extraVolumes"),
443            Some(InferredType::List)
444        );
445        assert_eq!(
446            TypeHeuristics::guess_type("pod.imagePullSecrets"),
447            Some(InferredType::List)
448        );
449    }
450
451    #[test]
452    fn test_heuristics_unknown() {
453        assert_eq!(TypeHeuristics::guess_type("controller.replicas"), None);
454        assert_eq!(TypeHeuristics::guess_type("custom.field"), None);
455    }
456
457    #[test]
458    fn test_complex_structure() {
459        let yaml = r#"
460global:
461  image:
462    registry: docker.io
463controller:
464  kind: Deployment
465  hostNetwork: false
466  containerPort:
467    http: 80
468    https: 443
469  admissionWebhooks:
470    enabled: true
471    patch:
472      image:
473        registry: registry.k8s.io
474        image: ingress-nginx/kube-webhook-certgen
475        tag: v1.4.1
476tcp: {}
477udp: {}
478"#;
479        let ctx = TypeContext::from_yaml(yaml).unwrap();
480
481        // Top-level
482        assert_eq!(ctx.get_type("global"), InferredType::Dict);
483        assert_eq!(ctx.get_type("controller"), InferredType::Dict);
484        assert_eq!(ctx.get_type("tcp"), InferredType::Dict);
485        assert_eq!(ctx.get_type("udp"), InferredType::Dict);
486
487        // Nested
488        assert_eq!(ctx.get_type("global.image"), InferredType::Dict);
489        assert_eq!(ctx.get_type("controller.containerPort"), InferredType::Dict);
490        assert_eq!(
491            ctx.get_type("controller.admissionWebhooks.patch.image"),
492            InferredType::Dict
493        );
494
495        // Scalars
496        assert_eq!(ctx.get_type("controller.kind"), InferredType::Scalar);
497        assert_eq!(ctx.get_type("controller.hostNetwork"), InferredType::Scalar);
498        assert_eq!(
499            ctx.get_type("controller.admissionWebhooks.enabled"),
500            InferredType::Scalar
501        );
502    }
503
504    #[test]
505    fn test_is_methods() {
506        assert!(InferredType::Dict.is_dict());
507        assert!(InferredType::Dict.is_collection());
508        assert!(!InferredType::Dict.is_list());
509
510        assert!(InferredType::List.is_list());
511        assert!(InferredType::List.is_collection());
512        assert!(!InferredType::List.is_dict());
513
514        assert!(!InferredType::Scalar.is_collection());
515        assert!(!InferredType::Unknown.is_collection());
516    }
517
518    #[test]
519    fn test_display() {
520        assert_eq!(format!("{}", InferredType::Scalar), "scalar");
521        assert_eq!(format!("{}", InferredType::List), "list");
522        assert_eq!(format!("{}", InferredType::Dict), "dict");
523        assert_eq!(format!("{}", InferredType::Unknown), "unknown");
524    }
525
526    #[test]
527    fn test_len_and_is_empty() {
528        let empty = TypeContext::new();
529        assert!(empty.is_empty());
530        assert_eq!(empty.len(), 0);
531
532        let ctx = TypeContext::from_yaml("foo: bar").unwrap();
533        assert!(!ctx.is_empty());
534        assert_eq!(ctx.len(), 1);
535    }
536}