syncable_cli/analyzer/dclint/rules/
dcl013.rs1use crate::analyzer::dclint::rules::{FixableRule, LintContext, Rule, make_failure};
6use crate::analyzer::dclint::types::{CheckFailure, RuleCategory, Severity};
7
8const CODE: &str = "DCL013";
9const NAME: &str = "service-ports-alphabetical-order";
10const DESCRIPTION: &str = "Service ports should be sorted numerically.";
11const URL: &str = "https://github.com/zavoloklom/docker-compose-linter/blob/main/docs/rules/service-ports-alphabetical-order-rule.md";
12
13pub fn rule() -> impl Rule {
14 FixableRule::new(
15 CODE,
16 NAME,
17 Severity::Style,
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 if service.ports.len() > 1 {
31 let port_strs: Vec<String> = service.ports.iter().map(|p| p.raw.clone()).collect();
32 let mut sorted_ports = port_strs.clone();
33 sorted_ports.sort();
34
35 if port_strs != sorted_ports {
36 let line = service
37 .ports_pos
38 .map(|p| p.line)
39 .unwrap_or(service.position.line);
40
41 let message = format!(
42 "Ports in service \"{}\" are not in alphabetical order. Expected: [{}], got: [{}].",
43 service_name,
44 sorted_ports.join(", "),
45 port_strs.join(", ")
46 );
47
48 failures.push(
49 make_failure(
50 &CODE.into(),
51 NAME,
52 Severity::Style,
53 RuleCategory::Style,
54 message,
55 line,
56 1,
57 true,
58 )
59 .with_data("serviceName", service_name.clone()),
60 );
61 }
62 }
63 }
64
65 failures
66}
67
68fn fix(source: &str) -> Option<String> {
69 let mut result = String::new();
70 let mut modified = false;
71 let mut in_ports_section = false;
72 let mut ports_indent = 0;
73 let mut _service_indent = 0;
74 let mut ports: Vec<(String, String)> = Vec::new(); for line in source.lines() {
77 let trimmed = line.trim();
78 let indent = line.len() - line.trim_start().len();
79
80 if !trimmed.is_empty()
82 && !trimmed.starts_with('#')
83 && !trimmed.starts_with('-')
84 && trimmed.ends_with(':')
85 && indent == 2
86 {
87 _service_indent = indent;
88 }
89
90 if trimmed.starts_with("ports:") {
92 in_ports_section = true;
93 ports_indent = indent;
94 ports.clear();
95 result.push_str(line);
96 result.push('\n');
97 continue;
98 }
99
100 if in_ports_section
102 && !trimmed.is_empty()
103 && indent <= ports_indent
104 && !trimmed.starts_with('-')
105 {
106 let mut sorted_ports = ports.clone();
108 sorted_ports.sort_by(|a, b| a.0.cmp(&b.0));
109
110 if ports.iter().map(|(r, _)| r.clone()).collect::<Vec<_>>()
111 != sorted_ports
112 .iter()
113 .map(|(r, _)| r.clone())
114 .collect::<Vec<_>>()
115 {
116 modified = true;
117 for (_, full_line) in &sorted_ports {
118 result.push_str(full_line);
119 result.push('\n');
120 }
121 } else {
122 for (_, full_line) in &ports {
123 result.push_str(full_line);
124 result.push('\n');
125 }
126 }
127
128 ports.clear();
129 in_ports_section = false;
130 }
131
132 if in_ports_section && trimmed.starts_with('-') {
134 let port_value = trimmed.trim_start_matches('-').trim();
135 let raw = port_value.trim_matches('"').trim_matches('\'').to_string();
136 ports.push((raw, line.to_string()));
137 continue;
138 }
139
140 result.push_str(line);
141 result.push('\n');
142 }
143
144 if in_ports_section && !ports.is_empty() {
146 let mut sorted_ports = ports.clone();
147 sorted_ports.sort_by(|a, b| a.0.cmp(&b.0));
148
149 if ports.iter().map(|(r, _)| r.clone()).collect::<Vec<_>>()
150 != sorted_ports
151 .iter()
152 .map(|(r, _)| r.clone())
153 .collect::<Vec<_>>()
154 {
155 modified = true;
156 for (_, full_line) in &sorted_ports {
157 result.push_str(full_line);
158 result.push('\n');
159 }
160 } else {
161 for (_, full_line) in &ports {
162 result.push_str(full_line);
163 result.push('\n');
164 }
165 }
166 }
167
168 if modified {
169 if !source.ends_with('\n') {
170 result.pop();
171 }
172 Some(result)
173 } else {
174 None
175 }
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181 use crate::analyzer::dclint::parser::parse_compose;
182
183 fn check_yaml(yaml: &str) -> Vec<CheckFailure> {
184 let compose = parse_compose(yaml).unwrap();
185 let ctx = LintContext::new(&compose, yaml, "docker-compose.yml");
186 check(&ctx)
187 }
188
189 #[test]
190 fn test_no_violation_sorted() {
191 let yaml = r#"
192services:
193 web:
194 image: nginx
195 ports:
196 - "3000:3000"
197 - "8080:80"
198"#;
199 assert!(check_yaml(yaml).is_empty());
200 }
201
202 #[test]
203 fn test_violation_unsorted() {
204 let yaml = r#"
205services:
206 web:
207 image: nginx
208 ports:
209 - "8080:80"
210 - "3000:3000"
211"#;
212 let failures = check_yaml(yaml);
213 assert_eq!(failures.len(), 1);
214 assert!(failures[0].message.contains("alphabetical"));
215 }
216
217 #[test]
218 fn test_no_violation_single_port() {
219 let yaml = r#"
220services:
221 web:
222 image: nginx
223 ports:
224 - "8080:80"
225"#;
226 assert!(check_yaml(yaml).is_empty());
227 }
228}