syncable_cli/analyzer/dclint/rules/
dcl001.rs

1//! DCL001: no-build-and-image
2//!
3//! Service cannot have both `build` and `image` fields (unless `pull_policy` is set).
4
5use crate::analyzer::dclint::rules::{LintContext, Rule, SimpleRule, make_failure};
6use crate::analyzer::dclint::types::{CheckFailure, RuleCategory, Severity};
7
8const CODE: &str = "DCL001";
9const NAME: &str = "no-build-and-image";
10const DESCRIPTION: &str = "Each service must use either `build` or `image`, not both.";
11const URL: &str = "https://github.com/zavoloklom/docker-compose-linter/blob/main/docs/rules/no-build-and-image-rule.md";
12
13pub fn rule() -> impl Rule {
14    SimpleRule::new(
15        CODE,
16        NAME,
17        Severity::Error,
18        RuleCategory::BestPractice,
19        DESCRIPTION,
20        URL,
21        check,
22    )
23}
24
25fn check(ctx: &LintContext) -> Vec<CheckFailure> {
26    let mut failures = Vec::new();
27
28    for (service_name, service) in &ctx.compose.services {
29        // Check if service has both build and image
30        let has_build = service.build.is_some();
31        let has_image = service.image.is_some();
32        let has_pull_policy = service.pull_policy.is_some();
33
34        // Having both is only allowed if pull_policy is set
35        if has_build && has_image && !has_pull_policy {
36            let line = service
37                .build_pos
38                .map(|p| p.line)
39                .or(service.position.line.into())
40                .unwrap_or(1);
41
42            let message = format!(
43                "Service \"{}\" is using both \"build\" and \"image\". Use one of them, but not both.",
44                service_name
45            );
46
47            failures.push(
48                make_failure(
49                    &CODE.into(),
50                    NAME,
51                    Severity::Error,
52                    RuleCategory::BestPractice,
53                    message,
54                    line,
55                    1,
56                    false,
57                )
58                .with_data("serviceName", service_name.clone()),
59            );
60        }
61    }
62
63    failures
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69    use crate::analyzer::dclint::parser::parse_compose;
70
71    fn check_yaml(yaml: &str) -> Vec<CheckFailure> {
72        let compose = parse_compose(yaml).unwrap();
73        let ctx = LintContext::new(&compose, yaml, "docker-compose.yml");
74        check(&ctx)
75    }
76
77    #[test]
78    fn test_no_violation_image_only() {
79        let yaml = r#"
80services:
81  web:
82    image: nginx:latest
83"#;
84        assert!(check_yaml(yaml).is_empty());
85    }
86
87    #[test]
88    fn test_no_violation_build_only() {
89        let yaml = r#"
90services:
91  web:
92    build: .
93"#;
94        assert!(check_yaml(yaml).is_empty());
95    }
96
97    #[test]
98    fn test_violation_build_and_image() {
99        let yaml = r#"
100services:
101  web:
102    build: .
103    image: myapp:latest
104"#;
105        let failures = check_yaml(yaml);
106        assert_eq!(failures.len(), 1);
107        assert!(failures[0].message.contains("web"));
108        assert!(failures[0].message.contains("build"));
109        assert!(failures[0].message.contains("image"));
110    }
111
112    #[test]
113    fn test_no_violation_with_pull_policy() {
114        let yaml = r#"
115services:
116  web:
117    build: .
118    image: myapp:latest
119    pull_policy: build
120"#;
121        assert!(check_yaml(yaml).is_empty());
122    }
123
124    #[test]
125    fn test_multiple_services() {
126        let yaml = r#"
127services:
128  web:
129    build: .
130    image: myapp:latest
131  db:
132    image: postgres:15
133  api:
134    build: ./api
135    image: myapi:v1
136"#;
137        let failures = check_yaml(yaml);
138        assert_eq!(failures.len(), 2);
139    }
140}