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 super::error::{ErrorCategory, format_error_for_llm};
19use crate::analyzer::dclint::{DclintConfig, LintResult, RuleCategory, Severity, lint, lint_file};
20
21/// Arguments for the dclint tool
22#[derive(Debug, Deserialize)]
23pub struct DclintArgs {
24    /// Path to docker-compose.yml (relative to project root) or inline content
25    #[serde(default)]
26    pub compose_file: Option<String>,
27
28    /// Inline Docker Compose content to lint (alternative to path)
29    #[serde(default)]
30    pub content: Option<String>,
31
32    /// Rules to ignore (e.g., ["DCL001", "DCL006"])
33    #[serde(default)]
34    pub ignore: Vec<String>,
35
36    /// Minimum severity threshold: "error", "warning", "info", "style"
37    #[serde(default)]
38    pub threshold: Option<String>,
39
40    /// Whether to apply auto-fixes (if available)
41    #[serde(default)]
42    pub fix: bool,
43}
44
45/// Error type for dclint tool
46#[derive(Debug, thiserror::Error)]
47#[error("Dclint error: {0}")]
48pub struct DclintError(String);
49
50/// Tool to lint Docker Compose files natively
51#[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, // Default
68        }
69    }
70
71    /// Get priority based on severity and category
72    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    /// Get actionable fix recommendation for a rule
85    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    /// Get documentation URL for a rule
129    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    /// Format result optimized for agent decision-making
159    fn format_result(result: &LintResult, filename: &str) -> String {
160        // Categorize and enrich failures
161        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        // Group by priority for agent decision ordering
185        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        // Group by category for thematic fixes
207        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        // Build decision context
215        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        // Count fixable issues
230        let fixable_count = enriched_failures
231            .iter()
232            .filter(|f| f["fixable"] == true)
233            .count();
234
235        // Build agent-optimized output
236        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        // Add quick fixes summary for agent
266        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        // Build configuration
365        let mut config = DclintConfig::default();
366
367        // Apply ignored rules
368        for rule in &args.ignore {
369            config = config.ignore(rule.as_str());
370        }
371
372        // Apply threshold
373        if let Some(threshold) = &args.threshold {
374            config = config.with_threshold(Self::parse_threshold(threshold));
375        }
376
377        // Determine source, filename, and lint
378        // IMPORTANT: Treat empty content as None - fixes AI agents passing empty strings
379        let (result, filename) = if args.content.as_ref().is_some_and(|c| !c.trim().is_empty()) {
380            // Lint non-empty inline content
381            let content = args.content.as_ref().unwrap();
382
383            // Check for non-compose YAML (no services section)
384            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            // Lint file
400            let path = self.project_path.join(compose_file);
401
402            // Check if file exists
403            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            // Check if file is empty
418            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            // Default: look for docker-compose.yml in project root
436            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        // Handle parse errors - return structured error for agent
471        if !result.parse_errors.is_empty() {
472            log::warn!("Docker Compose parse errors: {:?}", result.parse_errors);
473            // If we have ONLY parse errors and no lint results, treat as validation failure
474            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        // Should detect DCL001 (build+image)
525        assert!(!parsed["success"].as_bool().unwrap_or(true));
526        assert!(parsed["summary"]["total"].as_u64().unwrap_or(0) >= 1);
527
528        // Check new fields exist
529        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        // DCL006 and DCL011 should be ignored
556        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        // Well-formed compose file should have few/no critical issues
599        assert_eq!(parsed["file"], "docker-compose.yml");
600
601        // Cleanup
602        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        // Well-structured compose file should pass (no errors)
631        assert!(parsed["success"].as_bool().unwrap_or(false));
632        assert!(parsed["decision_context"].is_string());
633        // Should not have critical or high priority issues
634        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    // Unit tests for internal helper functions
649
650    #[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        // Case insensitive
657        assert_eq!(DclintTool::parse_threshold("ERROR"), Severity::Error);
658        assert_eq!(DclintTool::parse_threshold("Warning"), Severity::Warning);
659        // Invalid defaults to Warning
660        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        // Critical: Error + Security
669        assert_eq!(
670            DclintTool::get_priority(Severity::Error, RuleCategory::Security),
671            "critical"
672        );
673
674        // High: Error + other, Warning + Security
675        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        // Medium: Warning + BestPractice or other
685        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        // Low: Info or Style severity
695        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        // DCL001 - build+image conflict
712        let rec = DclintTool::get_fix_recommendation("DCL001");
713        assert!(rec.contains("build") || rec.contains("image"));
714
715        // DCL005 - port interface binding
716        let rec = DclintTool::get_fix_recommendation("DCL005");
717        assert!(rec.contains("interface") || rec.contains("127.0.0.1"));
718
719        // DCL006 - version field
720        let rec = DclintTool::get_fix_recommendation("DCL006");
721        assert!(rec.contains("version") || rec.contains("Remove"));
722
723        // DCL011 - explicit image tags
724        let rec = DclintTool::get_fix_recommendation("DCL011");
725        assert!(rec.contains("tag") || rec.contains("latest"));
726
727        // Unknown rule - generic guidance
728        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        // Valid rule codes should return URLs
735        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        // Unknown rule codes return empty string
743        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}