syncable_cli/analyzer/helmlint/
lint.rs

1//! Main linting orchestration for helmlint.
2//!
3//! This module ties together parsing, rules, and pragmas to provide
4//! the main linting API.
5
6use std::collections::HashSet;
7use std::path::Path;
8
9use crate::analyzer::helmlint::config::HelmlintConfig;
10use crate::analyzer::helmlint::parser::chart::parse_chart_yaml;
11use crate::analyzer::helmlint::parser::helpers::{ParsedHelpers, parse_helpers};
12use crate::analyzer::helmlint::parser::template::parse_template;
13use crate::analyzer::helmlint::parser::values::parse_values_yaml;
14use crate::analyzer::helmlint::pragma::{
15    PragmaState, extract_template_pragmas, extract_yaml_pragmas,
16};
17use crate::analyzer::helmlint::rules::{LintContext, all_rules};
18use crate::analyzer::helmlint::types::{CheckFailure, Severity};
19
20/// Result of linting a Helm chart.
21#[derive(Debug, Clone)]
22pub struct LintResult {
23    /// Path to the chart root.
24    pub chart_path: String,
25    /// Rule violations found.
26    pub failures: Vec<CheckFailure>,
27    /// Parse errors (if any).
28    pub parse_errors: Vec<String>,
29    /// Number of files checked.
30    pub files_checked: usize,
31    /// Number of errors.
32    pub error_count: usize,
33    /// Number of warnings.
34    pub warning_count: usize,
35}
36
37impl LintResult {
38    /// Create a new empty result.
39    pub fn new(chart_path: impl Into<String>) -> Self {
40        Self {
41            chart_path: chart_path.into(),
42            failures: Vec::new(),
43            parse_errors: Vec::new(),
44            files_checked: 0,
45            error_count: 0,
46            warning_count: 0,
47        }
48    }
49
50    /// Update counts based on failures.
51    fn update_counts(&mut self) {
52        self.error_count = self
53            .failures
54            .iter()
55            .filter(|f| f.severity == Severity::Error)
56            .count();
57        self.warning_count = self
58            .failures
59            .iter()
60            .filter(|f| f.severity == Severity::Warning)
61            .count();
62    }
63
64    /// Check if there are any failures.
65    pub fn has_failures(&self) -> bool {
66        !self.failures.is_empty()
67    }
68
69    /// Check if there are any errors.
70    pub fn has_errors(&self) -> bool {
71        self.error_count > 0
72    }
73
74    /// Check if there are any warnings.
75    pub fn has_warnings(&self) -> bool {
76        self.warning_count > 0
77    }
78
79    /// Get the maximum severity in the results.
80    pub fn max_severity(&self) -> Option<Severity> {
81        self.failures.iter().map(|f| f.severity).max()
82    }
83
84    /// Check if the results should cause a non-zero exit.
85    pub fn should_fail(&self, config: &HelmlintConfig) -> bool {
86        if config.no_fail {
87            return false;
88        }
89
90        if let Some(max) = self.max_severity() {
91            max >= config.failure_threshold
92        } else {
93            false
94        }
95    }
96
97    /// Sort failures by file and line number.
98    pub fn sort(&mut self) {
99        self.failures.sort();
100    }
101}
102
103/// Lint a Helm chart directory.
104pub fn lint_chart(path: &Path, config: &HelmlintConfig) -> LintResult {
105    let chart_path_str = path.display().to_string();
106    let mut result = LintResult::new(&chart_path_str);
107
108    // Validate path
109    if !path.exists() {
110        result
111            .parse_errors
112            .push(format!("Chart path does not exist: {}", chart_path_str));
113        return result;
114    }
115
116    if !path.is_dir() {
117        result
118            .parse_errors
119            .push(format!("Chart path is not a directory: {}", chart_path_str));
120        return result;
121    }
122
123    // Collect all files
124    let files = collect_chart_files(path);
125    result.files_checked = files.len();
126
127    // Parse Chart.yaml
128    let chart_yaml_path = path.join("Chart.yaml");
129    let chart_metadata = if chart_yaml_path.exists() {
130        match std::fs::read_to_string(&chart_yaml_path) {
131            Ok(content) => match parse_chart_yaml(&content) {
132                Ok(metadata) => Some(metadata),
133                Err(e) => {
134                    result.parse_errors.push(format!("Chart.yaml: {}", e));
135                    None
136                }
137            },
138            Err(e) => {
139                result
140                    .parse_errors
141                    .push(format!("Failed to read Chart.yaml: {}", e));
142                None
143            }
144        }
145    } else {
146        None
147    };
148
149    // Parse values.yaml
150    let values_yaml_path = path.join("values.yaml");
151    let values = if values_yaml_path.exists() {
152        match std::fs::read_to_string(&values_yaml_path) {
153            Ok(content) => match parse_values_yaml(&content) {
154                Ok(v) => Some(v),
155                Err(e) => {
156                    result.parse_errors.push(format!("values.yaml: {}", e));
157                    None
158                }
159            },
160            Err(e) => {
161                result
162                    .parse_errors
163                    .push(format!("Failed to read values.yaml: {}", e));
164                None
165            }
166        }
167    } else {
168        None
169    };
170
171    // Parse templates
172    let templates_dir = path.join("templates");
173    let mut templates = Vec::new();
174    let mut helpers: Option<ParsedHelpers> = None;
175
176    if templates_dir.exists() && templates_dir.is_dir() {
177        for entry in walkdir::WalkDir::new(&templates_dir)
178            .into_iter()
179            .filter_map(|e| e.ok())
180        {
181            let file_path = entry.path();
182            if file_path.is_file() {
183                let relative_path = file_path
184                    .strip_prefix(path)
185                    .unwrap_or(file_path)
186                    .display()
187                    .to_string();
188
189                // Skip excluded files
190                if config.is_excluded(&relative_path) {
191                    continue;
192                }
193
194                let extension = file_path.extension().and_then(|e| e.to_str());
195                match extension {
196                    Some("yaml") | Some("yml") | Some("tpl") | Some("txt") => {
197                        match std::fs::read_to_string(file_path) {
198                            Ok(content) => {
199                                let parsed = parse_template(&content, &relative_path);
200
201                                // Check if this is the helpers file
202                                if relative_path.contains("_helpers") {
203                                    helpers = Some(parse_helpers(&content, &relative_path));
204                                }
205
206                                templates.push(parsed);
207                            }
208                            Err(e) => {
209                                result
210                                    .parse_errors
211                                    .push(format!("Failed to read {}: {}", relative_path, e));
212                            }
213                        }
214                    }
215                    _ => {}
216                }
217            }
218        }
219    }
220
221    // Collect pragmas from all files
222    let mut all_pragmas = PragmaState::new();
223
224    // Chart.yaml pragmas
225    if let Ok(content) = std::fs::read_to_string(&chart_yaml_path) {
226        let pragmas = extract_yaml_pragmas(&content);
227        merge_pragmas(&mut all_pragmas, pragmas);
228    }
229
230    // values.yaml pragmas
231    if let Ok(content) = std::fs::read_to_string(&values_yaml_path) {
232        let pragmas = extract_yaml_pragmas(&content);
233        merge_pragmas(&mut all_pragmas, pragmas);
234    }
235
236    // Template pragmas
237    for template in &templates {
238        let content = template
239            .tokens
240            .iter()
241            .map(|t| t.content())
242            .collect::<Vec<_>>()
243            .join("");
244        let pragmas = extract_template_pragmas(&content);
245        merge_pragmas(&mut all_pragmas, pragmas);
246    }
247
248    // Build lint context
249    let ctx = LintContext::new(
250        path,
251        chart_metadata.as_ref(),
252        values.as_ref(),
253        helpers.as_ref(),
254        &templates,
255        &files,
256    );
257
258    // Run all rules
259    let rules = all_rules();
260    let mut all_failures = Vec::new();
261
262    for rule in rules {
263        // Skip ignored rules
264        if config.is_rule_ignored(rule.code()) {
265            continue;
266        }
267
268        let failures = rule.check(&ctx);
269        all_failures.extend(failures);
270    }
271
272    // Filter by config and pragmas
273    result.failures = all_failures
274        .into_iter()
275        .filter(|f| {
276            // Apply config severity overrides
277            let effective_severity = config.effective_severity(f.code.as_str(), f.severity);
278            config.should_report(effective_severity)
279        })
280        .filter(|f| !config.is_rule_ignored(f.code.as_str()))
281        .filter(|f| {
282            if config.disable_ignore_pragma {
283                true
284            } else {
285                !all_pragmas.is_ignored(&f.code, f.line)
286            }
287        })
288        .filter(|f| if config.fixable_only { f.fixable } else { true })
289        .map(|mut f| {
290            // Apply severity overrides
291            f.severity = config.effective_severity(f.code.as_str(), f.severity);
292            f
293        })
294        .collect();
295
296    // Sort and update counts
297    result.sort();
298    result.update_counts();
299
300    result
301}
302
303/// Lint a single Helm chart file (Chart.yaml only).
304pub fn lint_chart_file(path: &Path, config: &HelmlintConfig) -> LintResult {
305    // Find chart root from the file
306    let chart_root = path.parent().unwrap_or(path);
307    lint_chart(chart_root, config)
308}
309
310/// Collect all files in the chart directory.
311fn collect_chart_files(path: &Path) -> HashSet<String> {
312    let mut files = HashSet::new();
313
314    for entry in walkdir::WalkDir::new(path)
315        .into_iter()
316        .filter_map(|e| e.ok())
317    {
318        if entry.path().is_file()
319            && let Ok(relative) = entry.path().strip_prefix(path)
320        {
321            files.insert(relative.display().to_string());
322        }
323    }
324
325    files
326}
327
328/// Merge pragmas from one state into another.
329fn merge_pragmas(target: &mut PragmaState, source: PragmaState) {
330    if source.file_disabled {
331        target.file_disabled = true;
332    }
333
334    for code in source.file_ignores {
335        target.file_ignores.insert(code);
336    }
337
338    for (line, codes) in source.line_ignores {
339        target.line_ignores.entry(line).or_default().extend(codes);
340    }
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346    use std::fs;
347    use tempfile::TempDir;
348
349    fn create_test_chart(dir: &Path) {
350        fs::create_dir_all(dir.join("templates")).unwrap();
351
352        fs::write(
353            dir.join("Chart.yaml"),
354            r#"apiVersion: v2
355name: test-chart
356version: 1.0.0
357description: A test chart
358"#,
359        )
360        .unwrap();
361
362        fs::write(
363            dir.join("values.yaml"),
364            r#"replicaCount: 1
365image:
366  repository: nginx
367  tag: "1.25"
368"#,
369        )
370        .unwrap();
371
372        fs::write(
373            dir.join("templates/deployment.yaml"),
374            r#"apiVersion: apps/v1
375kind: Deployment
376metadata:
377  name: {{ .Release.Name }}
378spec:
379  replicas: {{ .Values.replicaCount }}
380"#,
381        )
382        .unwrap();
383    }
384
385    #[test]
386    fn test_lint_valid_chart() {
387        let temp_dir = TempDir::new().unwrap();
388        create_test_chart(temp_dir.path());
389
390        let config = HelmlintConfig::default();
391        let result = lint_chart(temp_dir.path(), &config);
392
393        assert!(result.parse_errors.is_empty());
394    }
395
396    #[test]
397    fn test_lint_nonexistent_path() {
398        let config = HelmlintConfig::default();
399        let result = lint_chart(Path::new("/nonexistent/path"), &config);
400
401        assert!(!result.parse_errors.is_empty());
402    }
403
404    #[test]
405    fn test_lint_with_ignored_rules() {
406        let temp_dir = TempDir::new().unwrap();
407        create_test_chart(temp_dir.path());
408
409        let config = HelmlintConfig::default()
410            .ignore("HL1007")  // Missing maintainers
411            .ignore("HL5001"); // Missing resource limits
412
413        let result = lint_chart(temp_dir.path(), &config);
414
415        assert!(!result.failures.iter().any(|f| f.code.as_str() == "HL1007"));
416        assert!(!result.failures.iter().any(|f| f.code.as_str() == "HL5001"));
417    }
418
419    #[test]
420    fn test_result_counts() {
421        let mut result = LintResult::new("test");
422        result.failures.push(CheckFailure::new(
423            "HL1001",
424            Severity::Error,
425            "test",
426            "Chart.yaml",
427            1,
428            crate::analyzer::helmlint::types::RuleCategory::Structure,
429        ));
430        result.failures.push(CheckFailure::new(
431            "HL1002",
432            Severity::Warning,
433            "test",
434            "Chart.yaml",
435            2,
436            crate::analyzer::helmlint::types::RuleCategory::Structure,
437        ));
438        result.update_counts();
439
440        assert_eq!(result.error_count, 1);
441        assert_eq!(result.warning_count, 1);
442        assert!(result.has_errors());
443        assert!(result.has_warnings());
444    }
445}