syncable_cli/analyzer/dclint/rules/
dcl010.rs

1//! DCL010: service-dependencies-alphabetical-order
2//!
3//! Service dependencies 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 = "DCL010";
9const NAME: &str = "service-dependencies-alphabetical-order";
10const DESCRIPTION: &str = "Service dependencies should be sorted alphabetically.";
11const URL: &str = "https://github.com/zavoloklom/docker-compose-linter/blob/main/docs/rules/service-dependencies-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.depends_on.len() > 1 {
31            let mut sorted = service.depends_on.clone();
32            sorted.sort();
33
34            if service.depends_on != sorted {
35                let line = service
36                    .depends_on_pos
37                    .map(|p| p.line)
38                    .unwrap_or(service.position.line);
39
40                let message = format!(
41                    "Dependencies in service \"{}\" are not in alphabetical order. Expected: [{}], got: [{}].",
42                    service_name,
43                    sorted.join(", "),
44                    service.depends_on.join(", ")
45                );
46
47                failures.push(
48                    make_failure(
49                        &CODE.into(),
50                        NAME,
51                        Severity::Style,
52                        RuleCategory::Style,
53                        message,
54                        line,
55                        1,
56                        true,
57                    )
58                    .with_data("serviceName", service_name.clone())
59                    .with_data("expected", sorted.join(", "))
60                    .with_data("actual", service.depends_on.join(", ")),
61                );
62            }
63        }
64    }
65
66    failures
67}
68
69fn fix(source: &str) -> Option<String> {
70    // This is a simplified fix that works for array-style depends_on
71    // A full implementation would need proper YAML manipulation
72    let mut result = String::new();
73    let mut modified = false;
74    let mut in_depends_on = false;
75    let mut depends_on_indent = 0;
76    let mut deps: Vec<String> = Vec::new();
77    let mut _deps_start_line = 0;
78    let mut collected_lines: Vec<String> = Vec::new();
79
80    for (idx, line) in source.lines().enumerate() {
81        let trimmed = line.trim();
82        let indent = line.len() - line.trim_start().len();
83
84        // Track if we're in a depends_on section
85        if trimmed.starts_with("depends_on:") {
86            in_depends_on = true;
87            depends_on_indent = indent;
88            _deps_start_line = idx;
89            deps.clear();
90            result.push_str(line);
91            result.push('\n');
92            continue;
93        }
94
95        // Collect dependencies
96        if in_depends_on && trimmed.starts_with('-') && indent > depends_on_indent {
97            let dep = trimmed.trim_start_matches('-').trim().to_string();
98            deps.push(dep);
99            collected_lines.push(line.to_string());
100            continue;
101        }
102
103        // Exit depends_on section
104        if in_depends_on && (!trimmed.starts_with('-') || indent <= depends_on_indent) {
105            // Sort and output deps
106            let mut sorted_deps = deps.clone();
107            sorted_deps.sort();
108
109            if deps != sorted_deps {
110                modified = true;
111                for dep in &sorted_deps {
112                    result.push_str(&" ".repeat(depends_on_indent + 2));
113                    result.push_str("- ");
114                    result.push_str(dep);
115                    result.push('\n');
116                }
117            } else {
118                for dep_line in &collected_lines {
119                    result.push_str(dep_line);
120                    result.push('\n');
121                }
122            }
123
124            deps.clear();
125            collected_lines.clear();
126            in_depends_on = false;
127        }
128
129        result.push_str(line);
130        result.push('\n');
131    }
132
133    // Handle case where depends_on is at the end of file
134    if in_depends_on && !deps.is_empty() {
135        let mut sorted_deps = deps.clone();
136        sorted_deps.sort();
137
138        if deps != sorted_deps {
139            modified = true;
140            for dep in &sorted_deps {
141                result.push_str(&" ".repeat(depends_on_indent + 2));
142                result.push_str("- ");
143                result.push_str(dep);
144                result.push('\n');
145            }
146        } else {
147            for dep_line in &collected_lines {
148                result.push_str(dep_line);
149                result.push('\n');
150            }
151        }
152    }
153
154    if modified {
155        if !source.ends_with('\n') {
156            result.pop();
157        }
158        Some(result)
159    } else {
160        None
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use crate::analyzer::dclint::parser::parse_compose;
168
169    fn check_yaml(yaml: &str) -> Vec<CheckFailure> {
170        let compose = parse_compose(yaml).unwrap();
171        let ctx = LintContext::new(&compose, yaml, "docker-compose.yml");
172        check(&ctx)
173    }
174
175    #[test]
176    fn test_no_violation_sorted() {
177        let yaml = r#"
178services:
179  web:
180    image: nginx
181    depends_on:
182      - cache
183      - db
184"#;
185        assert!(check_yaml(yaml).is_empty());
186    }
187
188    #[test]
189    fn test_no_violation_single_dep() {
190        let yaml = r#"
191services:
192  web:
193    image: nginx
194    depends_on:
195      - db
196"#;
197        assert!(check_yaml(yaml).is_empty());
198    }
199
200    #[test]
201    fn test_violation_unsorted() {
202        let yaml = r#"
203services:
204  web:
205    image: nginx
206    depends_on:
207      - db
208      - cache
209"#;
210        let failures = check_yaml(yaml);
211        assert_eq!(failures.len(), 1);
212        assert!(failures[0].message.contains("alphabetical"));
213    }
214
215    #[test]
216    fn test_violation_multiple_unsorted() {
217        let yaml = r#"
218services:
219  web:
220    image: nginx
221    depends_on:
222      - redis
223      - db
224      - cache
225"#;
226        let failures = check_yaml(yaml);
227        assert_eq!(failures.len(), 1);
228    }
229}