syncable_cli/agent/tools/
dclint.rs

1//! Dclint tool - Native Docker Compose linting using Rig's Tool trait
2//!
3//! Provides native Docker Compose linting without requiring the external dclint binary.
4//! Implements docker-compose-linter rules with full pragma support.
5//!
6//! Output is optimized for AI agent decision-making with:
7//! - Categorized issues (security, best-practice, style, 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::dclint::{DclintConfig, LintResult, RuleCategory, Severity, lint, lint_file};
19
20/// Arguments for the dclint tool
21#[derive(Debug, Deserialize)]
22pub struct DclintArgs {
23    /// Path to docker-compose.yml (relative to project root) or inline content
24    #[serde(default)]
25    pub compose_file: Option<String>,
26
27    /// Inline Docker Compose content to lint (alternative to path)
28    #[serde(default)]
29    pub content: Option<String>,
30
31    /// Rules to ignore (e.g., ["DCL001", "DCL006"])
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    /// Whether to apply auto-fixes (if available)
40    #[serde(default)]
41    pub fix: bool,
42}
43
44/// Error type for dclint tool
45#[derive(Debug, thiserror::Error)]
46#[error("Dclint error: {0}")]
47pub struct DclintError(String);
48
49/// Tool to lint Docker Compose files natively
50#[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, // Default
67        }
68    }
69
70    /// Get priority based on severity and category
71    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    /// Get actionable fix recommendation for a rule
84    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    /// Get documentation URL for a rule
128    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    /// Format result optimized for agent decision-making
158    fn format_result(result: &LintResult, filename: &str) -> String {
159        // Categorize and enrich failures
160        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        // Group by priority for agent decision ordering
184        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        // Group by category for thematic fixes
206        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        // Build decision context
214        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        // Count fixable issues
229        let fixable_count = enriched_failures
230            .iter()
231            .filter(|f| f["fixable"] == true)
232            .count();
233
234        // Build agent-optimized output
235        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        // Add quick fixes summary for agent
265        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        // Build configuration
344        let mut config = DclintConfig::default();
345
346        // Apply ignored rules
347        for rule in &args.ignore {
348            config = config.ignore(rule.as_str());
349        }
350
351        // Apply threshold
352        if let Some(threshold) = &args.threshold {
353            config = config.with_threshold(Self::parse_threshold(threshold));
354        }
355
356        // Determine source, filename, and lint
357        let (result, filename) = if let Some(content) = &args.content {
358            // Lint inline content
359            (lint(content, &config), "<inline>".to_string())
360        } else if let Some(compose_file) = &args.compose_file {
361            // Lint file
362            let path = self.project_path.join(compose_file);
363            (lint_file(&path, &config), compose_file.clone())
364        } else {
365            // Default: look for docker-compose.yml in project root
366            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        // Check for parse errors
393        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        // Should detect DCL001 (build+image)
430        assert!(!parsed["success"].as_bool().unwrap_or(true));
431        assert!(parsed["summary"]["total"].as_u64().unwrap_or(0) >= 1);
432
433        // Check new fields exist
434        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        // DCL006 and DCL011 should be ignored
461        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        // Well-formed compose file should have few/no critical issues
504        assert_eq!(parsed["file"], "docker-compose.yml");
505
506        // Cleanup
507        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        // Well-structured compose file should pass (no errors)
536        assert!(parsed["success"].as_bool().unwrap_or(false));
537        assert!(parsed["decision_context"].is_string());
538        // Should not have critical or high priority issues
539        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}