syncable_cli/analyzer/dclint/
config.rs

1//! Configuration for the dclint Docker Compose linter.
2//!
3//! Provides configuration options matching the TypeScript docker-compose-linter:
4//! - Rule-level configuration (off/warn/error)
5//! - Per-rule options
6//! - Global settings (quiet, debug, exclude patterns)
7
8use std::collections::HashMap;
9
10use crate::analyzer::dclint::types::{ConfigLevel, RuleCode, Severity};
11
12/// Configuration for a single rule.
13#[derive(Debug, Clone)]
14pub struct RuleConfig {
15    /// The configuration level (off, warn, error).
16    pub level: ConfigLevel,
17    /// Optional rule-specific options.
18    pub options: HashMap<String, serde_json::Value>,
19}
20
21impl Default for RuleConfig {
22    fn default() -> Self {
23        Self {
24            level: ConfigLevel::Error,
25            options: HashMap::new(),
26        }
27    }
28}
29
30impl RuleConfig {
31    /// Create a new rule config with the given level.
32    pub fn with_level(level: ConfigLevel) -> Self {
33        Self {
34            level,
35            options: HashMap::new(),
36        }
37    }
38
39    /// Create a rule config that's disabled.
40    pub fn off() -> Self {
41        Self::with_level(ConfigLevel::Off)
42    }
43
44    /// Create a rule config that produces warnings.
45    pub fn warn() -> Self {
46        Self::with_level(ConfigLevel::Warn)
47    }
48
49    /// Create a rule config that produces errors.
50    pub fn error() -> Self {
51        Self::with_level(ConfigLevel::Error)
52    }
53
54    /// Add an option to the rule config.
55    pub fn with_option(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
56        self.options.insert(key.into(), value);
57        self
58    }
59
60    /// Get an option value.
61    pub fn get_option(&self, key: &str) -> Option<&serde_json::Value> {
62        self.options.get(key)
63    }
64
65    /// Get a boolean option with a default value.
66    pub fn get_bool_option(&self, key: &str, default: bool) -> bool {
67        self.options
68            .get(key)
69            .and_then(|v| v.as_bool())
70            .unwrap_or(default)
71    }
72
73    /// Get a string option.
74    pub fn get_string_option(&self, key: &str) -> Option<&str> {
75        self.options.get(key).and_then(|v| v.as_str())
76    }
77
78    /// Get an array option as a vector of strings.
79    pub fn get_string_array_option(&self, key: &str) -> Vec<String> {
80        self.options
81            .get(key)
82            .and_then(|v| v.as_array())
83            .map(|arr| {
84                arr.iter()
85                    .filter_map(|v| v.as_str().map(String::from))
86                    .collect()
87            })
88            .unwrap_or_default()
89    }
90}
91
92/// Main configuration for dclint.
93#[derive(Debug, Clone)]
94pub struct DclintConfig {
95    /// Per-rule configuration.
96    pub rules: HashMap<String, RuleConfig>,
97    /// Suppress non-error output.
98    pub quiet: bool,
99    /// Enable debug output.
100    pub debug: bool,
101    /// File patterns to exclude from linting.
102    pub exclude: Vec<String>,
103    /// Minimum severity threshold for reporting.
104    pub threshold: Severity,
105    /// Whether to disable pragma (comment-based) ignores.
106    pub disable_ignore_pragma: bool,
107    /// Whether to report fixable issues only.
108    pub fixable_only: bool,
109}
110
111impl Default for DclintConfig {
112    fn default() -> Self {
113        Self {
114            rules: HashMap::new(),
115            quiet: false,
116            debug: false,
117            exclude: Vec::new(),
118            threshold: Severity::Style,
119            disable_ignore_pragma: false,
120            fixable_only: false,
121        }
122    }
123}
124
125impl DclintConfig {
126    /// Create a new default configuration.
127    pub fn new() -> Self {
128        Self::default()
129    }
130
131    /// Set quiet mode.
132    pub fn with_quiet(mut self, quiet: bool) -> Self {
133        self.quiet = quiet;
134        self
135    }
136
137    /// Set debug mode.
138    pub fn with_debug(mut self, debug: bool) -> Self {
139        self.debug = debug;
140        self
141    }
142
143    /// Add an exclude pattern.
144    pub fn with_exclude(mut self, pattern: impl Into<String>) -> Self {
145        self.exclude.push(pattern.into());
146        self
147    }
148
149    /// Set multiple exclude patterns.
150    pub fn with_excludes(mut self, patterns: Vec<String>) -> Self {
151        self.exclude = patterns;
152        self
153    }
154
155    /// Set the severity threshold.
156    pub fn with_threshold(mut self, threshold: Severity) -> Self {
157        self.threshold = threshold;
158        self
159    }
160
161    /// Configure a specific rule.
162    pub fn with_rule(mut self, rule: impl Into<String>, config: RuleConfig) -> Self {
163        self.rules.insert(rule.into(), config);
164        self
165    }
166
167    /// Disable a rule.
168    pub fn ignore(mut self, rule: impl Into<String>) -> Self {
169        self.rules.insert(rule.into(), RuleConfig::off());
170        self
171    }
172
173    /// Set a rule to warn level.
174    pub fn warn(mut self, rule: impl Into<String>) -> Self {
175        self.rules.insert(rule.into(), RuleConfig::warn());
176        self
177    }
178
179    /// Set a rule to error level.
180    pub fn error(mut self, rule: impl Into<String>) -> Self {
181        self.rules.insert(rule.into(), RuleConfig::error());
182        self
183    }
184
185    /// Disable pragma (comment-based) ignores.
186    pub fn with_disable_ignore_pragma(mut self, disable: bool) -> Self {
187        self.disable_ignore_pragma = disable;
188        self
189    }
190
191    /// Check if a rule is ignored (disabled).
192    pub fn is_rule_ignored(&self, code: &RuleCode) -> bool {
193        self.rules
194            .get(code.as_str())
195            .map(|c| c.level == ConfigLevel::Off)
196            .unwrap_or(false)
197    }
198
199    /// Get the configuration for a specific rule.
200    pub fn get_rule_config(&self, code: &str) -> Option<&RuleConfig> {
201        self.rules.get(code)
202    }
203
204    /// Get the effective severity for a rule, applying any overrides.
205    pub fn effective_severity(&self, code: &RuleCode, default: Severity) -> Severity {
206        self.rules
207            .get(code.as_str())
208            .and_then(|c| c.level.to_severity())
209            .unwrap_or(default)
210    }
211
212    /// Check if an issue should be reported based on threshold.
213    pub fn should_report(&self, severity: Severity) -> bool {
214        severity >= self.threshold
215    }
216
217    /// Check if a file path should be excluded.
218    pub fn is_excluded(&self, path: &str) -> bool {
219        for pattern in &self.exclude {
220            // Simple glob matching
221            if pattern.contains('*') {
222                let pattern_regex = pattern.replace('.', "\\.").replace('*', ".*");
223                if let Ok(re) = regex::Regex::new(&format!("^{}$", pattern_regex))
224                    && re.is_match(path)
225                {
226                    return true;
227                }
228            } else if path.contains(pattern) {
229                return true;
230            }
231        }
232        false
233    }
234}
235
236/// Builder for creating DclintConfig from various sources.
237pub struct DclintConfigBuilder {
238    config: DclintConfig,
239}
240
241impl DclintConfigBuilder {
242    pub fn new() -> Self {
243        Self {
244            config: DclintConfig::default(),
245        }
246    }
247
248    /// Load configuration from a JSON value (matching TypeScript config format).
249    pub fn from_json(mut self, json: &serde_json::Value) -> Self {
250        if let Some(rules) = json.get("rules").and_then(|v| v.as_object()) {
251            for (name, value) in rules {
252                let rule_config = match value {
253                    // Simple numeric level: 0, 1, or 2
254                    serde_json::Value::Number(n) => {
255                        if let Some(level) = n.as_u64().and_then(|n| ConfigLevel::from_u8(n as u8))
256                        {
257                            RuleConfig::with_level(level)
258                        } else {
259                            continue;
260                        }
261                    }
262                    // Array format: [level, options]
263                    serde_json::Value::Array(arr) => {
264                        let level = arr
265                            .first()
266                            .and_then(|v| v.as_u64())
267                            .and_then(|n| ConfigLevel::from_u8(n as u8))
268                            .unwrap_or(ConfigLevel::Error);
269
270                        let mut config = RuleConfig::with_level(level);
271
272                        if let Some(opts) = arr.get(1).and_then(|v| v.as_object()) {
273                            for (k, v) in opts {
274                                config.options.insert(k.clone(), v.clone());
275                            }
276                        }
277
278                        config
279                    }
280                    _ => continue,
281                };
282
283                self.config.rules.insert(name.clone(), rule_config);
284            }
285        }
286
287        if let Some(quiet) = json.get("quiet").and_then(|v| v.as_bool()) {
288            self.config.quiet = quiet;
289        }
290
291        if let Some(debug) = json.get("debug").and_then(|v| v.as_bool()) {
292            self.config.debug = debug;
293        }
294
295        if let Some(exclude) = json.get("exclude").and_then(|v| v.as_array()) {
296            self.config.exclude = exclude
297                .iter()
298                .filter_map(|v| v.as_str().map(String::from))
299                .collect();
300        }
301
302        self
303    }
304
305    /// Build the final configuration.
306    pub fn build(self) -> DclintConfig {
307        self.config
308    }
309}
310
311impl Default for DclintConfigBuilder {
312    fn default() -> Self {
313        Self::new()
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    #[test]
322    fn test_default_config() {
323        let config = DclintConfig::default();
324        assert!(!config.quiet);
325        assert!(!config.debug);
326        assert!(config.exclude.is_empty());
327        assert!(config.rules.is_empty());
328    }
329
330    #[test]
331    fn test_rule_config() {
332        let config = DclintConfig::default()
333            .ignore("DCL001")
334            .warn("DCL002")
335            .error("DCL003");
336
337        assert!(config.is_rule_ignored(&RuleCode::new("DCL001")));
338        assert!(!config.is_rule_ignored(&RuleCode::new("DCL002")));
339        assert!(!config.is_rule_ignored(&RuleCode::new("DCL003")));
340        assert!(!config.is_rule_ignored(&RuleCode::new("DCL004"))); // Not configured
341    }
342
343    #[test]
344    fn test_effective_severity() {
345        let config = DclintConfig::default().warn("DCL001").error("DCL002");
346
347        assert_eq!(
348            config.effective_severity(&RuleCode::new("DCL001"), Severity::Error),
349            Severity::Warning
350        );
351        assert_eq!(
352            config.effective_severity(&RuleCode::new("DCL002"), Severity::Warning),
353            Severity::Error
354        );
355        // Non-configured rule uses default
356        assert_eq!(
357            config.effective_severity(&RuleCode::new("DCL003"), Severity::Info),
358            Severity::Info
359        );
360    }
361
362    #[test]
363    fn test_threshold() {
364        let config = DclintConfig::default().with_threshold(Severity::Warning);
365
366        assert!(config.should_report(Severity::Error));
367        assert!(config.should_report(Severity::Warning));
368        assert!(!config.should_report(Severity::Info));
369        assert!(!config.should_report(Severity::Style));
370    }
371
372    #[test]
373    fn test_exclude_patterns() {
374        let config = DclintConfig::default()
375            .with_exclude("node_modules")
376            .with_exclude("*.test.yml");
377
378        assert!(config.is_excluded("path/to/node_modules/file.yml"));
379        assert!(config.is_excluded("docker-compose.test.yml"));
380        assert!(!config.is_excluded("docker-compose.yml"));
381    }
382
383    #[test]
384    fn test_rule_options() {
385        let rule_config = RuleConfig::default()
386            .with_option("checkPullPolicy", serde_json::json!(true))
387            .with_option("pattern", serde_json::json!("^[a-z]+$"));
388
389        assert!(rule_config.get_bool_option("checkPullPolicy", false));
390        assert_eq!(rule_config.get_string_option("pattern"), Some("^[a-z]+$"));
391        assert!(rule_config.get_bool_option("nonexistent", false) == false);
392    }
393
394    #[test]
395    fn test_config_from_json() {
396        let json = serde_json::json!({
397            "rules": {
398                "no-build-and-image": 2,
399                "no-version-field": [1, { "allowEmpty": true }],
400                "services-alphabetical-order": 0
401            },
402            "quiet": true,
403            "exclude": ["*.test.yml"]
404        });
405
406        let config = DclintConfigBuilder::new().from_json(&json).build();
407
408        assert!(config.quiet);
409        assert_eq!(config.exclude, vec!["*.test.yml"]);
410
411        let rule1 = config.get_rule_config("no-build-and-image").unwrap();
412        assert_eq!(rule1.level, ConfigLevel::Error);
413
414        let rule2 = config.get_rule_config("no-version-field").unwrap();
415        assert_eq!(rule2.level, ConfigLevel::Warn);
416        assert!(rule2.get_bool_option("allowEmpty", false));
417
418        let rule3 = config
419            .get_rule_config("services-alphabetical-order")
420            .unwrap();
421        assert_eq!(rule3.level, ConfigLevel::Off);
422    }
423}