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        // IMPORTANT: Treat empty content as None - fixes AI agents passing empty strings
358        let (result, filename) = if args.content.as_ref().is_some_and(|c| !c.trim().is_empty()) {
359            // Lint non-empty inline content
360            (
361                lint(args.content.as_ref().unwrap(), &config),
362                "<inline>".to_string(),
363            )
364        } else if let Some(compose_file) = &args.compose_file {
365            // Lint file
366            let path = self.project_path.join(compose_file);
367            (lint_file(&path, &config), compose_file.clone())
368        } else {
369            // Default: look for docker-compose.yml in project root
370            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        // Check for parse errors
397        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        // Should detect DCL001 (build+image)
434        assert!(!parsed["success"].as_bool().unwrap_or(true));
435        assert!(parsed["summary"]["total"].as_u64().unwrap_or(0) >= 1);
436
437        // Check new fields exist
438        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        // DCL006 and DCL011 should be ignored
465        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        // Well-formed compose file should have few/no critical issues
508        assert_eq!(parsed["file"], "docker-compose.yml");
509
510        // Cleanup
511        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        // Well-structured compose file should pass (no errors)
540        assert!(parsed["success"].as_bool().unwrap_or(false));
541        assert!(parsed["decision_context"].is_string());
542        // Should not have critical or high priority issues
543        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}