syncable_cli/analyzer/hadolint/
config.rs

1//! Configuration for the hadolint-rs linter.
2//!
3//! Supports configuration from:
4//! - Programmatic defaults
5//! - YAML config files (.hadolint.yaml)
6//!
7//! Configuration priority (highest to lowest):
8//! 1. Programmatic overrides
9//! 2. Config file settings
10//! 3. Defaults
11
12use crate::analyzer::hadolint::types::{RuleCode, Severity};
13use std::collections::{HashMap, HashSet};
14use std::path::Path;
15
16/// Label validation types for DL3049-DL3056 rules.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum LabelType {
19    /// Email address format
20    Email,
21    /// Git commit hash
22    GitHash,
23    /// Raw text (no validation)
24    RawText,
25    /// RFC3339 timestamp
26    Rfc3339,
27    /// Semantic versioning
28    SemVer,
29    /// SPDX license identifier
30    Spdx,
31    /// URL format
32    Url,
33}
34
35impl LabelType {
36    /// Parse a label type from a string.
37    pub fn parse(s: &str) -> Option<Self> {
38        match s.to_lowercase().as_str() {
39            "email" => Some(Self::Email),
40            "hash" => Some(Self::GitHash),
41            "text" | "" => Some(Self::RawText),
42            "rfc3339" => Some(Self::Rfc3339),
43            "semver" => Some(Self::SemVer),
44            "spdx" => Some(Self::Spdx),
45            "url" => Some(Self::Url),
46            _ => None,
47        }
48    }
49
50    /// Get the string representation.
51    pub fn as_str(&self) -> &'static str {
52        match self {
53            Self::Email => "email",
54            Self::GitHash => "hash",
55            Self::RawText => "text",
56            Self::Rfc3339 => "rfc3339",
57            Self::SemVer => "semver",
58            Self::Spdx => "spdx",
59            Self::Url => "url",
60        }
61    }
62}
63
64/// Configuration for the hadolint linter.
65#[derive(Debug, Clone)]
66pub struct HadolintConfig {
67    /// Rules to ignore entirely.
68    pub ignore_rules: HashSet<RuleCode>,
69    /// Rules to treat as errors (override default severity).
70    pub error_rules: HashSet<RuleCode>,
71    /// Rules to treat as warnings (override default severity).
72    pub warning_rules: HashSet<RuleCode>,
73    /// Rules to treat as info (override default severity).
74    pub info_rules: HashSet<RuleCode>,
75    /// Rules to treat as style (override default severity).
76    pub style_rules: HashSet<RuleCode>,
77    /// Allowed Docker registries (for DL3026).
78    pub allowed_registries: HashSet<String>,
79    /// Label schema requirements (for DL3049-DL3056).
80    pub label_schema: HashMap<String, LabelType>,
81    /// Fail on labels not in schema.
82    pub strict_labels: bool,
83    /// Disable inline ignore pragmas.
84    pub disable_ignore_pragma: bool,
85    /// Minimum severity to report.
86    pub failure_threshold: Severity,
87    /// Don't fail even if rules are violated.
88    pub no_fail: bool,
89}
90
91impl Default for HadolintConfig {
92    fn default() -> Self {
93        Self {
94            ignore_rules: HashSet::new(),
95            error_rules: HashSet::new(),
96            warning_rules: HashSet::new(),
97            info_rules: HashSet::new(),
98            style_rules: HashSet::new(),
99            allowed_registries: HashSet::new(),
100            label_schema: HashMap::new(),
101            strict_labels: false,
102            disable_ignore_pragma: false,
103            failure_threshold: Severity::Info,
104            no_fail: false,
105        }
106    }
107}
108
109impl HadolintConfig {
110    /// Create a new config with defaults.
111    pub fn new() -> Self {
112        Self::default()
113    }
114
115    /// Load config from a YAML file.
116    pub fn from_yaml_file(path: &Path) -> Result<Self, ConfigError> {
117        let content =
118            std::fs::read_to_string(path).map_err(|e| ConfigError::IoError(e.to_string()))?;
119        Self::from_yaml_str(&content)
120    }
121
122    /// Load config from a YAML string.
123    pub fn from_yaml_str(yaml: &str) -> Result<Self, ConfigError> {
124        let value: serde_yaml::Value =
125            serde_yaml::from_str(yaml).map_err(|e| ConfigError::ParseError(e.to_string()))?;
126
127        let mut config = Self::default();
128
129        // Parse ignored rules
130        if let Some(ignored) = value.get("ignored").and_then(|v| v.as_sequence()) {
131            for item in ignored {
132                if let Some(code) = item.as_str() {
133                    config.ignore_rules.insert(RuleCode::new(code));
134                }
135            }
136        }
137
138        // Parse override.error
139        if let Some(overrides) = value.get("override").and_then(|v| v.as_mapping()) {
140            if let Some(errors) = overrides.get("error").and_then(|v| v.as_sequence()) {
141                for item in errors {
142                    if let Some(code) = item.as_str() {
143                        config.error_rules.insert(RuleCode::new(code));
144                    }
145                }
146            }
147            if let Some(warnings) = overrides.get("warning").and_then(|v| v.as_sequence()) {
148                for item in warnings {
149                    if let Some(code) = item.as_str() {
150                        config.warning_rules.insert(RuleCode::new(code));
151                    }
152                }
153            }
154            if let Some(infos) = overrides.get("info").and_then(|v| v.as_sequence()) {
155                for item in infos {
156                    if let Some(code) = item.as_str() {
157                        config.info_rules.insert(RuleCode::new(code));
158                    }
159                }
160            }
161            if let Some(styles) = overrides.get("style").and_then(|v| v.as_sequence()) {
162                for item in styles {
163                    if let Some(code) = item.as_str() {
164                        config.style_rules.insert(RuleCode::new(code));
165                    }
166                }
167            }
168        }
169
170        // Parse trusted registries
171        if let Some(registries) = value.get("trustedRegistries").and_then(|v| v.as_sequence()) {
172            for item in registries {
173                if let Some(registry) = item.as_str() {
174                    config.allowed_registries.insert(registry.to_string());
175                }
176            }
177        }
178
179        // Parse label schema
180        if let Some(schema) = value.get("label-schema").and_then(|v| v.as_mapping()) {
181            for (key, val) in schema {
182                if let (Some(label), Some(type_str)) = (key.as_str(), val.as_str())
183                    && let Some(label_type) = LabelType::parse(type_str)
184                {
185                    config.label_schema.insert(label.to_string(), label_type);
186                }
187            }
188        }
189
190        // Parse boolean flags
191        if let Some(strict) = value.get("strict-labels").and_then(|v| v.as_bool()) {
192            config.strict_labels = strict;
193        }
194        if let Some(disable) = value.get("disable-ignore-pragma").and_then(|v| v.as_bool()) {
195            config.disable_ignore_pragma = disable;
196        }
197        if let Some(no_fail) = value.get("no-fail").and_then(|v| v.as_bool()) {
198            config.no_fail = no_fail;
199        }
200
201        // Parse failure threshold
202        if let Some(threshold) = value.get("failure-threshold").and_then(|v| v.as_str())
203            && let Some(severity) = Severity::parse(threshold)
204        {
205            config.failure_threshold = severity;
206        }
207
208        Ok(config)
209    }
210
211    /// Find and load config from standard locations.
212    ///
213    /// Search order:
214    /// 1. .hadolint.yaml in current directory
215    /// 2. .hadolint.yml in current directory
216    /// 3. XDG config directory
217    /// 4. Home directory
218    pub fn find_and_load() -> Option<Self> {
219        let search_paths = [".hadolint.yaml", ".hadolint.yml"];
220
221        for path in &search_paths {
222            let path = Path::new(path);
223            if path.exists()
224                && let Ok(config) = Self::from_yaml_file(path)
225            {
226                return Some(config);
227            }
228        }
229
230        // Try XDG config directory
231        if let Some(config_dir) = dirs::config_dir() {
232            let xdg_path = config_dir.join("hadolint.yaml");
233            if xdg_path.exists()
234                && let Ok(config) = Self::from_yaml_file(&xdg_path)
235            {
236                return Some(config);
237            }
238        }
239
240        // Try home directory
241        if let Some(home_dir) = dirs::home_dir() {
242            let home_path = home_dir.join(".hadolint.yaml");
243            if home_path.exists()
244                && let Ok(config) = Self::from_yaml_file(&home_path)
245            {
246                return Some(config);
247            }
248        }
249
250        None
251    }
252
253    /// Check if a rule should be ignored.
254    pub fn is_rule_ignored(&self, code: &RuleCode) -> bool {
255        self.ignore_rules.contains(code)
256    }
257
258    /// Get the effective severity for a rule.
259    pub fn effective_severity(&self, code: &RuleCode, default: Severity) -> Severity {
260        if self.error_rules.contains(code) {
261            return Severity::Error;
262        }
263        if self.warning_rules.contains(code) {
264            return Severity::Warning;
265        }
266        if self.info_rules.contains(code) {
267            return Severity::Info;
268        }
269        if self.style_rules.contains(code) {
270            return Severity::Style;
271        }
272        default
273    }
274
275    /// Builder method to add an ignored rule.
276    pub fn ignore(mut self, code: impl Into<RuleCode>) -> Self {
277        self.ignore_rules.insert(code.into());
278        self
279    }
280
281    /// Builder method to add an allowed registry.
282    pub fn allow_registry(mut self, registry: impl Into<String>) -> Self {
283        self.allowed_registries.insert(registry.into());
284        self
285    }
286
287    /// Builder method to set failure threshold.
288    pub fn with_threshold(mut self, threshold: Severity) -> Self {
289        self.failure_threshold = threshold;
290        self
291    }
292}
293
294/// Errors that can occur when loading configuration.
295#[derive(Debug, Clone)]
296pub enum ConfigError {
297    /// I/O error reading the file.
298    IoError(String),
299    /// YAML parsing error.
300    ParseError(String),
301}
302
303impl std::fmt::Display for ConfigError {
304    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
305        match self {
306            Self::IoError(msg) => write!(f, "I/O error: {}", msg),
307            Self::ParseError(msg) => write!(f, "Parse error: {}", msg),
308        }
309    }
310}
311
312impl std::error::Error for ConfigError {}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    #[test]
319    fn test_default_config() {
320        let config = HadolintConfig::default();
321        assert!(config.ignore_rules.is_empty());
322        assert!(!config.strict_labels);
323        assert!(!config.disable_ignore_pragma);
324        assert_eq!(config.failure_threshold, Severity::Info);
325    }
326
327    #[test]
328    fn test_yaml_parsing() {
329        let yaml = r#"
330ignored:
331  - DL3008
332  - DL3009
333
334override:
335  error:
336    - DL3001
337  warning:
338    - DL3002
339
340trustedRegistries:
341  - docker.io
342  - gcr.io
343
344failure-threshold: warning
345strict-labels: true
346"#;
347
348        let config = HadolintConfig::from_yaml_str(yaml).unwrap();
349        assert!(config.ignore_rules.contains(&RuleCode::new("DL3008")));
350        assert!(config.ignore_rules.contains(&RuleCode::new("DL3009")));
351        assert!(config.error_rules.contains(&RuleCode::new("DL3001")));
352        assert!(config.warning_rules.contains(&RuleCode::new("DL3002")));
353        assert!(config.allowed_registries.contains("docker.io"));
354        assert!(config.allowed_registries.contains("gcr.io"));
355        assert_eq!(config.failure_threshold, Severity::Warning);
356        assert!(config.strict_labels);
357    }
358
359    #[test]
360    fn test_effective_severity() {
361        let config = HadolintConfig::default().ignore("DL3008".to_string());
362
363        assert!(config.is_rule_ignored(&RuleCode::new("DL3008")));
364        assert!(!config.is_rule_ignored(&RuleCode::new("DL3009")));
365    }
366
367    #[test]
368    fn test_builder_pattern() {
369        let config = HadolintConfig::new()
370            .ignore("DL3008")
371            .allow_registry("docker.io")
372            .with_threshold(Severity::Warning);
373
374        assert!(config.ignore_rules.contains(&RuleCode::new("DL3008")));
375        assert!(config.allowed_registries.contains("docker.io"));
376        assert_eq!(config.failure_threshold, Severity::Warning);
377    }
378}