1use rig::completion::ToolDefinition;
13use rig::tool::Tool;
14use serde::{Deserialize, Serialize};
15use serde_json::json;
16use std::path::PathBuf;
17
18use super::error::{ErrorCategory, format_error_for_llm};
19use crate::analyzer::dclint::{DclintConfig, LintResult, RuleCategory, Severity, lint, lint_file};
20
21#[derive(Debug, Deserialize)]
23pub struct DclintArgs {
24 #[serde(default)]
26 pub compose_file: Option<String>,
27
28 #[serde(default)]
30 pub content: Option<String>,
31
32 #[serde(default)]
34 pub ignore: Vec<String>,
35
36 #[serde(default)]
38 pub threshold: Option<String>,
39
40 #[serde(default)]
42 pub fix: bool,
43}
44
45#[derive(Debug, thiserror::Error)]
47#[error("Dclint error: {0}")]
48pub struct DclintError(String);
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct DclintTool {
53 project_path: PathBuf,
54}
55
56impl DclintTool {
57 pub fn new(project_path: PathBuf) -> Self {
58 Self { project_path }
59 }
60
61 fn parse_threshold(threshold: &str) -> Severity {
62 match threshold.to_lowercase().as_str() {
63 "error" => Severity::Error,
64 "warning" => Severity::Warning,
65 "info" => Severity::Info,
66 "style" => Severity::Style,
67 _ => Severity::Warning, }
69 }
70
71 fn get_priority(severity: Severity, category: RuleCategory) -> &'static str {
73 match (severity, category) {
74 (Severity::Error, RuleCategory::Security) => "critical",
75 (Severity::Error, _) => "high",
76 (Severity::Warning, RuleCategory::Security) => "high",
77 (Severity::Warning, RuleCategory::BestPractice) => "medium",
78 (Severity::Warning, _) => "medium",
79 (Severity::Info, _) => "low",
80 (Severity::Style, _) => "low",
81 }
82 }
83
84 fn get_fix_recommendation(code: &str) -> &'static str {
86 match code {
87 "DCL001" => {
88 "Remove either the 'build' or 'image' field, or add 'pull_policy' if both are intentional."
89 }
90 "DCL002" => {
91 "Use unique container names for each service, or remove explicit container_name to use auto-generated names."
92 }
93 "DCL003" => {
94 "Use different host ports for each service, or bind to different interfaces (e.g., 127.0.0.1:8080:80)."
95 }
96 "DCL004" => "Remove quotes from volume paths. YAML doesn't require quotes for paths.",
97 "DCL005" => {
98 "Add explicit interface binding, e.g., '127.0.0.1:8080:80' instead of '8080:80' for local-only access."
99 }
100 "DCL006" => {
101 "Remove the 'version' field. Docker Compose now infers the version automatically."
102 }
103 "DCL007" => "Add 'name: myproject' at the top level for explicit project naming.",
104 "DCL008" => {
105 "Quote port mappings to prevent YAML parsing issues, e.g., \"8080:80\" instead of 8080:80."
106 }
107 "DCL009" => {
108 "Use lowercase container names with only letters, numbers, hyphens, and underscores."
109 }
110 "DCL010" => {
111 "Sort dependencies alphabetically for better readability and easier merges."
112 }
113 "DCL011" => {
114 "Use explicit version tags (e.g., nginx:1.25) instead of implicit latest or untagged images."
115 }
116 "DCL012" => {
117 "Reorder service keys to follow convention: image, build, container_name, ports, volumes, environment, etc."
118 }
119 "DCL013" => "Sort port mappings alphabetically/numerically for consistency.",
120 "DCL014" => "Sort services alphabetically for better navigation and easier merges.",
121 "DCL015" => {
122 "Reorder top-level keys: name, services, networks, volumes, configs, secrets."
123 }
124 _ => "Review the rule documentation for specific guidance.",
125 }
126 }
127
128 fn get_rule_url(code: &str) -> String {
130 if code.starts_with("DCL") {
131 let rule_name = match code {
132 "DCL001" => "no-build-and-image-rule",
133 "DCL002" => "no-duplicate-container-names-rule",
134 "DCL003" => "no-duplicate-exported-ports-rule",
135 "DCL004" => "no-quotes-in-volumes-rule",
136 "DCL005" => "no-unbound-port-interfaces-rule",
137 "DCL006" => "no-version-field-rule",
138 "DCL007" => "require-project-name-field-rule",
139 "DCL008" => "require-quotes-in-ports-rule",
140 "DCL009" => "service-container-name-regex-rule",
141 "DCL010" => "service-dependencies-alphabetical-order-rule",
142 "DCL011" => "service-image-require-explicit-tag-rule",
143 "DCL012" => "service-keys-order-rule",
144 "DCL013" => "service-ports-alphabetical-order-rule",
145 "DCL014" => "services-alphabetical-order-rule",
146 "DCL015" => "top-level-properties-order-rule",
147 _ => return String::new(),
148 };
149 format!(
150 "https://github.com/zavoloklom/docker-compose-linter/blob/main/docs/rules/{}.md",
151 rule_name
152 )
153 } else {
154 String::new()
155 }
156 }
157
158 fn format_result(result: &LintResult, filename: &str) -> String {
160 let enriched_failures: Vec<serde_json::Value> = result
162 .failures
163 .iter()
164 .map(|f| {
165 let code = f.code.as_str();
166 let priority = Self::get_priority(f.severity, f.category);
167
168 json!({
169 "code": code,
170 "ruleName": f.rule_name,
171 "severity": f.severity.as_str(),
172 "priority": priority,
173 "category": f.category.as_str(),
174 "message": f.message,
175 "line": f.line,
176 "column": f.column,
177 "fixable": f.fixable,
178 "fix": Self::get_fix_recommendation(code),
179 "docs": Self::get_rule_url(code),
180 })
181 })
182 .collect();
183
184 let critical: Vec<_> = enriched_failures
186 .iter()
187 .filter(|f| f["priority"] == "critical")
188 .cloned()
189 .collect();
190 let high: Vec<_> = enriched_failures
191 .iter()
192 .filter(|f| f["priority"] == "high")
193 .cloned()
194 .collect();
195 let medium: Vec<_> = enriched_failures
196 .iter()
197 .filter(|f| f["priority"] == "medium")
198 .cloned()
199 .collect();
200 let low: Vec<_> = enriched_failures
201 .iter()
202 .filter(|f| f["priority"] == "low")
203 .cloned()
204 .collect();
205
206 let mut by_category: std::collections::HashMap<&str, Vec<_>> =
208 std::collections::HashMap::new();
209 for f in &enriched_failures {
210 let cat = f["category"].as_str().unwrap_or("other");
211 by_category.entry(cat).or_default().push(f.clone());
212 }
213
214 let decision_context = if critical.is_empty() && high.is_empty() {
216 if medium.is_empty() && low.is_empty() {
217 "Docker Compose file follows best practices. No issues found."
218 } else if medium.is_empty() {
219 "Minor improvements possible. Low priority issues only (style/formatting)."
220 } else {
221 "Good baseline. Medium priority improvements recommended."
222 }
223 } else if !critical.is_empty() {
224 "Critical issues found. Address security/error issues first before deployment."
225 } else {
226 "High priority issues found. Review and fix before production use."
227 };
228
229 let fixable_count = enriched_failures
231 .iter()
232 .filter(|f| f["fixable"] == true)
233 .count();
234
235 let mut output = json!({
237 "file": filename,
238 "success": !result.has_errors(),
239 "decision_context": decision_context,
240 "summary": {
241 "total": result.failures.len(),
242 "by_priority": {
243 "critical": critical.len(),
244 "high": high.len(),
245 "medium": medium.len(),
246 "low": low.len(),
247 },
248 "by_severity": {
249 "errors": result.error_count,
250 "warnings": result.warning_count,
251 "info": result.failures.iter().filter(|f| f.severity == Severity::Info).count(),
252 "style": result.failures.iter().filter(|f| f.severity == Severity::Style).count(),
253 },
254 "by_category": by_category.iter().map(|(k, v)| (k.to_string(), v.len())).collect::<std::collections::HashMap<_, _>>(),
255 "fixable": fixable_count,
256 },
257 "action_plan": {
258 "critical": critical,
259 "high": high,
260 "medium": medium,
261 "low": low,
262 },
263 });
264
265 if !enriched_failures.is_empty() {
267 let quick_fixes: Vec<String> = enriched_failures
268 .iter()
269 .filter(|f| f["priority"] == "critical" || f["priority"] == "high")
270 .take(5)
271 .map(|f| {
272 format!(
273 "Line {}: {} - {}",
274 f["line"],
275 f["code"].as_str().unwrap_or(""),
276 f["fix"].as_str().unwrap_or("")
277 )
278 })
279 .collect();
280
281 if !quick_fixes.is_empty() {
282 output["quick_fixes"] = json!(quick_fixes);
283 }
284 }
285
286 if !result.parse_errors.is_empty() {
287 output["parse_errors"] = json!(result.parse_errors);
288 }
289
290 serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_string())
291 }
292}
293
294impl Tool for DclintTool {
295 const NAME: &'static str = "dclint";
296
297 type Error = DclintError;
298 type Args = DclintArgs;
299 type Output = String;
300
301 async fn definition(&self, _prompt: String) -> ToolDefinition {
302 ToolDefinition {
303 name: Self::NAME.to_string(),
304 description: r#"Native Docker Compose linting with AI-optimized output. No external binary required.
305
306CAPABILITIES:
307- Validates docker-compose.yml files against 15 rules
308- Provides auto-fix support for 8 rules (use fix: true)
309- Returns prioritized issues with actionable fix recommendations
310- Auto-discovers compose files in project root
311
312RULE CATEGORIES:
313- Security (DCL0xx): Port exposure (DCL005), network settings
314- Best Practice (DCL1xx): Version field (DCL006), project naming (DCL007), image tags (DCL011)
315- Style (DCL2xx): Ordering rules (DCL010, DCL012-015), container naming (DCL009)
316- Performance (DCL3xx): Build caching, resource usage patterns
317
318KEY RULES:
319- DCL001: No both build and image in same service
320- DCL005: Ports should bind to specific interface (security)
321- DCL006: Version field is deprecated (remove it)
322- DCL011: Images need explicit version tags (not :latest or untagged)
323
324OUTPUT FORMAT:
325- 'decision_context': Quick assessment of severity
326- 'action_plan': Issues grouped by priority (critical/high/medium/low)
327- 'quick_fixes': Top 5 most important fixes to apply
328
329USAGE:
3301. Without args: Scans for docker-compose.yml in project root
3312. With compose_file: Lint specific file by path
3323. With content: Lint inline YAML (useful for validating before write)"#.to_string(),
333 parameters: json!({
334 "type": "object",
335 "properties": {
336 "compose_file": {
337 "type": "string",
338 "description": "Path to docker-compose.yml relative to project root. Examples: 'docker-compose.yml', 'deploy/compose.prod.yml', 'docker/docker-compose.dev.yaml'"
339 },
340 "content": {
341 "type": "string",
342 "description": "Inline Docker Compose YAML content to lint. Use when validating generated content before writing to file. Must include 'services:' section."
343 },
344 "ignore": {
345 "type": "array",
346 "items": { "type": "string" },
347 "description": "Rule codes to skip. Common: ['DCL006'] for legacy version field, ['DCL014', 'DCL015'] to skip ordering rules."
348 },
349 "threshold": {
350 "type": "string",
351 "enum": ["error", "warning", "info", "style"],
352 "description": "Minimum severity to report. 'error' for critical only, 'warning' (default) for actionable issues, 'style' for all."
353 },
354 "fix": {
355 "type": "boolean",
356 "description": "Apply auto-fixes. Supported rules: DCL004, DCL006, DCL008, DCL010, DCL012-015. Returns fixed content in response."
357 }
358 }
359 }),
360 }
361 }
362
363 async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
364 let mut config = DclintConfig::default();
366
367 for rule in &args.ignore {
369 config = config.ignore(rule.as_str());
370 }
371
372 if let Some(threshold) = &args.threshold {
374 config = config.with_threshold(Self::parse_threshold(threshold));
375 }
376
377 let (result, filename) = if args.content.as_ref().is_some_and(|c| !c.trim().is_empty()) {
380 let content = args.content.as_ref().unwrap();
382
383 if !content.contains("services:") && !content.contains("services :") {
385 return Ok(format_error_for_llm(
386 "dclint",
387 ErrorCategory::ValidationFailed,
388 "Content does not appear to be a Docker Compose file (missing 'services' section)",
389 Some(vec![
390 "Docker Compose files must have a 'services' section",
391 "Ensure the YAML defines at least one service",
392 "Example: services:\\n web:\\n image: nginx:latest",
393 ]),
394 ));
395 }
396
397 (lint(content, &config), "<inline>".to_string())
398 } else if let Some(compose_file) = &args.compose_file {
399 let path = self.project_path.join(compose_file);
401
402 if !path.exists() {
404 return Ok(format_error_for_llm(
405 "dclint",
406 ErrorCategory::FileNotFound,
407 &format!("Docker Compose file not found: {}", compose_file),
408 Some(vec![
409 "Check if the file path is correct",
410 "Verify the file exists relative to the project root",
411 "Use list_directory to explore available files",
412 "Common names: docker-compose.yml, docker-compose.yaml, compose.yml",
413 ]),
414 ));
415 }
416
417 if let Ok(metadata) = std::fs::metadata(&path) {
419 if metadata.len() == 0 {
420 return Ok(format_error_for_llm(
421 "dclint",
422 ErrorCategory::ValidationFailed,
423 &format!("Docker Compose file is empty: {}", compose_file),
424 Some(vec![
425 "Add service definitions to the file",
426 "Example minimal compose file:",
427 "services:\\n app:\\n image: myimage:latest",
428 ]),
429 ));
430 }
431 }
432
433 (lint_file(&path, &config), compose_file.clone())
434 } else {
435 let default_files = [
437 "docker-compose.yml",
438 "docker-compose.yaml",
439 "compose.yml",
440 "compose.yaml",
441 ];
442
443 let mut found = None;
444 for file in &default_files {
445 let path = self.project_path.join(file);
446 if path.exists() {
447 found = Some((lint_file(&path, &config), file.to_string()));
448 break;
449 }
450 }
451
452 match found {
453 Some((result, filename)) => (result, filename),
454 None => {
455 return Ok(format_error_for_llm(
456 "dclint",
457 ErrorCategory::FileNotFound,
458 "No Docker Compose file found in project root",
459 Some(vec![
460 "Check if the file exists in the project root",
461 "Common names: docker-compose.yml, docker-compose.yaml, compose.yml, compose.yaml",
462 "Use compose_file parameter to specify a custom path",
463 "Use content parameter to lint inline YAML",
464 ]),
465 ));
466 }
467 }
468 };
469
470 if !result.parse_errors.is_empty() {
472 log::warn!("Docker Compose parse errors: {:?}", result.parse_errors);
473 if result.failures.is_empty() && result.error_count == 0 && result.warning_count == 0 {
475 return Ok(format_error_for_llm(
476 "dclint",
477 ErrorCategory::ValidationFailed,
478 &format!(
479 "Invalid Docker Compose YAML syntax: {}",
480 result.parse_errors.join(", ")
481 ),
482 Some(vec![
483 "Check YAML indentation (use spaces, not tabs)",
484 "Verify key-value pair syntax (key: value)",
485 "Ensure quotes are properly matched",
486 "Validate the 'services' section structure",
487 ]),
488 ));
489 }
490 }
491
492 Ok(Self::format_result(&result, &filename))
493 }
494}
495
496#[cfg(test)]
497mod tests {
498 use super::*;
499 use std::env::temp_dir;
500 use std::fs;
501
502 #[tokio::test]
503 async fn test_dclint_inline_content() {
504 let tool = DclintTool::new(temp_dir());
505 let args = DclintArgs {
506 compose_file: None,
507 content: Some(
508 r#"
509services:
510 web:
511 build: .
512 image: nginx:latest
513"#
514 .to_string(),
515 ),
516 ignore: vec![],
517 threshold: None,
518 fix: false,
519 };
520
521 let result = tool.call(args).await.unwrap();
522 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
523
524 assert!(!parsed["success"].as_bool().unwrap_or(true));
526 assert!(parsed["summary"]["total"].as_u64().unwrap_or(0) >= 1);
527
528 assert!(parsed["decision_context"].is_string());
530 assert!(parsed["action_plan"].is_object());
531 }
532
533 #[tokio::test]
534 async fn test_dclint_ignore_rules() {
535 let tool = DclintTool::new(temp_dir());
536 let args = DclintArgs {
537 compose_file: None,
538 content: Some(
539 r#"
540version: "3.8"
541services:
542 web:
543 image: nginx:latest
544"#
545 .to_string(),
546 ),
547 ignore: vec!["DCL006".to_string(), "DCL011".to_string()],
548 threshold: None,
549 fix: false,
550 };
551
552 let result = tool.call(args).await.unwrap();
553 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
554
555 let all_codes: Vec<&str> = parsed["action_plan"]
557 .as_object()
558 .unwrap()
559 .values()
560 .flat_map(|v| v.as_array().unwrap())
561 .filter_map(|v| v["code"].as_str())
562 .collect();
563
564 assert!(!all_codes.contains(&"DCL006"));
565 assert!(!all_codes.contains(&"DCL011"));
566 }
567
568 #[tokio::test]
569 async fn test_dclint_file() {
570 let temp = temp_dir().join("dclint_test");
571 fs::create_dir_all(&temp).unwrap();
572 let compose_file = temp.join("docker-compose.yml");
573 fs::write(
574 &compose_file,
575 r#"
576name: myproject
577services:
578 web:
579 image: nginx:1.25
580 ports:
581 - "8080:80"
582"#,
583 )
584 .unwrap();
585
586 let tool = DclintTool::new(temp.clone());
587 let args = DclintArgs {
588 compose_file: Some("docker-compose.yml".to_string()),
589 content: None,
590 ignore: vec![],
591 threshold: None,
592 fix: false,
593 };
594
595 let result = tool.call(args).await.unwrap();
596 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
597
598 assert_eq!(parsed["file"], "docker-compose.yml");
600
601 fs::remove_dir_all(&temp).ok();
603 }
604
605 #[tokio::test]
606 async fn test_dclint_valid_compose() {
607 let tool = DclintTool::new(temp_dir());
608 let compose = r#"
609name: myproject
610services:
611 api:
612 image: node:20-alpine
613 ports:
614 - "127.0.0.1:3000:3000"
615 db:
616 image: postgres:16-alpine
617"#;
618
619 let args = DclintArgs {
620 compose_file: None,
621 content: Some(compose.to_string()),
622 ignore: vec![],
623 threshold: None,
624 fix: false,
625 };
626
627 let result = tool.call(args).await.unwrap();
628 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
629
630 assert!(parsed["success"].as_bool().unwrap_or(false));
632 assert!(parsed["decision_context"].is_string());
633 assert_eq!(
635 parsed["summary"]["by_priority"]["critical"]
636 .as_u64()
637 .unwrap_or(99),
638 0
639 );
640 assert_eq!(
641 parsed["summary"]["by_priority"]["high"]
642 .as_u64()
643 .unwrap_or(99),
644 0
645 );
646 }
647
648 #[test]
651 fn test_parse_threshold() {
652 assert_eq!(DclintTool::parse_threshold("error"), Severity::Error);
653 assert_eq!(DclintTool::parse_threshold("warning"), Severity::Warning);
654 assert_eq!(DclintTool::parse_threshold("info"), Severity::Info);
655 assert_eq!(DclintTool::parse_threshold("style"), Severity::Style);
656 assert_eq!(DclintTool::parse_threshold("ERROR"), Severity::Error);
658 assert_eq!(DclintTool::parse_threshold("Warning"), Severity::Warning);
659 assert_eq!(DclintTool::parse_threshold("invalid"), Severity::Warning);
661 assert_eq!(DclintTool::parse_threshold(""), Severity::Warning);
662 }
663
664 #[test]
665 fn test_get_priority() {
666 use crate::analyzer::dclint::RuleCategory;
667
668 assert_eq!(
670 DclintTool::get_priority(Severity::Error, RuleCategory::Security),
671 "critical"
672 );
673
674 assert_eq!(
676 DclintTool::get_priority(Severity::Error, RuleCategory::BestPractice),
677 "high"
678 );
679 assert_eq!(
680 DclintTool::get_priority(Severity::Warning, RuleCategory::Security),
681 "high"
682 );
683
684 assert_eq!(
686 DclintTool::get_priority(Severity::Warning, RuleCategory::BestPractice),
687 "medium"
688 );
689 assert_eq!(
690 DclintTool::get_priority(Severity::Warning, RuleCategory::Style),
691 "medium"
692 );
693
694 assert_eq!(
696 DclintTool::get_priority(Severity::Info, RuleCategory::BestPractice),
697 "low"
698 );
699 assert_eq!(
700 DclintTool::get_priority(Severity::Info, RuleCategory::Style),
701 "low"
702 );
703 assert_eq!(
704 DclintTool::get_priority(Severity::Style, RuleCategory::Style),
705 "low"
706 );
707 }
708
709 #[test]
710 fn test_fix_recommendations() {
711 let rec = DclintTool::get_fix_recommendation("DCL001");
713 assert!(rec.contains("build") || rec.contains("image"));
714
715 let rec = DclintTool::get_fix_recommendation("DCL005");
717 assert!(rec.contains("interface") || rec.contains("127.0.0.1"));
718
719 let rec = DclintTool::get_fix_recommendation("DCL006");
721 assert!(rec.contains("version") || rec.contains("Remove"));
722
723 let rec = DclintTool::get_fix_recommendation("DCL011");
725 assert!(rec.contains("tag") || rec.contains("latest"));
726
727 let rec = DclintTool::get_fix_recommendation("UNKNOWN");
729 assert!(rec.contains("documentation") || rec.contains("Review"));
730 }
731
732 #[test]
733 fn test_rule_url_generation() {
734 let url = DclintTool::get_rule_url("DCL001");
736 assert!(url.contains("docker-compose-linter"));
737 assert!(url.contains("no-build-and-image"));
738
739 let url = DclintTool::get_rule_url("DCL006");
740 assert!(url.contains("no-version-field"));
741
742 let url = DclintTool::get_rule_url("UNKNOWN");
744 assert!(url.is_empty());
745
746 let url = DclintTool::get_rule_url("DCL999");
747 assert!(url.is_empty());
748 }
749}