syncable_cli/analyzer/dclint/rules/
dcl014.rs

1//! DCL014: services-alphabetical-order
2//!
3//! Services should be sorted alphabetically.
4
5use crate::analyzer::dclint::rules::{FixableRule, LintContext, Rule, make_failure};
6use crate::analyzer::dclint::types::{CheckFailure, RuleCategory, Severity};
7
8const CODE: &str = "DCL014";
9const NAME: &str = "services-alphabetical-order";
10const DESCRIPTION: &str = "Services should be defined in alphabetical order.";
11const URL: &str = "https://github.com/zavoloklom/docker-compose-linter/blob/main/docs/rules/services-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    let service_names: Vec<String> = ctx.compose.services.keys().cloned().collect();
30
31    if service_names.len() > 1 {
32        let mut sorted_names = service_names.clone();
33        sorted_names.sort();
34
35        // Check if they're already sorted
36        let current_order: Vec<String> = {
37            // We need to get the actual order from the source
38            // The HashMap doesn't preserve order, so we check against sorted
39            let mut names: Vec<_> = ctx.compose.services.keys().cloned().collect();
40            names.sort_by_key(|name| {
41                ctx.compose
42                    .services
43                    .get(name)
44                    .map(|s| s.position.line)
45                    .unwrap_or(u32::MAX)
46            });
47            names
48        };
49
50        if current_order != sorted_names {
51            let line = ctx.compose.services_pos.map(|p| p.line).unwrap_or(1);
52
53            let message = format!(
54                "Services are not in alphabetical order. Expected: [{}], got: [{}].",
55                sorted_names.join(", "),
56                current_order.join(", ")
57            );
58
59            failures.push(
60                make_failure(
61                    &CODE.into(),
62                    NAME,
63                    Severity::Style,
64                    RuleCategory::Style,
65                    message,
66                    line,
67                    1,
68                    true,
69                )
70                .with_data("expected", sorted_names.join(", "))
71                .with_data("actual", current_order.join(", ")),
72            );
73        }
74    }
75
76    failures
77}
78
79fn fix(_source: &str) -> Option<String> {
80    // Full service reordering requires proper YAML AST manipulation
81    // This is complex and would need yaml-rust2's Document API for proper handling
82    None
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88    use crate::analyzer::dclint::parser::parse_compose;
89
90    fn check_yaml(yaml: &str) -> Vec<CheckFailure> {
91        let compose = parse_compose(yaml).unwrap();
92        let ctx = LintContext::new(&compose, yaml, "docker-compose.yml");
93        check(&ctx)
94    }
95
96    #[test]
97    fn test_no_violation_sorted() {
98        let yaml = r#"
99services:
100  api:
101    image: api
102  db:
103    image: postgres
104  web:
105    image: nginx
106"#;
107        assert!(check_yaml(yaml).is_empty());
108    }
109
110    #[test]
111    fn test_violation_unsorted() {
112        let yaml = r#"
113services:
114  web:
115    image: nginx
116  api:
117    image: api
118  db:
119    image: postgres
120"#;
121        let failures = check_yaml(yaml);
122        assert_eq!(failures.len(), 1);
123        assert!(failures[0].message.contains("alphabetical"));
124    }
125
126    #[test]
127    fn test_no_violation_single_service() {
128        let yaml = r#"
129services:
130  web:
131    image: nginx
132"#;
133        assert!(check_yaml(yaml).is_empty());
134    }
135}