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() && !config.is_rule_ignored(rule.code()) {
229            if let Some(new_content) = rule.fix(&fixed) {
230                fixed = new_content;
231            }
232        }
233    }
234
235    fixed
236}
237
238/// Apply auto-fixes to a file.
239pub fn fix_file(
240    path: &Path,
241    config: &DclintConfig,
242    dry_run: bool,
243) -> Result<Option<String>, String> {
244    let path_str = path.display().to_string();
245
246    // Check if excluded
247    if config.is_excluded(&path_str) {
248        return Ok(None);
249    }
250
251    let content =
252        std::fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
253
254    let fixed = fix_content(&content, config);
255
256    if fixed == content {
257        return Ok(None); // No changes
258    }
259
260    if !dry_run {
261        std::fs::write(path, &fixed).map_err(|e| format!("Failed to write file: {}", e))?;
262    }
263
264    Ok(Some(fixed))
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    #[test]
272    fn test_lint_empty() {
273        let result = lint("", &DclintConfig::default());
274        // Empty content should fail to parse or have no services
275        assert!(result.failures.is_empty() || !result.parse_errors.is_empty());
276    }
277
278    #[test]
279    fn test_lint_valid_compose() {
280        let yaml = r#"
281name: myproject
282services:
283  web:
284    image: nginx:1.25
285    ports:
286      - "8080:80"
287"#;
288        let result = lint(yaml, &DclintConfig::default());
289        assert!(result.parse_errors.is_empty());
290        // May have some style warnings
291    }
292
293    #[test]
294    fn test_lint_with_violations() {
295        let yaml = r#"
296services:
297  web:
298    build: .
299    image: nginx:latest
300"#;
301        let result = lint(yaml, &DclintConfig::default());
302        assert!(result.parse_errors.is_empty());
303
304        // Should catch DCL001 (build+image) and DCL011 (latest tag)
305        let codes: Vec<&str> = result.failures.iter().map(|f| f.code.as_str()).collect();
306        assert!(
307            codes.contains(&"DCL001"),
308            "Should detect build+image violation"
309        );
310    }
311
312    #[test]
313    fn test_lint_with_ignore() {
314        let yaml = r#"
315services:
316  web:
317    build: .
318    image: nginx:latest
319"#;
320        let config = DclintConfig::default().ignore("DCL001");
321        let result = lint(yaml, &config);
322
323        // DCL001 should be ignored
324        let codes: Vec<&str> = result.failures.iter().map(|f| f.code.as_str()).collect();
325        assert!(!codes.contains(&"DCL001"));
326    }
327
328    #[test]
329    fn test_lint_with_pragma_ignore() {
330        let yaml = r#"
331# dclint-disable DCL001
332services:
333  web:
334    build: .
335    image: nginx:latest
336"#;
337        let result = lint(yaml, &DclintConfig::default());
338
339        // DCL001 should be ignored via pragma
340        let codes: Vec<&str> = result.failures.iter().map(|f| f.code.as_str()).collect();
341        assert!(!codes.contains(&"DCL001"));
342    }
343
344    #[test]
345    fn test_lint_disable_file() {
346        let yaml = r#"
347# dclint-disable-file
348services:
349  web:
350    build: .
351    image: nginx:latest
352"#;
353        let result = lint(yaml, &DclintConfig::default());
354
355        // All rules disabled for file
356        assert!(result.failures.is_empty());
357    }
358
359    #[test]
360    fn test_counts() {
361        let yaml = r#"
362services:
363  web:
364    build: .
365    image: nginx:latest
366  db:
367    image: postgres
368"#;
369        let result = lint(yaml, &DclintConfig::default());
370
371        // Should have at least one error (DCL001) and some warnings
372        assert!(result.error_count + result.warning_count > 0);
373    }
374
375    #[test]
376    fn test_fix_content() {
377        let yaml = r#"version: "3.8"
378
379services:
380  web:
381    image: nginx
382"#;
383        let config = DclintConfig::default();
384        let fixed = fix_content(yaml, &config);
385
386        // DCL006 fix should remove version field
387        assert!(!fixed.contains("version"));
388    }
389
390    #[test]
391    fn test_result_sort() {
392        let mut result = LintResult::new("test.yml");
393        result.failures.push(CheckFailure::new(
394            "DCL001",
395            "test",
396            Severity::Error,
397            crate::analyzer::dclint::types::RuleCategory::BestPractice,
398            "msg",
399            10,
400            1,
401        ));
402        result.failures.push(CheckFailure::new(
403            "DCL002",
404            "test",
405            Severity::Warning,
406            crate::analyzer::dclint::types::RuleCategory::Style,
407            "msg",
408            5,
409            1,
410        ));
411        result.failures.push(CheckFailure::new(
412            "DCL003",
413            "test",
414            Severity::Info,
415            crate::analyzer::dclint::types::RuleCategory::Style,
416            "msg",
417            1,
418            1,
419        ));
420
421        result.sort();
422
423        assert_eq!(result.failures[0].line, 1);
424        assert_eq!(result.failures[1].line, 5);
425        assert_eq!(result.failures[2].line, 10);
426    }
427}