syncable_cli/analyzer/helmlint/parser/
values.rs

1//! Values.yaml parser.
2//!
3//! Parses Helm values files with position tracking for error reporting.
4
5use std::collections::{HashMap, HashSet};
6use std::path::Path;
7
8use serde_yaml::Value;
9
10/// Parsed values file with metadata.
11#[derive(Debug, Clone)]
12pub struct ValuesFile {
13    /// The parsed YAML values.
14    pub values: Value,
15    /// Map of value paths to their line numbers.
16    pub line_map: HashMap<String, u32>,
17    /// All defined value paths.
18    pub defined_paths: HashSet<String>,
19}
20
21impl ValuesFile {
22    /// Create a new empty values file.
23    pub fn empty() -> Self {
24        Self {
25            values: Value::Mapping(serde_yaml::Mapping::new()),
26            line_map: HashMap::new(),
27            defined_paths: HashSet::new(),
28        }
29    }
30
31    /// Get a value by path (e.g., "image.repository").
32    pub fn get(&self, path: &str) -> Option<&Value> {
33        let parts: Vec<&str> = path.split('.').collect();
34        let mut current = &self.values;
35
36        for part in parts {
37            match current {
38                Value::Mapping(map) => {
39                    current = map.get(Value::String(part.to_string()))?;
40                }
41                _ => return None,
42            }
43        }
44
45        Some(current)
46    }
47
48    /// Check if a path is defined.
49    pub fn has_path(&self, path: &str) -> bool {
50        self.defined_paths.contains(path)
51    }
52
53    /// Get the line number for a path.
54    pub fn line_for_path(&self, path: &str) -> Option<u32> {
55        self.line_map.get(path).copied()
56    }
57
58    /// Get all paths that match a pattern (simple prefix matching).
59    pub fn paths_with_prefix(&self, prefix: &str) -> Vec<&str> {
60        self.defined_paths
61            .iter()
62            .filter(|p| p.starts_with(prefix))
63            .map(|s| s.as_str())
64            .collect()
65    }
66
67    /// Check if a value is a sensitive field (common patterns).
68    pub fn is_sensitive_path(path: &str) -> bool {
69        let lower = path.to_lowercase();
70        lower.contains("password")
71            || lower.contains("secret")
72            || lower.contains("token")
73            || lower.contains("key")
74            || lower.contains("credential")
75            || lower.contains("apikey")
76            || lower.contains("api_key")
77            || lower.ends_with(".auth")
78    }
79
80    /// Get all sensitive paths.
81    pub fn sensitive_paths(&self) -> Vec<&str> {
82        self.defined_paths
83            .iter()
84            .filter(|p| Self::is_sensitive_path(p))
85            .map(|s| s.as_str())
86            .collect()
87    }
88}
89
90/// Parse error for values.yaml.
91#[derive(Debug)]
92pub struct ValuesParseError {
93    pub message: String,
94    pub line: Option<u32>,
95}
96
97impl std::fmt::Display for ValuesParseError {
98    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99        if let Some(line) = self.line {
100            write!(f, "line {}: {}", line, self.message)
101        } else {
102            write!(f, "{}", self.message)
103        }
104    }
105}
106
107impl std::error::Error for ValuesParseError {}
108
109/// Parse values.yaml content.
110pub fn parse_values_yaml(content: &str) -> Result<ValuesFile, ValuesParseError> {
111    // Parse the YAML
112    let values: Value = serde_yaml::from_str(content).map_err(|e| {
113        let line = e.location().map(|l| l.line() as u32);
114        ValuesParseError {
115            message: e.to_string(),
116            line,
117        }
118    })?;
119
120    // Build line map by re-parsing with position tracking
121    let (line_map, defined_paths) = build_line_map(content);
122
123    Ok(ValuesFile {
124        values,
125        line_map,
126        defined_paths,
127    })
128}
129
130/// Parse values.yaml from a file path.
131pub fn parse_values_yaml_file(path: &Path) -> Result<ValuesFile, ValuesParseError> {
132    let content = std::fs::read_to_string(path).map_err(|e| ValuesParseError {
133        message: format!("Failed to read file: {}", e),
134        line: None,
135    })?;
136    parse_values_yaml(&content)
137}
138
139/// Build a map of value paths to line numbers.
140fn build_line_map(content: &str) -> (HashMap<String, u32>, HashSet<String>) {
141    let mut line_map = HashMap::new();
142    let mut defined_paths = HashSet::new();
143    let mut path_stack: Vec<(String, usize)> = Vec::new();
144
145    for (line_num, line) in content.lines().enumerate() {
146        let line_number = (line_num + 1) as u32;
147        let trimmed = line.trim();
148
149        // Skip empty lines and comments
150        if trimmed.is_empty() || trimmed.starts_with('#') {
151            continue;
152        }
153
154        // Count indentation (spaces)
155        let indent = line.len() - line.trim_start().len();
156
157        // Pop items from stack that are at same or greater indentation
158        while let Some((_, stack_indent)) = path_stack.last() {
159            if indent <= *stack_indent {
160                path_stack.pop();
161            } else {
162                break;
163            }
164        }
165
166        // Check if this line defines a key
167        if let Some(colon_pos) = trimmed.find(':') {
168            let key = trimmed[..colon_pos].trim();
169
170            // Skip if key contains special characters that indicate it's not a simple key
171            if key.contains(' ') && !key.starts_with('"') && !key.starts_with('\'') {
172                continue;
173            }
174
175            // Clean up quoted keys
176            let key = key.trim_matches('"').trim_matches('\'');
177
178            // Build the full path
179            let full_path = if path_stack.is_empty() {
180                key.to_string()
181            } else {
182                let parent_path = &path_stack.last().unwrap().0;
183                format!("{}.{}", parent_path, key)
184            };
185
186            line_map.insert(full_path.clone(), line_number);
187            defined_paths.insert(full_path.clone());
188
189            // Check if this key has a nested value (no value after colon or just whitespace)
190            let after_colon = trimmed[colon_pos + 1..].trim();
191            if after_colon.is_empty() || after_colon.starts_with('#') {
192                // This is a parent key, add to stack
193                path_stack.push((full_path, indent));
194            }
195        }
196    }
197
198    (line_map, defined_paths)
199}
200
201/// Extract all value references from a path expression.
202/// E.g., ".Values.image.repository" -> "image.repository"
203pub fn extract_values_path(expr: &str) -> Option<&str> {
204    let trimmed = expr.trim();
205    trimmed.strip_prefix(".Values.")
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn test_parse_simple_values() {
214        let yaml = r#"
215replicaCount: 1
216image:
217  repository: nginx
218  tag: "1.25"
219"#;
220        let values = parse_values_yaml(yaml).unwrap();
221        assert!(values.has_path("replicaCount"));
222        assert!(values.has_path("image"));
223        assert!(values.has_path("image.repository"));
224        assert!(values.has_path("image.tag"));
225    }
226
227    #[test]
228    fn test_get_value() {
229        let yaml = r#"
230image:
231  repository: nginx
232  tag: "1.25"
233service:
234  port: 80
235"#;
236        let values = parse_values_yaml(yaml).unwrap();
237
238        assert_eq!(
239            values.get("image.repository"),
240            Some(&Value::String("nginx".to_string()))
241        );
242        assert_eq!(values.get("service.port"), Some(&Value::Number(80.into())));
243        assert_eq!(values.get("nonexistent"), None);
244    }
245
246    #[test]
247    fn test_line_numbers() {
248        let yaml = r#"replicaCount: 1
249image:
250  repository: nginx
251  tag: "1.25"
252"#;
253        let values = parse_values_yaml(yaml).unwrap();
254        assert_eq!(values.line_for_path("replicaCount"), Some(1));
255        assert_eq!(values.line_for_path("image"), Some(2));
256        assert_eq!(values.line_for_path("image.repository"), Some(3));
257        assert_eq!(values.line_for_path("image.tag"), Some(4));
258    }
259
260    #[test]
261    fn test_sensitive_paths() {
262        let yaml = r#"
263database:
264  password: secret123
265  host: localhost
266auth:
267  apiKey: abc123
268  token: xyz789
269"#;
270        let values = parse_values_yaml(yaml).unwrap();
271        let sensitive = values.sensitive_paths();
272
273        assert!(sensitive.contains(&"database.password"));
274        assert!(sensitive.contains(&"auth.apiKey"));
275        assert!(sensitive.contains(&"auth.token"));
276        assert!(!sensitive.contains(&"database.host"));
277    }
278
279    #[test]
280    fn test_extract_values_path() {
281        assert_eq!(
282            extract_values_path(".Values.image.repository"),
283            Some("image.repository")
284        );
285        assert_eq!(
286            extract_values_path(".Values.replicaCount"),
287            Some("replicaCount")
288        );
289        assert_eq!(extract_values_path(".Release.Name"), None);
290        assert_eq!(extract_values_path("something.else"), None);
291    }
292
293    #[test]
294    fn test_paths_with_prefix() {
295        let yaml = r#"
296image:
297  repository: nginx
298  tag: "1.25"
299  pullPolicy: Always
300service:
301  port: 80
302"#;
303        let values = parse_values_yaml(yaml).unwrap();
304        let image_paths = values.paths_with_prefix("image.");
305
306        assert_eq!(image_paths.len(), 3);
307        assert!(image_paths.contains(&"image.repository"));
308        assert!(image_paths.contains(&"image.tag"));
309        assert!(image_paths.contains(&"image.pullPolicy"));
310    }
311
312    #[test]
313    fn test_empty_values() {
314        let values = ValuesFile::empty();
315        assert!(!values.has_path("anything"));
316    }
317
318    #[test]
319    fn test_parse_error() {
320        let yaml = "invalid: [yaml";
321        let result = parse_values_yaml(yaml);
322        assert!(result.is_err());
323    }
324}