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        // IMPORTANT: Treat empty content as None - fixes AI agents passing empty strings
403        let (result, filename) = if args.content.as_ref().is_some_and(|c| !c.trim().is_empty()) {
404            // Lint non-empty inline content
405            (
406                lint(args.content.as_ref().unwrap(), &config),
407                "<inline>".to_string(),
408            )
409        } else if let Some(dockerfile) = &args.dockerfile {
410            // Lint file
411            let path = self.project_path.join(dockerfile);
412            (lint_file(&path, &config), dockerfile.clone())
413        } else {
414            // Default: look for Dockerfile in project root
415            let path = self.project_path.join("Dockerfile");
416            if path.exists() {
417                (lint_file(&path, &config), "Dockerfile".to_string())
418            } else {
419                return Err(HadolintError(
420                    "No Dockerfile specified and no Dockerfile found in project root".to_string(),
421                ));
422            }
423        };
424
425        // Check for parse errors
426        if !result.parse_errors.is_empty() {
427            log::warn!("Dockerfile parse errors: {:?}", result.parse_errors);
428        }
429
430        Ok(Self::format_result(&result, &filename))
431    }
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437    use std::env::temp_dir;
438    use std::fs;
439
440    /// Helper to collect all issues from action_plan
441    fn collect_all_issues(parsed: &serde_json::Value) -> Vec<serde_json::Value> {
442        let mut all = Vec::new();
443        for priority in ["critical", "high", "medium", "low"] {
444            if let Some(arr) = parsed["action_plan"][priority].as_array() {
445                all.extend(arr.clone());
446            }
447        }
448        all
449    }
450
451    #[tokio::test]
452    async fn test_hadolint_inline_content() {
453        let tool = HadolintTool::new(temp_dir());
454        let args = HadolintArgs {
455            dockerfile: None,
456            content: Some("FROM ubuntu:latest\nRUN sudo apt-get update".to_string()),
457            ignore: vec![],
458            threshold: None,
459        };
460
461        let result = tool.call(args).await.unwrap();
462        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
463
464        // Should detect DL3007 (latest tag) and DL3004 (sudo)
465        assert!(!parsed["success"].as_bool().unwrap_or(true));
466        assert!(parsed["summary"]["total"].as_u64().unwrap_or(0) >= 2);
467
468        // Check new fields exist
469        assert!(parsed["decision_context"].is_string());
470        assert!(parsed["action_plan"].is_object());
471
472        // Check issues have fix recommendations
473        let issues = collect_all_issues(&parsed);
474        assert!(
475            issues
476                .iter()
477                .all(|i| i["fix"].is_string() && !i["fix"].as_str().unwrap().is_empty())
478        );
479    }
480
481    #[tokio::test]
482    async fn test_hadolint_ignore_rules() {
483        let tool = HadolintTool::new(temp_dir());
484        let args = HadolintArgs {
485            dockerfile: None,
486            content: Some("FROM ubuntu:latest".to_string()),
487            ignore: vec!["DL3007".to_string()],
488            threshold: None,
489        };
490
491        let result = tool.call(args).await.unwrap();
492        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
493
494        // DL3007 should be ignored
495        let all_issues = collect_all_issues(&parsed);
496        assert!(!all_issues.iter().any(|f| f["code"] == "DL3007"));
497    }
498
499    #[tokio::test]
500    async fn test_hadolint_threshold() {
501        let tool = HadolintTool::new(temp_dir());
502        let args = HadolintArgs {
503            dockerfile: None,
504            content: Some("FROM ubuntu\nMAINTAINER test".to_string()),
505            ignore: vec![],
506            threshold: Some("error".to_string()),
507        };
508
509        let result = tool.call(args).await.unwrap();
510        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
511
512        // DL4000 (MAINTAINER deprecated) is Error, DL3006 (untagged) is Warning
513        // With error threshold, only errors should show
514        let all_issues = collect_all_issues(&parsed);
515        assert!(all_issues.iter().all(|f| f["severity"] == "error"));
516    }
517
518    #[tokio::test]
519    async fn test_hadolint_file() {
520        let temp = temp_dir().join("hadolint_test");
521        fs::create_dir_all(&temp).unwrap();
522        let dockerfile = temp.join("Dockerfile");
523        fs::write(
524            &dockerfile,
525            "FROM node:18-alpine\nWORKDIR /app\nCOPY . .\nCMD [\"node\", \"app.js\"]",
526        )
527        .unwrap();
528
529        let tool = HadolintTool::new(temp.clone());
530        let args = HadolintArgs {
531            dockerfile: Some("Dockerfile".to_string()),
532            content: None,
533            ignore: vec![],
534            threshold: None,
535        };
536
537        let result = tool.call(args).await.unwrap();
538        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
539
540        // This is a well-formed Dockerfile, should have few/no errors
541        assert!(parsed["success"].as_bool().unwrap_or(false));
542        assert_eq!(parsed["file"], "Dockerfile");
543
544        // Cleanup
545        fs::remove_dir_all(&temp).ok();
546    }
547
548    #[tokio::test]
549    async fn test_hadolint_valid_dockerfile() {
550        let tool = HadolintTool::new(temp_dir());
551        let dockerfile = r#"
552FROM node:18-alpine AS builder
553WORKDIR /app
554COPY package*.json ./
555RUN npm ci --only=production
556COPY . .
557RUN npm run build
558
559FROM node:18-alpine
560WORKDIR /app
561COPY --from=builder /app/dist ./dist
562USER node
563EXPOSE 3000
564CMD ["node", "dist/index.js"]
565"#;
566
567        let args = HadolintArgs {
568            dockerfile: None,
569            content: Some(dockerfile.to_string()),
570            ignore: vec![],
571            threshold: None,
572        };
573
574        let result = tool.call(args).await.unwrap();
575        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
576
577        // Well-structured Dockerfile should pass (no errors)
578        assert!(parsed["success"].as_bool().unwrap_or(false));
579        // Should have decision context
580        assert!(parsed["decision_context"].is_string());
581        // Should not have critical or high priority issues
582        assert_eq!(
583            parsed["summary"]["by_priority"]["critical"]
584                .as_u64()
585                .unwrap_or(99),
586            0
587        );
588        assert_eq!(
589            parsed["summary"]["by_priority"]["high"]
590                .as_u64()
591                .unwrap_or(99),
592            0
593        );
594    }
595
596    #[tokio::test]
597    async fn test_hadolint_priority_categorization() {
598        let tool = HadolintTool::new(temp_dir());
599        let args = HadolintArgs {
600            dockerfile: None,
601            content: Some("FROM ubuntu\nRUN sudo apt-get update\nMAINTAINER test".to_string()),
602            ignore: vec![],
603            threshold: None,
604        };
605
606        let result = tool.call(args).await.unwrap();
607        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
608
609        // Check priority counts are present
610        assert!(parsed["summary"]["by_priority"]["critical"].is_number());
611        assert!(parsed["summary"]["by_priority"]["high"].is_number());
612        assert!(parsed["summary"]["by_priority"]["medium"].is_number());
613
614        // Check category counts
615        assert!(parsed["summary"]["by_category"].is_object());
616
617        // DL3004 (sudo) should be high priority security
618        let all_issues = collect_all_issues(&parsed);
619        let sudo_issue = all_issues.iter().find(|i| i["code"] == "DL3004");
620        assert!(sudo_issue.is_some());
621        assert_eq!(sudo_issue.unwrap()["category"], "security");
622    }
623
624    #[tokio::test]
625    async fn test_hadolint_quick_fixes() {
626        let tool = HadolintTool::new(temp_dir());
627        let args = HadolintArgs {
628            dockerfile: None,
629            content: Some("FROM ubuntu\nRUN sudo rm -rf /".to_string()),
630            ignore: vec![],
631            threshold: None,
632        };
633
634        let result = tool.call(args).await.unwrap();
635        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
636
637        // Should have quick_fixes for high priority issues
638        if parsed["summary"]["by_priority"]["high"]
639            .as_u64()
640            .unwrap_or(0)
641            > 0
642            || parsed["summary"]["by_priority"]["critical"]
643                .as_u64()
644                .unwrap_or(0)
645                > 0
646        {
647            assert!(parsed["quick_fixes"].is_array());
648        }
649    }
650}