1use rig::completion::ToolDefinition;
16use rig::tool::Tool;
17use serde::{Deserialize, Serialize};
18use serde_json::json;
19use std::path::PathBuf;
20
21use crate::analyzer::helmlint::types::RuleCategory;
22use crate::analyzer::helmlint::{HelmlintConfig, LintResult, Severity, lint_chart};
23
24#[derive(Debug, Deserialize)]
26pub struct HelmlintArgs {
27 #[serde(default)]
29 pub chart: Option<String>,
30
31 #[serde(default)]
33 pub ignore: Vec<String>,
34
35 #[serde(default)]
37 pub threshold: Option<String>,
38}
39
40#[derive(Debug, thiserror::Error)]
42#[error("Helmlint error: {0}")]
43pub struct HelmlintError(String);
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct HelmlintTool {
57 project_path: PathBuf,
58}
59
60impl HelmlintTool {
61 pub fn new(project_path: PathBuf) -> Self {
62 Self { project_path }
63 }
64
65 fn parse_threshold(threshold: &str) -> Severity {
66 match threshold.to_lowercase().as_str() {
67 "error" => Severity::Error,
68 "warning" => Severity::Warning,
69 "info" => Severity::Info,
70 "style" => Severity::Style,
71 _ => Severity::Warning,
72 }
73 }
74
75 fn get_priority(severity: Severity, category: RuleCategory) -> &'static str {
77 match (severity, category) {
78 (Severity::Error, RuleCategory::Security) => "critical",
79 (Severity::Error, _) => "high",
80 (Severity::Warning, RuleCategory::Security) => "high",
81 (Severity::Warning, RuleCategory::Template) => "high",
82 (Severity::Warning, RuleCategory::Structure) => "medium",
83 (Severity::Warning, _) => "medium",
84 (Severity::Info, _) => "low",
85 (Severity::Style, _) => "low",
86 (Severity::Ignore, _) => "info",
87 }
88 }
89
90 fn get_fix_recommendation(code: &str) -> &'static str {
92 match code {
93 "HL1001" => "Create a Chart.yaml file in the chart root directory.",
95 "HL1002" => "Add 'apiVersion: v2' (for Helm 3) or 'apiVersion: v1' to Chart.yaml.",
96 "HL1003" => "Add a 'name' field to Chart.yaml matching the chart directory name.",
97 "HL1004" => {
98 "Add a 'version' field with semantic versioning (e.g., '1.0.0') to Chart.yaml."
99 }
100 "HL1005" => "Use semantic versioning format (MAJOR.MINOR.PATCH) for the version field.",
101 "HL1006" => "Add a 'description' field explaining what the chart does.",
102 "HL1007" => "Add a 'maintainers' list with name and email for chart ownership.",
103 "HL1008" => "Ensure all dependencies listed in Chart.yaml are available and versioned.",
104
105 "HL2001" => "Create a values.yaml file with default configuration values.",
107 "HL2002" => "Define this value in values.yaml or provide a default in the template.",
108 "HL2003" => "Remove unused values from values.yaml or use them in templates.",
109 "HL2004" => "Use consistent naming (camelCase or snake_case) for all values.",
110 "HL2005" => "Add comments documenting the purpose and valid options for values.",
111
112 "HL3001" => "Close the unclosed template block ({{- end }}).",
114 "HL3002" => "Define this template with {{ define \"name\" }} or check for typos.",
115 "HL3003" => "Use {{ .Values.key }} or {{ .Release.Name }} for valid references.",
116 "HL3004" => "Check nesting of if/range/with blocks - each needs matching {{ end }}.",
117 "HL3005" => "Ensure the pipeline uses valid functions and proper syntax.",
118 "HL3006" => "Add whitespace control with {{- and -}} to avoid extra blank lines.",
119
120 "HL4001" => "Add 'securityContext.runAsNonRoot: true' to container specs.",
122 "HL4002" => "Remove 'privileged: true' or add explicit justification annotation.",
123 "HL4003" => "Add resource limits (cpu, memory) to prevent resource exhaustion.",
124 "HL4004" => "Use 'readOnlyRootFilesystem: true' in securityContext.",
125 "HL4005" => "Drop all capabilities and add only required ones explicitly.",
126
127 "HL5001" => "Add resource requests and limits for all containers.",
129 "HL5002" => "Add liveness and readiness probes for health checking.",
130 "HL5003" => "Use '{{ .Release.Namespace }}' for namespace-aware resources.",
131 "HL5004" => "Include NOTES.txt with post-install instructions.",
132 "HL5005" => "Add labels including 'app.kubernetes.io/name' and 'helm.sh/chart'.",
133 "HL5006" => "Use '{{ include \"chart.fullname\" . }}' for consistent naming.",
134 "HL5007" => "Add selector labels to connect Services with Deployments.",
135
136 _ => "Review the Helm chart best practices documentation.",
137 }
138 }
139
140 fn format_result(result: &LintResult) -> String {
142 let enriched_failures: Vec<serde_json::Value> = result
144 .failures
145 .iter()
146 .map(|f| {
147 let code = f.code.as_str();
148 let priority = Self::get_priority(f.severity, f.category);
149
150 json!({
151 "code": code,
152 "severity": f.severity.as_str(),
153 "priority": priority,
154 "category": f.category.display_name(),
155 "message": f.message,
156 "file": f.file.display().to_string(),
157 "line": f.line,
158 "column": f.column,
159 "fixable": f.fixable,
160 "fix": Self::get_fix_recommendation(code),
161 })
162 })
163 .collect();
164
165 let critical: Vec<_> = enriched_failures
167 .iter()
168 .filter(|f| f["priority"] == "critical")
169 .cloned()
170 .collect();
171 let high: Vec<_> = enriched_failures
172 .iter()
173 .filter(|f| f["priority"] == "high")
174 .cloned()
175 .collect();
176 let medium: Vec<_> = enriched_failures
177 .iter()
178 .filter(|f| f["priority"] == "medium")
179 .cloned()
180 .collect();
181 let low: Vec<_> = enriched_failures
182 .iter()
183 .filter(|f| f["priority"] == "low")
184 .cloned()
185 .collect();
186
187 let mut by_category: std::collections::HashMap<&str, usize> =
189 std::collections::HashMap::new();
190 for f in &result.failures {
191 *by_category.entry(f.category.display_name()).or_default() += 1;
192 }
193
194 let decision_context = if critical.is_empty() && high.is_empty() {
196 if medium.is_empty() && low.is_empty() {
197 "Helm chart follows best practices. No issues found."
198 } else if medium.is_empty() {
199 "Minor improvements possible. Low priority issues only."
200 } else {
201 "Good baseline. Medium priority improvements recommended."
202 }
203 } else if !critical.is_empty() {
204 "Critical issues found. Fix template/security issues before deployment."
205 } else {
206 "High priority issues found. Fix template syntax or structure issues."
207 };
208
209 let mut output = json!({
211 "chart": result.chart_path,
212 "success": !result.has_errors(),
213 "decision_context": decision_context,
214 "tool_guidance": "Use helmlint for chart structure/template issues. Use kubelint for K8s resource security/best practices.",
215 "summary": {
216 "total": result.failures.len(),
217 "files_checked": result.files_checked,
218 "by_priority": {
219 "critical": critical.len(),
220 "high": high.len(),
221 "medium": medium.len(),
222 "low": low.len(),
223 },
224 "by_severity": {
225 "errors": result.error_count,
226 "warnings": result.warning_count,
227 },
228 "by_category": by_category,
229 },
230 "action_plan": {
231 "critical": critical,
232 "high": high,
233 "medium": medium,
234 "low": low,
235 },
236 });
237
238 if !enriched_failures.is_empty() {
240 let quick_fixes: Vec<String> = enriched_failures
241 .iter()
242 .filter(|f| f["priority"] == "critical" || f["priority"] == "high")
243 .take(5)
244 .map(|f| {
245 format!(
246 "{} line {}: {} - {}",
247 f["file"].as_str().unwrap_or(""),
248 f["line"],
249 f["code"].as_str().unwrap_or(""),
250 f["fix"].as_str().unwrap_or("")
251 )
252 })
253 .collect();
254
255 if !quick_fixes.is_empty() {
256 output["quick_fixes"] = json!(quick_fixes);
257 }
258 }
259
260 if !result.parse_errors.is_empty() {
261 output["parse_errors"] = json!(result.parse_errors);
262 }
263
264 serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_string())
265 }
266}
267
268impl Tool for HelmlintTool {
269 const NAME: &'static str = "helmlint";
270
271 type Error = HelmlintError;
272 type Args = HelmlintArgs;
273 type Output = String;
274
275 async fn definition(&self, _prompt: String) -> ToolDefinition {
276 ToolDefinition {
277 name: Self::NAME.to_string(),
278 description: "Lint Helm chart STRUCTURE and TEMPLATES (before rendering). \
279 Validates Chart.yaml, values.yaml, Go template syntax, and Helm-specific best practices. \
280 \n\n**Use helmlint for:** Chart metadata, template syntax errors, undefined values, unclosed blocks. \
281 \n**Use kubelint for:** Security/best practices in rendered K8s manifests (probes, resources, RBAC). \
282 \n\nReturns AI-optimized JSON with issues categorized by priority and type. \
283 Each issue includes an actionable fix recommendation."
284 .to_string(),
285 parameters: json!({
286 "type": "object",
287 "properties": {
288 "chart": {
289 "type": "string",
290 "description": "Path to Helm chart directory relative to project root (e.g., 'charts/my-app', 'helm/production'). Must contain Chart.yaml."
291 },
292 "ignore": {
293 "type": "array",
294 "items": { "type": "string" },
295 "description": "List of rule codes to ignore (e.g., ['HL1007', 'HL5001']). See rule categories: HL1xxx=Structure, HL2xxx=Values, HL3xxx=Template, HL4xxx=Security, HL5xxx=BestPractice"
296 },
297 "threshold": {
298 "type": "string",
299 "enum": ["error", "warning", "info", "style"],
300 "description": "Minimum severity to report. Default is 'warning'."
301 }
302 },
303 "required": ["chart"]
304 }),
305 }
306 }
307
308 async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
309 let mut config = HelmlintConfig::default();
311
312 for rule in &args.ignore {
314 config = config.ignore(rule.as_str());
315 }
316
317 if let Some(threshold) = &args.threshold {
319 config = config.with_threshold(Self::parse_threshold(threshold));
320 }
321
322 let chart_path = if let Some(chart) = &args.chart {
324 self.project_path.join(chart)
325 } else {
326 if self.project_path.join("Chart.yaml").exists() {
328 self.project_path.clone()
329 } else {
330 return Err(HelmlintError(
331 "No chart specified and no Chart.yaml found in project root. \
332 Specify a chart directory with 'chart' parameter."
333 .to_string(),
334 ));
335 }
336 };
337
338 if !chart_path.join("Chart.yaml").exists() {
340 return Err(HelmlintError(format!(
341 "No Chart.yaml found in '{}'. This doesn't appear to be a Helm chart directory. \
342 For K8s manifest linting, use the kubelint tool instead.",
343 chart_path.display()
344 )));
345 }
346
347 let result = lint_chart(&chart_path, &config);
349
350 if !result.parse_errors.is_empty() {
352 log::warn!("Helm chart parse errors: {:?}", result.parse_errors);
353 }
354
355 Ok(Self::format_result(&result))
356 }
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362 use std::fs;
363 use tempfile::TempDir;
364
365 fn create_test_chart(dir: &std::path::Path) {
366 fs::create_dir_all(dir.join("templates")).unwrap();
367
368 fs::write(
369 dir.join("Chart.yaml"),
370 r#"apiVersion: v2
371name: test-chart
372version: 1.0.0
373description: A test chart
374"#,
375 )
376 .unwrap();
377
378 fs::write(
379 dir.join("values.yaml"),
380 r#"replicaCount: 1
381image:
382 repository: nginx
383 tag: "1.25"
384"#,
385 )
386 .unwrap();
387
388 fs::write(
389 dir.join("templates/deployment.yaml"),
390 r#"apiVersion: apps/v1
391kind: Deployment
392metadata:
393 name: {{ .Release.Name }}
394spec:
395 replicas: {{ .Values.replicaCount }}
396"#,
397 )
398 .unwrap();
399 }
400
401 #[tokio::test]
402 async fn test_helmlint_valid_chart() {
403 let temp_dir = TempDir::new().unwrap();
404 create_test_chart(temp_dir.path());
405
406 let tool = HelmlintTool::new(temp_dir.path().to_path_buf());
407 let args = HelmlintArgs {
408 chart: Some(".".to_string()),
409 ignore: vec![],
410 threshold: None,
411 };
412
413 let result = tool.call(args).await.unwrap();
414 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
415
416 assert!(parsed["decision_context"].is_string());
417 assert!(parsed["tool_guidance"].is_string());
418 assert!(parsed["summary"]["files_checked"].is_number());
419 }
420
421 #[tokio::test]
422 async fn test_helmlint_no_chart() {
423 let temp_dir = TempDir::new().unwrap();
424 let tool = HelmlintTool::new(temp_dir.path().to_path_buf());
427 let args = HelmlintArgs {
428 chart: None,
429 ignore: vec![],
430 threshold: None,
431 };
432
433 let result = tool.call(args).await;
434 assert!(result.is_err());
435 assert!(
436 result
437 .unwrap_err()
438 .to_string()
439 .contains("No chart specified")
440 );
441 }
442
443 #[tokio::test]
444 async fn test_helmlint_not_a_chart() {
445 let temp_dir = TempDir::new().unwrap();
446 fs::create_dir_all(temp_dir.path().join("some-dir")).unwrap();
448
449 let tool = HelmlintTool::new(temp_dir.path().to_path_buf());
450 let args = HelmlintArgs {
451 chart: Some("some-dir".to_string()),
452 ignore: vec![],
453 threshold: None,
454 };
455
456 let result = tool.call(args).await;
457 assert!(result.is_err());
458 assert!(result.unwrap_err().to_string().contains("No Chart.yaml"));
459 }
460}