1use rig::completion::ToolDefinition;
13use rig::tool::Tool;
14use serde::{Deserialize, Serialize};
15use serde_json::json;
16use std::path::PathBuf;
17
18use crate::analyzer::hadolint::{HadolintConfig, LintResult, Severity, lint, lint_file};
19
20#[derive(Debug, Deserialize)]
22pub struct HadolintArgs {
23 #[serde(default)]
25 pub dockerfile: Option<String>,
26
27 #[serde(default)]
29 pub content: 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("Hadolint error: {0}")]
43pub struct HadolintError(String);
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct HadolintTool {
48 project_path: PathBuf,
49}
50
51impl HadolintTool {
52 pub fn new(project_path: PathBuf) -> Self {
53 Self { project_path }
54 }
55
56 fn parse_threshold(threshold: &str) -> Severity {
57 match threshold.to_lowercase().as_str() {
58 "error" => Severity::Error,
59 "warning" => Severity::Warning,
60 "info" => Severity::Info,
61 "style" => Severity::Style,
62 _ => Severity::Warning, }
64 }
65
66 fn get_rule_category(code: &str) -> &'static str {
68 match code {
69 "DL3000" | "DL3002" | "DL3004" | "DL3047" => "security",
71 "DL3003" | "DL3006" | "DL3007" | "DL3008" | "DL3009" | "DL3013" | "DL3014"
73 | "DL3015" | "DL3016" | "DL3018" | "DL3019" | "DL3020" | "DL3025" | "DL3027"
74 | "DL3028" | "DL3033" | "DL3042" | "DL3059" => "best-practice",
75 "DL3005" | "DL3010" | "DL3021" | "DL3022" | "DL3023" | "DL3024" | "DL3026"
77 | "DL3029" | "DL3030" | "DL3032" | "DL3034" | "DL3035" | "DL3036" | "DL3044"
78 | "DL3045" | "DL3048" | "DL3049" | "DL3050" | "DL3051" | "DL3052" | "DL3053"
79 | "DL3054" | "DL3055" | "DL3056" | "DL3057" | "DL3058" | "DL3060" | "DL3061" => {
80 "maintainability"
81 }
82 "DL3001" | "DL3011" | "DL3017" | "DL3031" | "DL3037" | "DL3038" | "DL3039"
84 | "DL3040" | "DL3041" | "DL3046" | "DL3062" => "performance",
85 "DL4000" | "DL4001" | "DL4003" | "DL4005" | "DL4006" => "deprecated",
87 _ if code.starts_with("SC") => "shell",
89 _ => "other",
90 }
91 }
92
93 fn get_priority(severity: Severity, category: &str) -> &'static str {
95 match (severity, category) {
96 (Severity::Error, "security") => "critical",
97 (Severity::Error, _) => "high",
98 (Severity::Warning, "security") => "high",
99 (Severity::Warning, "best-practice") => "medium",
100 (Severity::Warning, _) => "medium",
101 (Severity::Info, _) => "low",
102 (Severity::Style, _) => "low",
103 (Severity::Ignore, _) => "info",
104 }
105 }
106
107 fn get_fix_recommendation(code: &str) -> &'static str {
109 match code {
110 "DL3000" => "Use absolute WORKDIR paths like '/app' instead of relative paths.",
111 "DL3001" => "Remove commands that have no effect in Docker (like 'ssh', 'mount').",
112 "DL3002" => {
113 "Remove the last USER instruction setting root, or add 'USER <non-root>' at the end."
114 }
115 "DL3003" => "Use WORKDIR to change directories instead of 'cd' in RUN commands.",
116 "DL3004" => {
117 "Remove 'sudo' from RUN commands. Docker runs as root by default, or use proper USER switching."
118 }
119 "DL3005" => {
120 "Remove 'apt-get upgrade' or 'dist-upgrade'. Pin packages instead for reproducibility."
121 }
122 "DL3006" => {
123 "Add explicit version tag to base image, e.g., 'FROM node:18-alpine' instead of 'FROM node'."
124 }
125 "DL3007" => "Use specific version tag instead of ':latest', e.g., 'nginx:1.25-alpine'.",
126 "DL3008" => {
127 "Pin apt package versions: 'apt-get install package=version' or use '--no-install-recommends'."
128 }
129 "DL3009" => {
130 "Add 'rm -rf /var/lib/apt/lists/*' after apt-get install to reduce image size."
131 }
132 "DL3010" => "Use ADD only for extracting archives. For other files, use COPY.",
133 "DL3011" => "Use valid port numbers (0-65535) in EXPOSE.",
134 "DL3013" => "Pin pip package versions: 'pip install package==version'.",
135 "DL3014" => "Add '-y' flag to apt-get install for non-interactive mode.",
136 "DL3015" => "Add '--no-install-recommends' to apt-get install to minimize image size.",
137 "DL3016" => "Pin npm package versions: 'npm install package@version'.",
138 "DL3017" => "Remove 'apt-get upgrade'. Pin specific package versions instead.",
139 "DL3018" => "Pin apk package versions: 'apk add package=version'.",
140 "DL3019" => "Add '--no-cache' to apk add instead of separate cache cleanup.",
141 "DL3020" => {
142 "Use COPY instead of ADD for files from build context. ADD is for URLs and archives."
143 }
144 "DL3021" => {
145 "Use COPY with --from for multi-stage builds instead of COPY from external images."
146 }
147 "DL3022" => "Use COPY --from=stage instead of --from=image for multi-stage builds.",
148 "DL3023" => "Reference build stage by name instead of number in COPY --from.",
149 "DL3024" => "Use lowercase for 'as' in multi-stage builds: 'FROM image AS builder'.",
150 "DL3025" => "Use JSON array format for CMD/ENTRYPOINT: CMD [\"executable\", \"arg1\"].",
151 "DL3026" => {
152 "Use official Docker images when possible, or document why unofficial is needed."
153 }
154 "DL3027" => "Remove 'apt' and use 'apt-get' for scripting in Dockerfiles.",
155 "DL3028" => "Pin gem versions: 'gem install package:version'.",
156 "DL3029" => "Specify --platform explicitly for multi-arch builds.",
157 "DL3030" => "Pin yum/dnf package versions: 'yum install package-version'.",
158 "DL3032" => "Replace 'yum clean all' with 'dnf clean all' for newer distros.",
159 "DL3033" => "Add 'yum clean all' after yum install to reduce image size.",
160 "DL3034" => "Add '--setopt=install_weak_deps=False' to dnf install.",
161 "DL3035" => "Add 'dnf clean all' after dnf install to reduce image size.",
162 "DL3036" => "Pin zypper package versions: 'zypper install package=version'.",
163 "DL3037" => "Add 'zypper clean' after zypper install.",
164 "DL3038" => "Add '--no-recommends' to zypper install.",
165 "DL3039" => "Add 'zypper clean' after zypper install.",
166 "DL3040" => "Add 'dnf clean all && rm -rf /var/cache/dnf' after dnf install.",
167 "DL3041" => "Add 'microdnf clean all' after microdnf install.",
168 "DL3042" => {
169 "Avoid pip cache in builds. Use '--no-cache-dir' or set PIP_NO_CACHE_DIR=1."
170 }
171 "DL3044" => "Only use 'HEALTHCHECK' once per Dockerfile, or it won't work correctly.",
172 "DL3045" => "Use COPY instead of ADD for local files.",
173 "DL3046" => "Use 'useradd' instead of 'adduser' for better compatibility.",
174 "DL3047" => {
175 "Add 'wget --progress=dot:giga' or 'curl --progress-bar' to show progress during download."
176 }
177 "DL3048" => "Prefer setting flag with 'SHELL' instruction instead of inline in RUN.",
178 "DL3049" => "Add a 'LABEL maintainer=\"name\"' for documentation.",
179 "DL3050" => "Add 'LABEL version=\"x.y\"' for versioning.",
180 "DL3051" => "Add 'LABEL description=\"...\"' for documentation.",
181 "DL3052" => "Prefer relative paths with LABEL for better portability.",
182 "DL3053" => "Remove unused LABEL instructions.",
183 "DL3054" => "Use recommended labels from OCI spec (org.opencontainers.image.*).",
184 "DL3055" => "Add 'LABEL org.opencontainers.image.created' with ISO 8601 date.",
185 "DL3056" => "Add 'LABEL org.opencontainers.image.description'.",
186 "DL3057" => "Add a HEALTHCHECK instruction for container health monitoring.",
187 "DL3058" => "Add 'LABEL org.opencontainers.image.title'.",
188 "DL3059" => "Combine consecutive RUN instructions with '&&' to reduce layers.",
189 "DL3060" => "Pin package versions in yarn add: 'yarn add package@version'.",
190 "DL3061" => "Use specific image digest or tag instead of implicit latest.",
191 "DL3062" => "Prefer single RUN with '&&' over multiple RUN for related commands.",
192 "DL4000" => "Replace MAINTAINER with 'LABEL maintainer=\"name <email>\"'.",
193 "DL4001" => "Use wget or curl instead of ADD for downloading from URLs.",
194 "DL4003" => "Use 'ENTRYPOINT' and 'CMD' together properly for container startup.",
195 "DL4005" => "Prefer JSON notation for SHELL: SHELL [\"/bin/bash\", \"-c\"].",
196 "DL4006" => {
197 "Add 'SHELL [\"/bin/bash\", \"-o\", \"pipefail\", \"-c\"]' before RUN with pipes."
198 }
199 _ if code.starts_with("SC") => "See ShellCheck wiki for shell scripting fix.",
200 _ => "Review the rule documentation for specific guidance.",
201 }
202 }
203
204 fn get_rule_url(code: &str) -> String {
206 if code.starts_with("DL") || code.starts_with("SC") {
207 if code.starts_with("SC") {
208 format!("https://www.shellcheck.net/wiki/{}", code)
209 } else {
210 format!("https://github.com/hadolint/hadolint/wiki/{}", code)
211 }
212 } else {
213 String::new()
214 }
215 }
216
217 fn format_result(result: &LintResult, filename: &str) -> String {
219 let enriched_failures: Vec<serde_json::Value> = result
221 .failures
222 .iter()
223 .map(|f| {
224 let code = f.code.as_str();
225 let category = Self::get_rule_category(code);
226 let priority = Self::get_priority(f.severity, category);
227
228 json!({
229 "code": code,
230 "severity": format!("{:?}", f.severity).to_lowercase(),
231 "priority": priority,
232 "category": category,
233 "message": f.message,
234 "line": f.line,
235 "column": f.column,
236 "fix": Self::get_fix_recommendation(code),
237 "docs": Self::get_rule_url(code),
238 })
239 })
240 .collect();
241
242 let critical: Vec<_> = enriched_failures
244 .iter()
245 .filter(|f| f["priority"] == "critical")
246 .cloned()
247 .collect();
248 let high: Vec<_> = enriched_failures
249 .iter()
250 .filter(|f| f["priority"] == "high")
251 .cloned()
252 .collect();
253 let medium: Vec<_> = enriched_failures
254 .iter()
255 .filter(|f| f["priority"] == "medium")
256 .cloned()
257 .collect();
258 let low: Vec<_> = enriched_failures
259 .iter()
260 .filter(|f| f["priority"] == "low")
261 .cloned()
262 .collect();
263
264 let mut by_category: std::collections::HashMap<&str, Vec<_>> =
266 std::collections::HashMap::new();
267 for f in &enriched_failures {
268 let cat = f["category"].as_str().unwrap_or("other");
269 by_category.entry(cat).or_default().push(f.clone());
270 }
271
272 let decision_context = if critical.is_empty() && high.is_empty() {
274 if medium.is_empty() && low.is_empty() {
275 "Dockerfile follows best practices. No issues found."
276 } else if medium.is_empty() {
277 "Minor improvements possible. Low priority issues only."
278 } else {
279 "Good baseline. Medium priority improvements recommended."
280 }
281 } else if !critical.is_empty() {
282 "Critical issues found. Address security/error issues first before deployment."
283 } else {
284 "High priority issues found. Review and fix before production use."
285 };
286
287 let mut output = json!({
289 "file": filename,
290 "success": !result.has_errors(),
291 "decision_context": decision_context,
292 "summary": {
293 "total": result.failures.len(),
294 "by_priority": {
295 "critical": critical.len(),
296 "high": high.len(),
297 "medium": medium.len(),
298 "low": low.len(),
299 },
300 "by_severity": {
301 "errors": result.failures.iter().filter(|f| f.severity == Severity::Error).count(),
302 "warnings": result.failures.iter().filter(|f| f.severity == Severity::Warning).count(),
303 "info": result.failures.iter().filter(|f| f.severity == Severity::Info).count(),
304 },
305 "by_category": by_category.iter().map(|(k, v)| (k.to_string(), v.len())).collect::<std::collections::HashMap<_, _>>(),
306 },
307 "action_plan": {
308 "critical": critical,
309 "high": high,
310 "medium": medium,
311 "low": low,
312 },
313 });
314
315 if !enriched_failures.is_empty() {
317 let quick_fixes: Vec<String> = enriched_failures
318 .iter()
319 .filter(|f| f["priority"] == "critical" || f["priority"] == "high")
320 .take(5)
321 .map(|f| {
322 format!(
323 "Line {}: {} - {}",
324 f["line"],
325 f["code"].as_str().unwrap_or(""),
326 f["fix"].as_str().unwrap_or("")
327 )
328 })
329 .collect();
330
331 if !quick_fixes.is_empty() {
332 output["quick_fixes"] = json!(quick_fixes);
333 }
334 }
335
336 if !result.parse_errors.is_empty() {
337 output["parse_errors"] = json!(result.parse_errors);
338 }
339
340 serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_string())
341 }
342}
343
344impl Tool for HadolintTool {
345 const NAME: &'static str = "hadolint";
346
347 type Error = HadolintError;
348 type Args = HadolintArgs;
349 type Output = String;
350
351 async fn definition(&self, _prompt: String) -> ToolDefinition {
352 ToolDefinition {
353 name: Self::NAME.to_string(),
354 description: "Lint Dockerfiles for best practices, security issues, and common mistakes. \
355 Returns AI-optimized JSON with issues categorized by priority (critical/high/medium/low) \
356 and type (security/best-practice/maintainability/performance/deprecated). \
357 Each issue includes an actionable fix recommendation. Use this to analyze Dockerfiles \
358 before deployment or to improve existing ones. The 'decision_context' field provides \
359 a summary for quick assessment, and 'quick_fixes' lists the most important changes."
360 .to_string(),
361 parameters: json!({
362 "type": "object",
363 "properties": {
364 "dockerfile": {
365 "type": "string",
366 "description": "Path to Dockerfile relative to project root (e.g., 'Dockerfile', 'docker/Dockerfile.prod')"
367 },
368 "content": {
369 "type": "string",
370 "description": "Inline Dockerfile content to lint. Use this when you want to validate generated Dockerfile content before writing."
371 },
372 "ignore": {
373 "type": "array",
374 "items": { "type": "string" },
375 "description": "List of rule codes to ignore (e.g., ['DL3008', 'DL3013'])"
376 },
377 "threshold": {
378 "type": "string",
379 "enum": ["error", "warning", "info", "style"],
380 "description": "Minimum severity to report. Default is 'warning'."
381 }
382 }
383 }),
384 }
385 }
386
387 async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
388 let mut config = HadolintConfig::default();
390
391 for rule in &args.ignore {
393 config = config.ignore(rule.as_str());
394 }
395
396 if let Some(threshold) = &args.threshold {
398 config = config.with_threshold(Self::parse_threshold(threshold));
399 }
400
401 let (result, filename) = if let Some(content) = &args.content {
403 (lint(content, &config), "<inline>".to_string())
405 } else if let Some(dockerfile) = &args.dockerfile {
406 let path = self.project_path.join(dockerfile);
408 (lint_file(&path, &config), dockerfile.clone())
409 } else {
410 let path = self.project_path.join("Dockerfile");
412 if path.exists() {
413 (lint_file(&path, &config), "Dockerfile".to_string())
414 } else {
415 return Err(HadolintError(
416 "No Dockerfile specified and no Dockerfile found in project root".to_string(),
417 ));
418 }
419 };
420
421 if !result.parse_errors.is_empty() {
423 log::warn!("Dockerfile parse errors: {:?}", result.parse_errors);
424 }
425
426 Ok(Self::format_result(&result, &filename))
427 }
428}
429
430#[cfg(test)]
431mod tests {
432 use super::*;
433 use std::env::temp_dir;
434 use std::fs;
435
436 fn collect_all_issues(parsed: &serde_json::Value) -> Vec<serde_json::Value> {
438 let mut all = Vec::new();
439 for priority in ["critical", "high", "medium", "low"] {
440 if let Some(arr) = parsed["action_plan"][priority].as_array() {
441 all.extend(arr.clone());
442 }
443 }
444 all
445 }
446
447 #[tokio::test]
448 async fn test_hadolint_inline_content() {
449 let tool = HadolintTool::new(temp_dir());
450 let args = HadolintArgs {
451 dockerfile: None,
452 content: Some("FROM ubuntu:latest\nRUN sudo apt-get update".to_string()),
453 ignore: vec![],
454 threshold: None,
455 };
456
457 let result = tool.call(args).await.unwrap();
458 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
459
460 assert!(!parsed["success"].as_bool().unwrap_or(true));
462 assert!(parsed["summary"]["total"].as_u64().unwrap_or(0) >= 2);
463
464 assert!(parsed["decision_context"].is_string());
466 assert!(parsed["action_plan"].is_object());
467
468 let issues = collect_all_issues(&parsed);
470 assert!(
471 issues
472 .iter()
473 .all(|i| i["fix"].is_string() && !i["fix"].as_str().unwrap().is_empty())
474 );
475 }
476
477 #[tokio::test]
478 async fn test_hadolint_ignore_rules() {
479 let tool = HadolintTool::new(temp_dir());
480 let args = HadolintArgs {
481 dockerfile: None,
482 content: Some("FROM ubuntu:latest".to_string()),
483 ignore: vec!["DL3007".to_string()],
484 threshold: None,
485 };
486
487 let result = tool.call(args).await.unwrap();
488 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
489
490 let all_issues = collect_all_issues(&parsed);
492 assert!(!all_issues.iter().any(|f| f["code"] == "DL3007"));
493 }
494
495 #[tokio::test]
496 async fn test_hadolint_threshold() {
497 let tool = HadolintTool::new(temp_dir());
498 let args = HadolintArgs {
499 dockerfile: None,
500 content: Some("FROM ubuntu\nMAINTAINER test".to_string()),
501 ignore: vec![],
502 threshold: Some("error".to_string()),
503 };
504
505 let result = tool.call(args).await.unwrap();
506 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
507
508 let all_issues = collect_all_issues(&parsed);
511 assert!(all_issues.iter().all(|f| f["severity"] == "error"));
512 }
513
514 #[tokio::test]
515 async fn test_hadolint_file() {
516 let temp = temp_dir().join("hadolint_test");
517 fs::create_dir_all(&temp).unwrap();
518 let dockerfile = temp.join("Dockerfile");
519 fs::write(
520 &dockerfile,
521 "FROM node:18-alpine\nWORKDIR /app\nCOPY . .\nCMD [\"node\", \"app.js\"]",
522 )
523 .unwrap();
524
525 let tool = HadolintTool::new(temp.clone());
526 let args = HadolintArgs {
527 dockerfile: Some("Dockerfile".to_string()),
528 content: None,
529 ignore: vec![],
530 threshold: None,
531 };
532
533 let result = tool.call(args).await.unwrap();
534 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
535
536 assert!(parsed["success"].as_bool().unwrap_or(false));
538 assert_eq!(parsed["file"], "Dockerfile");
539
540 fs::remove_dir_all(&temp).ok();
542 }
543
544 #[tokio::test]
545 async fn test_hadolint_valid_dockerfile() {
546 let tool = HadolintTool::new(temp_dir());
547 let dockerfile = r#"
548FROM node:18-alpine AS builder
549WORKDIR /app
550COPY package*.json ./
551RUN npm ci --only=production
552COPY . .
553RUN npm run build
554
555FROM node:18-alpine
556WORKDIR /app
557COPY --from=builder /app/dist ./dist
558USER node
559EXPOSE 3000
560CMD ["node", "dist/index.js"]
561"#;
562
563 let args = HadolintArgs {
564 dockerfile: None,
565 content: Some(dockerfile.to_string()),
566 ignore: vec![],
567 threshold: None,
568 };
569
570 let result = tool.call(args).await.unwrap();
571 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
572
573 assert!(parsed["success"].as_bool().unwrap_or(false));
575 assert!(parsed["decision_context"].is_string());
577 assert_eq!(
579 parsed["summary"]["by_priority"]["critical"]
580 .as_u64()
581 .unwrap_or(99),
582 0
583 );
584 assert_eq!(
585 parsed["summary"]["by_priority"]["high"]
586 .as_u64()
587 .unwrap_or(99),
588 0
589 );
590 }
591
592 #[tokio::test]
593 async fn test_hadolint_priority_categorization() {
594 let tool = HadolintTool::new(temp_dir());
595 let args = HadolintArgs {
596 dockerfile: None,
597 content: Some("FROM ubuntu\nRUN sudo apt-get update\nMAINTAINER test".to_string()),
598 ignore: vec![],
599 threshold: None,
600 };
601
602 let result = tool.call(args).await.unwrap();
603 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
604
605 assert!(parsed["summary"]["by_priority"]["critical"].is_number());
607 assert!(parsed["summary"]["by_priority"]["high"].is_number());
608 assert!(parsed["summary"]["by_priority"]["medium"].is_number());
609
610 assert!(parsed["summary"]["by_category"].is_object());
612
613 let all_issues = collect_all_issues(&parsed);
615 let sudo_issue = all_issues.iter().find(|i| i["code"] == "DL3004");
616 assert!(sudo_issue.is_some());
617 assert_eq!(sudo_issue.unwrap()["category"], "security");
618 }
619
620 #[tokio::test]
621 async fn test_hadolint_quick_fixes() {
622 let tool = HadolintTool::new(temp_dir());
623 let args = HadolintArgs {
624 dockerfile: None,
625 content: Some("FROM ubuntu\nRUN sudo rm -rf /".to_string()),
626 ignore: vec![],
627 threshold: None,
628 };
629
630 let result = tool.call(args).await.unwrap();
631 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
632
633 if parsed["summary"]["by_priority"]["high"]
635 .as_u64()
636 .unwrap_or(0)
637 > 0
638 || parsed["summary"]["by_priority"]["critical"]
639 .as_u64()
640 .unwrap_or(0)
641 > 0
642 {
643 assert!(parsed["quick_fixes"].is_array());
644 }
645 }
646}