syncable_cli/analyzer/dclint/
lint.rs

1//! Main linting orchestration for dclint.
2//!
3//! This module ties together parsing, rules, and pragmas to provide
4//! the main linting API.
5
6use std::path::Path;
7
8use crate::analyzer::dclint::config::DclintConfig;
9use crate::analyzer::dclint::parser::{ComposeFile, parse_compose};
10use crate::analyzer::dclint::pragma::{
11    PragmaState, extract_pragmas, starts_with_disable_file_comment,
12};
13use crate::analyzer::dclint::rules::{LintContext, all_rules};
14use crate::analyzer::dclint::types::{CheckFailure, Severity};
15
16/// Result of linting a Docker Compose file.
17#[derive(Debug, Clone)]
18pub struct LintResult {
19    /// The file path that was linted.
20    pub file_path: String,
21    /// Rule violations found.
22    pub failures: Vec<CheckFailure>,
23    /// Parse errors (if any).
24    pub parse_errors: Vec<String>,
25    /// Number of errors.
26    pub error_count: usize,
27    /// Number of warnings.
28    pub warning_count: usize,
29    /// Number of fixable errors.
30    pub fixable_error_count: usize,
31    /// Number of fixable warnings.
32    pub fixable_warning_count: usize,
33}
34
35impl LintResult {
36    /// Create a new empty result.
37    pub fn new(file_path: impl Into<String>) -> Self {
38        Self {
39            file_path: file_path.into(),
40            failures: Vec::new(),
41            parse_errors: Vec::new(),
42            error_count: 0,
43            warning_count: 0,
44            fixable_error_count: 0,
45            fixable_warning_count: 0,
46        }
47    }
48
49    /// Update counts based on failures.
50    fn update_counts(&mut self) {
51        self.error_count = self
52            .failures
53            .iter()
54            .filter(|f| f.severity == Severity::Error)
55            .count();
56        self.warning_count = self
57            .failures
58            .iter()
59            .filter(|f| f.severity == Severity::Warning)
60            .count();
61        self.fixable_error_count = self
62            .failures
63            .iter()
64            .filter(|f| f.fixable && f.severity == Severity::Error)
65            .count();
66        self.fixable_warning_count = self
67            .failures
68            .iter()
69            .filter(|f| f.fixable && f.severity == Severity::Warning)
70            .count();
71    }
72
73    /// Check if there are any failures.
74    pub fn has_failures(&self) -> bool {
75        !self.failures.is_empty()
76    }
77
78    /// Check if there are any errors (failure with Error severity).
79    pub fn has_errors(&self) -> bool {
80        self.error_count > 0
81    }
82
83    /// Check if there are any warnings (failure with Warning severity).
84    pub fn has_warnings(&self) -> bool {
85        self.warning_count > 0
86    }
87
88    /// Get the maximum severity in the results.
89    pub fn max_severity(&self) -> Option<Severity> {
90        self.failures.iter().map(|f| f.severity).max()
91    }
92
93    /// Check if the results should cause a non-zero exit.
94    pub fn should_fail(&self, threshold: Severity) -> bool {
95        if let Some(max) = self.max_severity() {
96            max >= threshold
97        } else {
98            false
99        }
100    }
101
102    /// Sort failures by line number.
103    pub fn sort(&mut self) {
104        self.failures.sort();
105    }
106}
107
108/// Lint a Docker Compose file string.
109pub fn lint(content: &str, config: &DclintConfig) -> LintResult {
110    lint_with_path(content, "<inline>", config)
111}
112
113/// Lint a Docker Compose file string with a path for error messages.
114pub fn lint_with_path(content: &str, path: &str, config: &DclintConfig) -> LintResult {
115    let mut result = LintResult::new(path);
116
117    // Check for disable-file pragma
118    if !config.disable_ignore_pragma && starts_with_disable_file_comment(content) {
119        return result; // File is completely disabled
120    }
121
122    // Parse the compose file
123    let compose = match parse_compose(content) {
124        Ok(c) => c,
125        Err(err) => {
126            result.parse_errors.push(err.to_string());
127            return result;
128        }
129    };
130
131    // Extract pragmas
132    let pragmas = if config.disable_ignore_pragma {
133        PragmaState::new()
134    } else {
135        extract_pragmas(content)
136    };
137
138    // Run all rules
139    let failures = run_rules(&compose, content, path, config, &pragmas);
140
141    // Apply config filters
142    result.failures = failures
143        .into_iter()
144        .filter(|f| {
145            // Check severity threshold
146            let effective_severity = config.effective_severity(&f.code, f.severity);
147            config.should_report(effective_severity)
148        })
149        .filter(|f| !config.is_rule_ignored(&f.code))
150        .filter(|f| !pragmas.is_ignored(&f.code, f.line))
151        .filter(|f| {
152            // Filter fixable-only if requested
153            if config.fixable_only { f.fixable } else { true }
154        })
155        .map(|mut f| {
156            // Apply severity overrides
157            f.severity = config.effective_severity(&f.code, f.severity);
158            f
159        })
160        .collect();
161
162    // Sort and update counts
163    result.sort();
164    result.update_counts();
165
166    result
167}
168
169/// Lint a Docker Compose file from a file path.
170pub fn lint_file(path: &Path, config: &DclintConfig) -> LintResult {
171    let path_str = path.display().to_string();
172
173    // Check if excluded
174    if config.is_excluded(&path_str) {
175        return LintResult::new(path_str);
176    }
177
178    match std::fs::read_to_string(path) {
179        Ok(content) => lint_with_path(&content, &path_str, config),
180        Err(err) => {
181            let mut result = LintResult::new(path_str);
182            result
183                .parse_errors
184                .push(format!("Failed to read file: {}", err));
185            result
186        }
187    }
188}
189
190/// Run all enabled rules on the compose file.
191fn run_rules(
192    compose: &ComposeFile,
193    source: &str,
194    path: &str,
195    config: &DclintConfig,
196    _pragmas: &PragmaState,
197) -> Vec<CheckFailure> {
198    let rules = all_rules();
199    let ctx = LintContext::new(compose, source, path);
200    let mut all_failures = Vec::new();
201
202    for rule in rules {
203        // Skip ignored rules
204        if config.is_rule_ignored(rule.code()) {
205            continue;
206        }
207
208        // Run the rule
209        let failures = rule.check(&ctx);
210        all_failures.extend(failures);
211    }
212
213    all_failures
214}
215
216/// Apply auto-fixes to source content.
217pub fn fix_content(content: &str, config: &DclintConfig) -> String {
218    // Check for disable-file pragma
219    if !config.disable_ignore_pragma && starts_with_disable_file_comment(content) {
220        return content.to_string();
221    }
222
223    let rules = all_rules();
224    let mut fixed = content.to_string();
225
226    // Apply fixes from all fixable rules
227    for rule in rules {
228        if rule.is_fixable()
229            && !config.is_rule_ignored(rule.code())
230            && let Some(new_content) = rule.fix(&fixed)
231        {
232            fixed = new_content;
233        }
234    }
235
236    fixed
237}
238
239/// Apply auto-fixes to a file.
240pub fn fix_file(
241    path: &Path,
242    config: &DclintConfig,
243    dry_run: bool,
244) -> Result<Option<String>, String> {
245    let path_str = path.display().to_string();
246
247    // Check if excluded
248    if config.is_excluded(&path_str) {
249        return Ok(None);
250    }
251
252    let content =
253        std::fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
254
255    let fixed = fix_content(&content, config);
256
257    if fixed == content {
258        return Ok(None); // No changes
259    }
260
261    if !dry_run {
262        std::fs::write(path, &fixed).map_err(|e| format!("Failed to write file: {}", e))?;
263    }
264
265    Ok(Some(fixed))
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    #[test]
273    fn test_lint_empty() {
274        let result = lint("", &DclintConfig::default());
275        // Empty content should fail to parse or have no services
276        assert!(result.failures.is_empty() || !result.parse_errors.is_empty());
277    }
278
279    #[test]
280    fn test_lint_valid_compose() {
281        let yaml = r#"
282name: myproject
283services:
284  web:
285    image: nginx:1.25
286    ports:
287      - "8080:80"
288"#;
289        let result = lint(yaml, &DclintConfig::default());
290        assert!(result.parse_errors.is_empty());
291        // May have some style warnings
292    }
293
294    #[test]
295    fn test_lint_with_violations() {
296        let yaml = r#"
297services:
298  web:
299    build: .
300    image: nginx:latest
301"#;
302        let result = lint(yaml, &DclintConfig::default());
303        assert!(result.parse_errors.is_empty());
304
305        // Should catch DCL001 (build+image) and DCL011 (latest tag)
306        let codes: Vec<&str> = result.failures.iter().map(|f| f.code.as_str()).collect();
307        assert!(
308            codes.contains(&"DCL001"),
309            "Should detect build+image violation"
310        );
311    }
312
313    #[test]
314    fn test_lint_with_ignore() {
315        let yaml = r#"
316services:
317  web:
318    build: .
319    image: nginx:latest
320"#;
321        let config = DclintConfig::default().ignore("DCL001");
322        let result = lint(yaml, &config);
323
324        // DCL001 should be ignored
325        let codes: Vec<&str> = result.failures.iter().map(|f| f.code.as_str()).collect();
326        assert!(!codes.contains(&"DCL001"));
327    }
328
329    #[test]
330    fn test_lint_with_pragma_ignore() {
331        let yaml = r#"
332# dclint-disable DCL001
333services:
334  web:
335    build: .
336    image: nginx:latest
337"#;
338        let result = lint(yaml, &DclintConfig::default());
339
340        // DCL001 should be ignored via pragma
341        let codes: Vec<&str> = result.failures.iter().map(|f| f.code.as_str()).collect();
342        assert!(!codes.contains(&"DCL001"));
343    }
344
345    #[test]
346    fn test_lint_disable_file() {
347        let yaml = r#"
348# dclint-disable-file
349services:
350  web:
351    build: .
352    image: nginx:latest
353"#;
354        let result = lint(yaml, &DclintConfig::default());
355
356        // All rules disabled for file
357        assert!(result.failures.is_empty());
358    }
359
360    #[test]
361    fn test_counts() {
362        let yaml = r#"
363services:
364  web:
365    build: .
366    image: nginx:latest
367  db:
368    image: postgres
369"#;
370        let result = lint(yaml, &DclintConfig::default());
371
372        // Should have at least one error (DCL001) and some warnings
373        assert!(result.error_count + result.warning_count > 0);
374    }
375
376    #[test]
377    fn test_fix_content() {
378        let yaml = r#"version: "3.8"
379
380services:
381  web:
382    image: nginx
383"#;
384        let config = DclintConfig::default();
385        let fixed = fix_content(yaml, &config);
386
387        // DCL006 fix should remove version field
388        assert!(!fixed.contains("version"));
389    }
390
391    #[test]
392    fn test_result_sort() {
393        let mut result = LintResult::new("test.yml");
394        result.failures.push(CheckFailure::new(
395            "DCL001",
396            "test",
397            Severity::Error,
398            crate::analyzer::dclint::types::RuleCategory::BestPractice,
399            "msg",
400            10,
401            1,
402        ));
403        result.failures.push(CheckFailure::new(
404            "DCL002",
405            "test",
406            Severity::Warning,
407            crate::analyzer::dclint::types::RuleCategory::Style,
408            "msg",
409            5,
410            1,
411        ));
412        result.failures.push(CheckFailure::new(
413            "DCL003",
414            "test",
415            Severity::Info,
416            crate::analyzer::dclint::types::RuleCategory::Style,
417            "msg",
418            1,
419            1,
420        ));
421
422        result.sort();
423
424        assert_eq!(result.failures[0].line, 1);
425        assert_eq!(result.failures[1].line, 5);
426        assert_eq!(result.failures[2].line, 10);
427    }
428}