syncable_cli/agent/tools/
hadolint.rs

1//! Hadolint tool - Native Dockerfile linting using Rig's Tool trait
2//!
3//! Provides native Dockerfile linting without requiring the external hadolint binary.
4//! Implements hadolint rules with full pragma support.
5//!
6//! Output is optimized for AI agent decision-making with:
7//! - Categorized issues (security, best-practice, maintainability, performance)
8//! - Priority rankings (critical, high, medium, low)
9//! - Actionable fix recommendations
10//! - Rule documentation links
11
12use 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/// Arguments for the hadolint tool
21#[derive(Debug, Deserialize)]
22pub struct HadolintArgs {
23    /// Path to Dockerfile (relative to project root) or inline content
24    #[serde(default)]
25    pub dockerfile: Option<String>,
26
27    /// Inline Dockerfile content to lint (alternative to path)
28    #[serde(default)]
29    pub content: Option<String>,
30
31    /// Rules to ignore (e.g., ["DL3008", "DL3013"])
32    #[serde(default)]
33    pub ignore: Vec<String>,
34
35    /// Minimum severity threshold: "error", "warning", "info", "style"
36    #[serde(default)]
37    pub threshold: Option<String>,
38}
39
40/// Error type for hadolint tool
41#[derive(Debug, thiserror::Error)]
42#[error("Hadolint error: {0}")]
43pub struct HadolintError(String);
44
45/// Tool to lint Dockerfiles natively
46#[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, // Default
63        }
64    }
65
66    /// Get the category for a rule code
67    fn get_rule_category(code: &str) -> &'static str {
68        match code {
69            // Security rules
70            "DL3000" | "DL3002" | "DL3004" | "DL3047" => "security",
71            // Best practice rules
72            "DL3003" | "DL3006" | "DL3007" | "DL3008" | "DL3009" | "DL3013" | "DL3014"
73            | "DL3015" | "DL3016" | "DL3018" | "DL3019" | "DL3020" | "DL3025" | "DL3027"
74            | "DL3028" | "DL3033" | "DL3042" | "DL3059" => "best-practice",
75            // Maintainability rules
76            "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            // Performance rules
83            "DL3001" | "DL3011" | "DL3017" | "DL3031" | "DL3037" | "DL3038" | "DL3039"
84            | "DL3040" | "DL3041" | "DL3046" | "DL3062" => "performance",
85            // Deprecated instructions
86            "DL4000" | "DL4001" | "DL4003" | "DL4005" | "DL4006" => "deprecated",
87            // ShellCheck rules
88            _ if code.starts_with("SC") => "shell",
89            _ => "other",
90        }
91    }
92
93    /// Get priority based on severity and category
94    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    /// Get actionable fix recommendation for a rule
108    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    /// Get documentation URL for a rule
205    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    /// Format result optimized for agent decision-making
218    fn format_result(result: &LintResult, filename: &str) -> String {
219        // Categorize and enrich failures
220        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        // Group by priority for agent decision ordering
243        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        // Group by category for thematic fixes
265        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        // Build decision context
273        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        // Build agent-optimized output
288        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        // Add quick fixes summary for agent
316        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        // Build configuration
389        let mut config = HadolintConfig::default();
390
391        // Apply ignored rules
392        for rule in &args.ignore {
393            config = config.ignore(rule.as_str());
394        }
395
396        // Apply threshold
397        if let Some(threshold) = &args.threshold {
398            config = config.with_threshold(Self::parse_threshold(threshold));
399        }
400
401        // Determine source, filename, and lint
402        let (result, filename) = if let Some(content) = &args.content {
403            // Lint inline content
404            (lint(content, &config), "<inline>".to_string())
405        } else if let Some(dockerfile) = &args.dockerfile {
406            // Lint file
407            let path = self.project_path.join(dockerfile);
408            (lint_file(&path, &config), dockerfile.clone())
409        } else {
410            // Default: look for Dockerfile in project root
411            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        // Check for parse errors
422        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    /// Helper to collect all issues from action_plan
437    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        // Should detect DL3007 (latest tag) and DL3004 (sudo)
461        assert!(!parsed["success"].as_bool().unwrap_or(true));
462        assert!(parsed["summary"]["total"].as_u64().unwrap_or(0) >= 2);
463
464        // Check new fields exist
465        assert!(parsed["decision_context"].is_string());
466        assert!(parsed["action_plan"].is_object());
467
468        // Check issues have fix recommendations
469        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        // DL3007 should be ignored
491        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        // DL4000 (MAINTAINER deprecated) is Error, DL3006 (untagged) is Warning
509        // With error threshold, only errors should show
510        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        // This is a well-formed Dockerfile, should have few/no errors
537        assert!(parsed["success"].as_bool().unwrap_or(false));
538        assert_eq!(parsed["file"], "Dockerfile");
539
540        // Cleanup
541        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        // Well-structured Dockerfile should pass (no errors)
574        assert!(parsed["success"].as_bool().unwrap_or(false));
575        // Should have decision context
576        assert!(parsed["decision_context"].is_string());
577        // Should not have critical or high priority issues
578        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        // Check priority counts are present
606        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        // Check category counts
611        assert!(parsed["summary"]["by_category"].is_object());
612
613        // DL3004 (sudo) should be high priority security
614        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        // Should have quick_fixes for high priority issues
634        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}