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 let Some(content) = &args.content {
358 (lint(content, &config), "<inline>".to_string())
360 } else if let Some(compose_file) = &args.compose_file {
361 let path = self.project_path.join(compose_file);
363 (lint_file(&path, &config), compose_file.clone())
364 } else {
365 let default_files = [
367 "docker-compose.yml",
368 "docker-compose.yaml",
369 "compose.yml",
370 "compose.yaml",
371 ];
372
373 let mut found = None;
374 for file in &default_files {
375 let path = self.project_path.join(file);
376 if path.exists() {
377 found = Some((lint_file(&path, &config), file.to_string()));
378 break;
379 }
380 }
381
382 match found {
383 Some((result, filename)) => (result, filename),
384 None => {
385 return Err(DclintError(
386 "No Docker Compose file specified and no docker-compose.yml found in project root".to_string(),
387 ));
388 }
389 }
390 };
391
392 if !result.parse_errors.is_empty() {
394 log::warn!("Docker Compose parse errors: {:?}", result.parse_errors);
395 }
396
397 Ok(Self::format_result(&result, &filename))
398 }
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404 use std::env::temp_dir;
405 use std::fs;
406
407 #[tokio::test]
408 async fn test_dclint_inline_content() {
409 let tool = DclintTool::new(temp_dir());
410 let args = DclintArgs {
411 compose_file: None,
412 content: Some(
413 r#"
414services:
415 web:
416 build: .
417 image: nginx:latest
418"#
419 .to_string(),
420 ),
421 ignore: vec![],
422 threshold: None,
423 fix: false,
424 };
425
426 let result = tool.call(args).await.unwrap();
427 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
428
429 assert!(!parsed["success"].as_bool().unwrap_or(true));
431 assert!(parsed["summary"]["total"].as_u64().unwrap_or(0) >= 1);
432
433 assert!(parsed["decision_context"].is_string());
435 assert!(parsed["action_plan"].is_object());
436 }
437
438 #[tokio::test]
439 async fn test_dclint_ignore_rules() {
440 let tool = DclintTool::new(temp_dir());
441 let args = DclintArgs {
442 compose_file: None,
443 content: Some(
444 r#"
445version: "3.8"
446services:
447 web:
448 image: nginx:latest
449"#
450 .to_string(),
451 ),
452 ignore: vec!["DCL006".to_string(), "DCL011".to_string()],
453 threshold: None,
454 fix: false,
455 };
456
457 let result = tool.call(args).await.unwrap();
458 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
459
460 let all_codes: Vec<&str> = parsed["action_plan"]
462 .as_object()
463 .unwrap()
464 .values()
465 .flat_map(|v| v.as_array().unwrap())
466 .filter_map(|v| v["code"].as_str())
467 .collect();
468
469 assert!(!all_codes.contains(&"DCL006"));
470 assert!(!all_codes.contains(&"DCL011"));
471 }
472
473 #[tokio::test]
474 async fn test_dclint_file() {
475 let temp = temp_dir().join("dclint_test");
476 fs::create_dir_all(&temp).unwrap();
477 let compose_file = temp.join("docker-compose.yml");
478 fs::write(
479 &compose_file,
480 r#"
481name: myproject
482services:
483 web:
484 image: nginx:1.25
485 ports:
486 - "8080:80"
487"#,
488 )
489 .unwrap();
490
491 let tool = DclintTool::new(temp.clone());
492 let args = DclintArgs {
493 compose_file: Some("docker-compose.yml".to_string()),
494 content: None,
495 ignore: vec![],
496 threshold: None,
497 fix: false,
498 };
499
500 let result = tool.call(args).await.unwrap();
501 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
502
503 assert_eq!(parsed["file"], "docker-compose.yml");
505
506 fs::remove_dir_all(&temp).ok();
508 }
509
510 #[tokio::test]
511 async fn test_dclint_valid_compose() {
512 let tool = DclintTool::new(temp_dir());
513 let compose = r#"
514name: myproject
515services:
516 api:
517 image: node:20-alpine
518 ports:
519 - "127.0.0.1:3000:3000"
520 db:
521 image: postgres:16-alpine
522"#;
523
524 let args = DclintArgs {
525 compose_file: None,
526 content: Some(compose.to_string()),
527 ignore: vec![],
528 threshold: None,
529 fix: false,
530 };
531
532 let result = tool.call(args).await.unwrap();
533 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
534
535 assert!(parsed["success"].as_bool().unwrap_or(false));
537 assert!(parsed["decision_context"].is_string());
538 assert_eq!(
540 parsed["summary"]["by_priority"]["critical"]
541 .as_u64()
542 .unwrap_or(99),
543 0
544 );
545 assert_eq!(
546 parsed["summary"]["by_priority"]["high"]
547 .as_u64()
548 .unwrap_or(99),
549 0
550 );
551 }
552}