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() && !trimmed.starts_with('#') && !trimmed.starts_with('-') {
82 if trimmed.ends_with(':') && indent == 2 {
83 service_indent = indent;
84 }
85 }
86
87 if trimmed.starts_with("ports:") {
89 in_ports_section = true;
90 ports_indent = indent;
91 ports.clear();
92 result.push_str(line);
93 result.push('\n');
94 continue;
95 }
96
97 if in_ports_section
99 && !trimmed.is_empty()
100 && indent <= ports_indent
101 && !trimmed.starts_with('-')
102 {
103 let mut sorted_ports = ports.clone();
105 sorted_ports.sort_by(|a, b| a.0.cmp(&b.0));
106
107 if ports.iter().map(|(r, _)| r.clone()).collect::<Vec<_>>()
108 != sorted_ports
109 .iter()
110 .map(|(r, _)| r.clone())
111 .collect::<Vec<_>>()
112 {
113 modified = true;
114 for (_, full_line) in &sorted_ports {
115 result.push_str(full_line);
116 result.push('\n');
117 }
118 } else {
119 for (_, full_line) in &ports {
120 result.push_str(full_line);
121 result.push('\n');
122 }
123 }
124
125 ports.clear();
126 in_ports_section = false;
127 }
128
129 if in_ports_section && trimmed.starts_with('-') {
131 let port_value = trimmed.trim_start_matches('-').trim();
132 let raw = port_value.trim_matches('"').trim_matches('\'').to_string();
133 ports.push((raw, line.to_string()));
134 continue;
135 }
136
137 result.push_str(line);
138 result.push('\n');
139 }
140
141 if in_ports_section && !ports.is_empty() {
143 let mut sorted_ports = ports.clone();
144 sorted_ports.sort_by(|a, b| a.0.cmp(&b.0));
145
146 if ports.iter().map(|(r, _)| r.clone()).collect::<Vec<_>>()
147 != sorted_ports
148 .iter()
149 .map(|(r, _)| r.clone())
150 .collect::<Vec<_>>()
151 {
152 modified = true;
153 for (_, full_line) in &sorted_ports {
154 result.push_str(full_line);
155 result.push('\n');
156 }
157 } else {
158 for (_, full_line) in &ports {
159 result.push_str(full_line);
160 result.push('\n');
161 }
162 }
163 }
164
165 if modified {
166 if !source.ends_with('\n') {
167 result.pop();
168 }
169 Some(result)
170 } else {
171 None
172 }
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178 use crate::analyzer::dclint::parser::parse_compose;
179
180 fn check_yaml(yaml: &str) -> Vec<CheckFailure> {
181 let compose = parse_compose(yaml).unwrap();
182 let ctx = LintContext::new(&compose, yaml, "docker-compose.yml");
183 check(&ctx)
184 }
185
186 #[test]
187 fn test_no_violation_sorted() {
188 let yaml = r#"
189services:
190 web:
191 image: nginx
192 ports:
193 - "3000:3000"
194 - "8080:80"
195"#;
196 assert!(check_yaml(yaml).is_empty());
197 }
198
199 #[test]
200 fn test_violation_unsorted() {
201 let yaml = r#"
202services:
203 web:
204 image: nginx
205 ports:
206 - "8080:80"
207 - "3000:3000"
208"#;
209 let failures = check_yaml(yaml);
210 assert_eq!(failures.len(), 1);
211 assert!(failures[0].message.contains("alphabetical"));
212 }
213
214 #[test]
215 fn test_no_violation_single_port() {
216 let yaml = r#"
217services:
218 web:
219 image: nginx
220 ports:
221 - "8080:80"
222"#;
223 assert!(check_yaml(yaml).is_empty());
224 }
225}