syncable_cli/analyzer/dclint/rules/
dcl013.rs

1//! DCL013: service-ports-alphabetical-order
2//!
3//! Service ports should be sorted alphabetically/numerically.
4
5use 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(); // (raw, full line)
75
76    for line in source.lines() {
77        let trimmed = line.trim();
78        let indent = line.len() - line.trim_start().len();
79
80        // Track service indent level
81        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        // Track if we're in a ports section
88        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        // Exit ports section when indent decreases
98        if in_ports_section
99            && !trimmed.is_empty()
100            && indent <= ports_indent
101            && !trimmed.starts_with('-')
102        {
103            // Sort and output ports
104            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        // Collect port entries
130        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    // Handle case where ports section is at the end
142    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}