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()
82            && !trimmed.starts_with('#')
83            && !trimmed.starts_with('-')
84            && trimmed.ends_with(':')
85            && indent == 2
86        {
87            _service_indent = indent;
88        }
89
90        // Track if we're in a ports section
91        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        // Exit ports section when indent decreases
101        if in_ports_section
102            && !trimmed.is_empty()
103            && indent <= ports_indent
104            && !trimmed.starts_with('-')
105        {
106            // Sort and output ports
107            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        // Collect port entries
133        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    // Handle case where ports section is at the end
145    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}