1use rig::completion::ToolDefinition;
13use rig::tool::Tool;
14use serde::{Deserialize, Serialize};
15use serde_json::json;
16use std::path::PathBuf;
17
18use crate::analyzer::dclint::{DclintConfig, LintResult, RuleCategory, Severity, lint, lint_file};
19
20#[derive(Debug, Deserialize)]
22pub struct DclintArgs {
23 #[serde(default)]
25 pub compose_file: 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 #[serde(default)]
41 pub fix: bool,
42}
43
44#[derive(Debug, thiserror::Error)]
46#[error("Dclint error: {0}")]
47pub struct DclintError(String);
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct DclintTool {
52 project_path: PathBuf,
53}
54
55impl DclintTool {
56 pub fn new(project_path: PathBuf) -> Self {
57 Self { project_path }
58 }
59
60 fn parse_threshold(threshold: &str) -> Severity {
61 match threshold.to_lowercase().as_str() {
62 "error" => Severity::Error,
63 "warning" => Severity::Warning,
64 "info" => Severity::Info,
65 "style" => Severity::Style,
66 _ => Severity::Warning, }
68 }
69
70 fn get_priority(severity: Severity, category: RuleCategory) -> &'static str {
72 match (severity, category) {
73 (Severity::Error, RuleCategory::Security) => "critical",
74 (Severity::Error, _) => "high",
75 (Severity::Warning, RuleCategory::Security) => "high",
76 (Severity::Warning, RuleCategory::BestPractice) => "medium",
77 (Severity::Warning, _) => "medium",
78 (Severity::Info, _) => "low",
79 (Severity::Style, _) => "low",
80 }
81 }
82
83 fn get_fix_recommendation(code: &str) -> &'static str {
85 match code {
86 "DCL001" => {
87 "Remove either the 'build' or 'image' field, or add 'pull_policy' if both are intentional."
88 }
89 "DCL002" => {
90 "Use unique container names for each service, or remove explicit container_name to use auto-generated names."
91 }
92 "DCL003" => {
93 "Use different host ports for each service, or bind to different interfaces (e.g., 127.0.0.1:8080:80)."
94 }
95 "DCL004" => "Remove quotes from volume paths. YAML doesn't require quotes for paths.",
96 "DCL005" => {
97 "Add explicit interface binding, e.g., '127.0.0.1:8080:80' instead of '8080:80' for local-only access."
98 }
99 "DCL006" => {
100 "Remove the 'version' field. Docker Compose now infers the version automatically."
101 }
102 "DCL007" => "Add 'name: myproject' at the top level for explicit project naming.",
103 "DCL008" => {
104 "Quote port mappings to prevent YAML parsing issues, e.g., \"8080:80\" instead of 8080:80."
105 }
106 "DCL009" => {
107 "Use lowercase container names with only letters, numbers, hyphens, and underscores."
108 }
109 "DCL010" => {
110 "Sort dependencies alphabetically for better readability and easier merges."
111 }
112 "DCL011" => {
113 "Use explicit version tags (e.g., nginx:1.25) instead of implicit latest or untagged images."
114 }
115 "DCL012" => {
116 "Reorder service keys to follow convention: image, build, container_name, ports, volumes, environment, etc."
117 }
118 "DCL013" => "Sort port mappings alphabetically/numerically for consistency.",
119 "DCL014" => "Sort services alphabetically for better navigation and easier merges.",
120 "DCL015" => {
121 "Reorder top-level keys: name, services, networks, volumes, configs, secrets."
122 }
123 _ => "Review the rule documentation for specific guidance.",
124 }
125 }
126
127 fn get_rule_url(code: &str) -> String {
129 if code.starts_with("DCL") {
130 let rule_name = match code {
131 "DCL001" => "no-build-and-image-rule",
132 "DCL002" => "no-duplicate-container-names-rule",
133 "DCL003" => "no-duplicate-exported-ports-rule",
134 "DCL004" => "no-quotes-in-volumes-rule",
135 "DCL005" => "no-unbound-port-interfaces-rule",
136 "DCL006" => "no-version-field-rule",
137 "DCL007" => "require-project-name-field-rule",
138 "DCL008" => "require-quotes-in-ports-rule",
139 "DCL009" => "service-container-name-regex-rule",
140 "DCL010" => "service-dependencies-alphabetical-order-rule",
141 "DCL011" => "service-image-require-explicit-tag-rule",
142 "DCL012" => "service-keys-order-rule",
143 "DCL013" => "service-ports-alphabetical-order-rule",
144 "DCL014" => "services-alphabetical-order-rule",
145 "DCL015" => "top-level-properties-order-rule",
146 _ => return String::new(),
147 };
148 format!(
149 "https://github.com/zavoloklom/docker-compose-linter/blob/main/docs/rules/{}.md",
150 rule_name
151 )
152 } else {
153 String::new()
154 }
155 }
156
157 fn format_result(result: &LintResult, filename: &str) -> String {
159 let enriched_failures: Vec<serde_json::Value> = result
161 .failures
162 .iter()
163 .map(|f| {
164 let code = f.code.as_str();
165 let priority = Self::get_priority(f.severity, f.category);
166
167 json!({
168 "code": code,
169 "ruleName": f.rule_name,
170 "severity": f.severity.as_str(),
171 "priority": priority,
172 "category": f.category.as_str(),
173 "message": f.message,
174 "line": f.line,
175 "column": f.column,
176 "fixable": f.fixable,
177 "fix": Self::get_fix_recommendation(code),
178 "docs": Self::get_rule_url(code),
179 })
180 })
181 .collect();
182
183 let critical: Vec<_> = enriched_failures
185 .iter()
186 .filter(|f| f["priority"] == "critical")
187 .cloned()
188 .collect();
189 let high: Vec<_> = enriched_failures
190 .iter()
191 .filter(|f| f["priority"] == "high")
192 .cloned()
193 .collect();
194 let medium: Vec<_> = enriched_failures
195 .iter()
196 .filter(|f| f["priority"] == "medium")
197 .cloned()
198 .collect();
199 let low: Vec<_> = enriched_failures
200 .iter()
201 .filter(|f| f["priority"] == "low")
202 .cloned()
203 .collect();
204
205 let mut by_category: std::collections::HashMap<&str, Vec<_>> =
207 std::collections::HashMap::new();
208 for f in &enriched_failures {
209 let cat = f["category"].as_str().unwrap_or("other");
210 by_category.entry(cat).or_default().push(f.clone());
211 }
212
213 let decision_context = if critical.is_empty() && high.is_empty() {
215 if medium.is_empty() && low.is_empty() {
216 "Docker Compose file follows best practices. No issues found."
217 } else if medium.is_empty() {
218 "Minor improvements possible. Low priority issues only (style/formatting)."
219 } else {
220 "Good baseline. Medium priority improvements recommended."
221 }
222 } else if !critical.is_empty() {
223 "Critical issues found. Address security/error issues first before deployment."
224 } else {
225 "High priority issues found. Review and fix before production use."
226 };
227
228 let fixable_count = enriched_failures
230 .iter()
231 .filter(|f| f["fixable"] == true)
232 .count();
233
234 let mut output = json!({
236 "file": filename,
237 "success": !result.has_errors(),
238 "decision_context": decision_context,
239 "summary": {
240 "total": result.failures.len(),
241 "by_priority": {
242 "critical": critical.len(),
243 "high": high.len(),
244 "medium": medium.len(),
245 "low": low.len(),
246 },
247 "by_severity": {
248 "errors": result.error_count,
249 "warnings": result.warning_count,
250 "info": result.failures.iter().filter(|f| f.severity == Severity::Info).count(),
251 "style": result.failures.iter().filter(|f| f.severity == Severity::Style).count(),
252 },
253 "by_category": by_category.iter().map(|(k, v)| (k.to_string(), v.len())).collect::<std::collections::HashMap<_, _>>(),
254 "fixable": fixable_count,
255 },
256 "action_plan": {
257 "critical": critical,
258 "high": high,
259 "medium": medium,
260 "low": low,
261 },
262 });
263
264 if !enriched_failures.is_empty() {
266 let quick_fixes: Vec<String> = enriched_failures
267 .iter()
268 .filter(|f| f["priority"] == "critical" || f["priority"] == "high")
269 .take(5)
270 .map(|f| {
271 format!(
272 "Line {}: {} - {}",
273 f["line"],
274 f["code"].as_str().unwrap_or(""),
275 f["fix"].as_str().unwrap_or("")
276 )
277 })
278 .collect();
279
280 if !quick_fixes.is_empty() {
281 output["quick_fixes"] = json!(quick_fixes);
282 }
283 }
284
285 if !result.parse_errors.is_empty() {
286 output["parse_errors"] = json!(result.parse_errors);
287 }
288
289 serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_string())
290 }
291}
292
293impl Tool for DclintTool {
294 const NAME: &'static str = "dclint";
295
296 type Error = DclintError;
297 type Args = DclintArgs;
298 type Output = String;
299
300 async fn definition(&self, _prompt: String) -> ToolDefinition {
301 ToolDefinition {
302 name: Self::NAME.to_string(),
303 description: "Lint Docker Compose files for best practices, security issues, and style consistency. \
304 Returns AI-optimized JSON with issues categorized by priority (critical/high/medium/low) \
305 and type (security/best-practice/style/performance). \
306 Each issue includes an actionable fix recommendation. Use this to analyze docker-compose.yml \
307 files before deployment or to improve existing configurations. The 'decision_context' field provides \
308 a summary for quick assessment, and 'quick_fixes' lists the most important changes. \
309 Supports 15 rules including: build+image conflicts, duplicate names/ports, image tagging, \
310 port security, alphabetical ordering, and more."
311 .to_string(),
312 parameters: json!({
313 "type": "object",
314 "properties": {
315 "compose_file": {
316 "type": "string",
317 "description": "Path to docker-compose.yml relative to project root (e.g., 'docker-compose.yml', 'deploy/docker-compose.prod.yml')"
318 },
319 "content": {
320 "type": "string",
321 "description": "Inline Docker Compose YAML content to lint. Use this when you want to validate generated content before writing."
322 },
323 "ignore": {
324 "type": "array",
325 "items": { "type": "string" },
326 "description": "List of rule codes to ignore (e.g., ['DCL006', 'DCL014'])"
327 },
328 "threshold": {
329 "type": "string",
330 "enum": ["error", "warning", "info", "style"],
331 "description": "Minimum severity to report. Default is 'warning'."
332 },
333 "fix": {
334 "type": "boolean",
335 "description": "Apply auto-fixes where available (8 of 15 rules support auto-fix)."
336 }
337 }
338 }),
339 }
340 }
341
342 async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
343 let mut config = DclintConfig::default();
345
346 for rule in &args.ignore {
348 config = config.ignore(rule.as_str());
349 }
350
351 if let Some(threshold) = &args.threshold {
353 config = config.with_threshold(Self::parse_threshold(threshold));
354 }
355
356 let (result, filename) = if args.content.as_ref().is_some_and(|c| !c.trim().is_empty()) {
359 (
361 lint(args.content.as_ref().unwrap(), &config),
362 "<inline>".to_string(),
363 )
364 } else if let Some(compose_file) = &args.compose_file {
365 let path = self.project_path.join(compose_file);
367 (lint_file(&path, &config), compose_file.clone())
368 } else {
369 let default_files = [
371 "docker-compose.yml",
372 "docker-compose.yaml",
373 "compose.yml",
374 "compose.yaml",
375 ];
376
377 let mut found = None;
378 for file in &default_files {
379 let path = self.project_path.join(file);
380 if path.exists() {
381 found = Some((lint_file(&path, &config), file.to_string()));
382 break;
383 }
384 }
385
386 match found {
387 Some((result, filename)) => (result, filename),
388 None => {
389 return Err(DclintError(
390 "No Docker Compose file specified and no docker-compose.yml found in project root".to_string(),
391 ));
392 }
393 }
394 };
395
396 if !result.parse_errors.is_empty() {
398 log::warn!("Docker Compose parse errors: {:?}", result.parse_errors);
399 }
400
401 Ok(Self::format_result(&result, &filename))
402 }
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408 use std::env::temp_dir;
409 use std::fs;
410
411 #[tokio::test]
412 async fn test_dclint_inline_content() {
413 let tool = DclintTool::new(temp_dir());
414 let args = DclintArgs {
415 compose_file: None,
416 content: Some(
417 r#"
418services:
419 web:
420 build: .
421 image: nginx:latest
422"#
423 .to_string(),
424 ),
425 ignore: vec![],
426 threshold: None,
427 fix: false,
428 };
429
430 let result = tool.call(args).await.unwrap();
431 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
432
433 assert!(!parsed["success"].as_bool().unwrap_or(true));
435 assert!(parsed["summary"]["total"].as_u64().unwrap_or(0) >= 1);
436
437 assert!(parsed["decision_context"].is_string());
439 assert!(parsed["action_plan"].is_object());
440 }
441
442 #[tokio::test]
443 async fn test_dclint_ignore_rules() {
444 let tool = DclintTool::new(temp_dir());
445 let args = DclintArgs {
446 compose_file: None,
447 content: Some(
448 r#"
449version: "3.8"
450services:
451 web:
452 image: nginx:latest
453"#
454 .to_string(),
455 ),
456 ignore: vec!["DCL006".to_string(), "DCL011".to_string()],
457 threshold: None,
458 fix: false,
459 };
460
461 let result = tool.call(args).await.unwrap();
462 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
463
464 let all_codes: Vec<&str> = parsed["action_plan"]
466 .as_object()
467 .unwrap()
468 .values()
469 .flat_map(|v| v.as_array().unwrap())
470 .filter_map(|v| v["code"].as_str())
471 .collect();
472
473 assert!(!all_codes.contains(&"DCL006"));
474 assert!(!all_codes.contains(&"DCL011"));
475 }
476
477 #[tokio::test]
478 async fn test_dclint_file() {
479 let temp = temp_dir().join("dclint_test");
480 fs::create_dir_all(&temp).unwrap();
481 let compose_file = temp.join("docker-compose.yml");
482 fs::write(
483 &compose_file,
484 r#"
485name: myproject
486services:
487 web:
488 image: nginx:1.25
489 ports:
490 - "8080:80"
491"#,
492 )
493 .unwrap();
494
495 let tool = DclintTool::new(temp.clone());
496 let args = DclintArgs {
497 compose_file: Some("docker-compose.yml".to_string()),
498 content: None,
499 ignore: vec![],
500 threshold: None,
501 fix: false,
502 };
503
504 let result = tool.call(args).await.unwrap();
505 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
506
507 assert_eq!(parsed["file"], "docker-compose.yml");
509
510 fs::remove_dir_all(&temp).ok();
512 }
513
514 #[tokio::test]
515 async fn test_dclint_valid_compose() {
516 let tool = DclintTool::new(temp_dir());
517 let compose = r#"
518name: myproject
519services:
520 api:
521 image: node:20-alpine
522 ports:
523 - "127.0.0.1:3000:3000"
524 db:
525 image: postgres:16-alpine
526"#;
527
528 let args = DclintArgs {
529 compose_file: None,
530 content: Some(compose.to_string()),
531 ignore: vec![],
532 threshold: None,
533 fix: false,
534 };
535
536 let result = tool.call(args).await.unwrap();
537 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
538
539 assert!(parsed["success"].as_bool().unwrap_or(false));
541 assert!(parsed["decision_context"].is_string());
542 assert_eq!(
544 parsed["summary"]["by_priority"]["critical"]
545 .as_u64()
546 .unwrap_or(99),
547 0
548 );
549 assert_eq!(
550 parsed["summary"]["by_priority"]["high"]
551 .as_u64()
552 .unwrap_or(99),
553 0
554 );
555 }
556}