use crate::analyzer::dclint::rules::{FixableRule, LintContext, Rule, make_failure};
use crate::analyzer::dclint::types::{CheckFailure, RuleCategory, Severity};
const CODE: &str = "DCL008";
const NAME: &str = "require-quotes-in-ports";
const DESCRIPTION: &str = "Port mappings should be quoted to avoid YAML parsing issues.";
const URL: &str = "https://github.com/zavoloklom/docker-compose-linter/blob/main/docs/rules/require-quotes-in-ports-rule.md";
pub fn rule() -> impl Rule {
FixableRule::new(
CODE,
NAME,
Severity::Warning,
RuleCategory::Style,
DESCRIPTION,
URL,
check,
fix,
)
}
fn check(ctx: &LintContext) -> Vec<CheckFailure> {
let mut failures = Vec::new();
for (service_name, service) in &ctx.compose.services {
for port in &service.ports {
if port.raw.contains(':') && !port.is_quoted {
let message = format!(
"Port mapping \"{}\" in service \"{}\" should be quoted to prevent YAML interpretation issues (e.g., \"60:60\" being parsed as base-60).",
port.raw, service_name
);
failures.push(
make_failure(
&CODE.into(),
NAME,
Severity::Warning,
RuleCategory::Style,
message,
port.position.line,
port.position.column,
true,
)
.with_data("serviceName", service_name.clone())
.with_data("port", port.raw.clone()),
);
}
}
}
failures
}
fn fix(source: &str) -> Option<String> {
let mut result = String::new();
let mut modified = false;
let mut in_ports_section = false;
let mut ports_indent = 0;
for line in source.lines() {
let trimmed = line.trim();
let indent = line.len() - line.trim_start().len();
if trimmed.starts_with("ports:") {
in_ports_section = true;
ports_indent = indent;
result.push_str(line);
result.push('\n');
continue;
}
if in_ports_section
&& !trimmed.is_empty()
&& indent <= ports_indent
&& !trimmed.starts_with('-')
{
in_ports_section = false;
}
if in_ports_section && trimmed.starts_with('-') {
let after_dash = trimmed.trim_start_matches('-').trim();
if after_dash.contains(':')
&& !after_dash.starts_with('"')
&& !after_dash.starts_with('\'')
&& !after_dash.starts_with('{')
{
result.push_str(&" ".repeat(indent));
result.push_str("- \"");
result.push_str(after_dash);
result.push_str("\"\n");
modified = true;
continue;
}
}
result.push_str(line);
result.push('\n');
}
if modified {
if !source.ends_with('\n') {
result.pop();
}
Some(result)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::analyzer::dclint::parser::parse_compose;
fn check_yaml(yaml: &str) -> Vec<CheckFailure> {
let compose = parse_compose(yaml).unwrap();
let ctx = LintContext::new(&compose, yaml, "docker-compose.yml");
check(&ctx)
}
#[test]
fn test_no_violation_quoted_port() {
let yaml = r#"
services:
web:
image: nginx
ports:
- "8080:80"
"#;
let failures = check_yaml(yaml);
assert!(failures.is_empty() || failures.iter().all(|f| f.code.as_str() == CODE));
}
#[test]
fn test_no_violation_single_port() {
let yaml = r#"
services:
web:
image: nginx
ports:
- 80
"#;
assert!(check_yaml(yaml).is_empty());
}
#[test]
fn test_fix_adds_quotes() {
let yaml = r#"services:
web:
image: nginx
ports:
- 8080:80
"#;
let fixed = fix(yaml).unwrap();
assert!(fixed.contains("\"8080:80\""));
}
#[test]
fn test_fix_no_change_already_quoted() {
let yaml = r#"services:
web:
image: nginx
ports:
- "8080:80"
"#;
assert!(fix(yaml).is_none());
}
}