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 from_str(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 = std::fs::read_to_string(path)
118            .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 = serde_yaml::from_str(yaml)
125            .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                    if let Some(label_type) = LabelType::from_str(type_str) {
184                        config.label_schema.insert(label.to_string(), label_type);
185                    }
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            if let Some(severity) = Severity::from_str(threshold) {
204                config.failure_threshold = severity;
205            }
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 = [
220            ".hadolint.yaml",
221            ".hadolint.yml",
222        ];
223
224        for path in &search_paths {
225            let path = Path::new(path);
226            if path.exists() {
227                if let Ok(config) = Self::from_yaml_file(path) {
228                    return Some(config);
229                }
230            }
231        }
232
233        // Try XDG config directory
234        if let Some(config_dir) = dirs::config_dir() {
235            let xdg_path = config_dir.join("hadolint.yaml");
236            if xdg_path.exists() {
237                if let Ok(config) = Self::from_yaml_file(&xdg_path) {
238                    return Some(config);
239                }
240            }
241        }
242
243        // Try home directory
244        if let Some(home_dir) = dirs::home_dir() {
245            let home_path = home_dir.join(".hadolint.yaml");
246            if home_path.exists() {
247                if let Ok(config) = Self::from_yaml_file(&home_path) {
248                    return Some(config);
249                }
250            }
251        }
252
253        None
254    }
255
256    /// Check if a rule should be ignored.
257    pub fn is_rule_ignored(&self, code: &RuleCode) -> bool {
258        self.ignore_rules.contains(code)
259    }
260
261    /// Get the effective severity for a rule.
262    pub fn effective_severity(&self, code: &RuleCode, default: Severity) -> Severity {
263        if self.error_rules.contains(code) {
264            return Severity::Error;
265        }
266        if self.warning_rules.contains(code) {
267            return Severity::Warning;
268        }
269        if self.info_rules.contains(code) {
270            return Severity::Info;
271        }
272        if self.style_rules.contains(code) {
273            return Severity::Style;
274        }
275        default
276    }
277
278    /// Builder method to add an ignored rule.
279    pub fn ignore(mut self, code: impl Into<RuleCode>) -> Self {
280        self.ignore_rules.insert(code.into());
281        self
282    }
283
284    /// Builder method to add an allowed registry.
285    pub fn allow_registry(mut self, registry: impl Into<String>) -> Self {
286        self.allowed_registries.insert(registry.into());
287        self
288    }
289
290    /// Builder method to set failure threshold.
291    pub fn with_threshold(mut self, threshold: Severity) -> Self {
292        self.failure_threshold = threshold;
293        self
294    }
295}
296
297/// Errors that can occur when loading configuration.
298#[derive(Debug, Clone)]
299pub enum ConfigError {
300    /// I/O error reading the file.
301    IoError(String),
302    /// YAML parsing error.
303    ParseError(String),
304}
305
306impl std::fmt::Display for ConfigError {
307    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
308        match self {
309            Self::IoError(msg) => write!(f, "I/O error: {}", msg),
310            Self::ParseError(msg) => write!(f, "Parse error: {}", msg),
311        }
312    }
313}
314
315impl std::error::Error for ConfigError {}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    #[test]
322    fn test_default_config() {
323        let config = HadolintConfig::default();
324        assert!(config.ignore_rules.is_empty());
325        assert!(!config.strict_labels);
326        assert!(!config.disable_ignore_pragma);
327        assert_eq!(config.failure_threshold, Severity::Info);
328    }
329
330    #[test]
331    fn test_yaml_parsing() {
332        let yaml = r#"
333ignored:
334  - DL3008
335  - DL3009
336
337override:
338  error:
339    - DL3001
340  warning:
341    - DL3002
342
343trustedRegistries:
344  - docker.io
345  - gcr.io
346
347failure-threshold: warning
348strict-labels: true
349"#;
350
351        let config = HadolintConfig::from_yaml_str(yaml).unwrap();
352        assert!(config.ignore_rules.contains(&RuleCode::new("DL3008")));
353        assert!(config.ignore_rules.contains(&RuleCode::new("DL3009")));
354        assert!(config.error_rules.contains(&RuleCode::new("DL3001")));
355        assert!(config.warning_rules.contains(&RuleCode::new("DL3002")));
356        assert!(config.allowed_registries.contains("docker.io"));
357        assert!(config.allowed_registries.contains("gcr.io"));
358        assert_eq!(config.failure_threshold, Severity::Warning);
359        assert!(config.strict_labels);
360    }
361
362    #[test]
363    fn test_effective_severity() {
364        let config = HadolintConfig::default()
365            .ignore("DL3008".to_string());
366
367        assert!(config.is_rule_ignored(&RuleCode::new("DL3008")));
368        assert!(!config.is_rule_ignored(&RuleCode::new("DL3009")));
369    }
370
371    #[test]
372    fn test_builder_pattern() {
373        let config = HadolintConfig::new()
374            .ignore("DL3008")
375            .allow_registry("docker.io")
376            .with_threshold(Severity::Warning);
377
378        assert!(config.ignore_rules.contains(&RuleCode::new("DL3008")));
379        assert!(config.allowed_registries.contains("docker.io"));
380        assert_eq!(config.failure_threshold, Severity::Warning);
381    }
382}