syncable_cli/analyzer/dclint/rules/
dcl008.rs1use crate::analyzer::dclint::rules::{FixableRule, LintContext, Rule, make_failure};
6use crate::analyzer::dclint::types::{CheckFailure, RuleCategory, Severity};
7
8const CODE: &str = "DCL008";
9const NAME: &str = "require-quotes-in-ports";
10const DESCRIPTION: &str = "Port mappings should be quoted to avoid YAML parsing issues.";
11const URL: &str = "https://github.com/zavoloklom/docker-compose-linter/blob/main/docs/rules/require-quotes-in-ports-rule.md";
12
13pub fn rule() -> impl Rule {
14 FixableRule::new(
15 CODE,
16 NAME,
17 Severity::Warning,
18 RuleCategory::Style,
19 DESCRIPTION,
20 URL,
21 check,
22 fix,
23 )
24}
25
26fn check(ctx: &LintContext) -> Vec<CheckFailure> {
27 let mut failures = Vec::new();
28
29 for (service_name, service) in &ctx.compose.services {
30 for port in &service.ports {
31 if port.raw.contains(':') && !port.is_quoted {
33 let message = format!(
34 "Port mapping \"{}\" in service \"{}\" should be quoted to prevent YAML interpretation issues (e.g., \"60:60\" being parsed as base-60).",
35 port.raw, service_name
36 );
37
38 failures.push(
39 make_failure(
40 &CODE.into(),
41 NAME,
42 Severity::Warning,
43 RuleCategory::Style,
44 message,
45 port.position.line,
46 port.position.column,
47 true,
48 )
49 .with_data("serviceName", service_name.clone())
50 .with_data("port", port.raw.clone()),
51 );
52 }
53 }
54 }
55
56 failures
57}
58
59fn fix(source: &str) -> Option<String> {
60 let mut result = String::new();
61 let mut modified = false;
62 let mut in_ports_section = false;
63 let mut ports_indent = 0;
64
65 for line in source.lines() {
66 let trimmed = line.trim();
67 let indent = line.len() - line.trim_start().len();
68
69 if trimmed.starts_with("ports:") {
71 in_ports_section = true;
72 ports_indent = indent;
73 result.push_str(line);
74 result.push('\n');
75 continue;
76 }
77
78 if in_ports_section
80 && !trimmed.is_empty()
81 && indent <= ports_indent
82 && !trimmed.starts_with('-')
83 {
84 in_ports_section = false;
85 }
86
87 if in_ports_section && trimmed.starts_with('-') {
89 let after_dash = trimmed.trim_start_matches('-').trim();
90
91 if after_dash.contains(':')
93 && !after_dash.starts_with('"')
94 && !after_dash.starts_with('\'')
95 && !after_dash.starts_with('{')
96 {
98 result.push_str(&" ".repeat(indent));
99 result.push_str("- \"");
100 result.push_str(after_dash);
101 result.push_str("\"\n");
102 modified = true;
103 continue;
104 }
105 }
106
107 result.push_str(line);
108 result.push('\n');
109 }
110
111 if modified {
112 if !source.ends_with('\n') {
113 result.pop();
114 }
115 Some(result)
116 } else {
117 None
118 }
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124 use crate::analyzer::dclint::parser::parse_compose;
125
126 fn check_yaml(yaml: &str) -> Vec<CheckFailure> {
127 let compose = parse_compose(yaml).unwrap();
128 let ctx = LintContext::new(&compose, yaml, "docker-compose.yml");
129 check(&ctx)
130 }
131
132 #[test]
133 fn test_no_violation_quoted_port() {
134 let yaml = r#"
135services:
136 web:
137 image: nginx
138 ports:
139 - "8080:80"
140"#;
141 let failures = check_yaml(yaml);
143 assert!(failures.is_empty() || failures.iter().all(|f| f.code.as_str() == CODE));
145 }
146
147 #[test]
148 fn test_no_violation_single_port() {
149 let yaml = r#"
150services:
151 web:
152 image: nginx
153 ports:
154 - 80
155"#;
156 assert!(check_yaml(yaml).is_empty());
158 }
159
160 #[test]
161 fn test_fix_adds_quotes() {
162 let yaml = r#"services:
163 web:
164 image: nginx
165 ports:
166 - 8080:80
167"#;
168 let fixed = fix(yaml).unwrap();
169 assert!(fixed.contains("\"8080:80\""));
170 }
171
172 #[test]
173 fn test_fix_no_change_already_quoted() {
174 let yaml = r#"services:
175 web:
176 image: nginx
177 ports:
178 - "8080:80"
179"#;
180 assert!(fix(yaml).is_none());
181 }
182}