syncable_cli/analyzer/dclint/
lint.rs1use 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#[derive(Debug, Clone)]
18pub struct LintResult {
19 pub file_path: String,
21 pub failures: Vec<CheckFailure>,
23 pub parse_errors: Vec<String>,
25 pub error_count: usize,
27 pub warning_count: usize,
29 pub fixable_error_count: usize,
31 pub fixable_warning_count: usize,
33}
34
35impl LintResult {
36 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 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 pub fn has_failures(&self) -> bool {
75 !self.failures.is_empty()
76 }
77
78 pub fn has_errors(&self) -> bool {
80 self.error_count > 0
81 }
82
83 pub fn has_warnings(&self) -> bool {
85 self.warning_count > 0
86 }
87
88 pub fn max_severity(&self) -> Option<Severity> {
90 self.failures.iter().map(|f| f.severity).max()
91 }
92
93 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 pub fn sort(&mut self) {
104 self.failures.sort();
105 }
106}
107
108pub fn lint(content: &str, config: &DclintConfig) -> LintResult {
110 lint_with_path(content, "<inline>", config)
111}
112
113pub fn lint_with_path(content: &str, path: &str, config: &DclintConfig) -> LintResult {
115 let mut result = LintResult::new(path);
116
117 if !config.disable_ignore_pragma && starts_with_disable_file_comment(content) {
119 return result; }
121
122 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 let pragmas = if config.disable_ignore_pragma {
133 PragmaState::new()
134 } else {
135 extract_pragmas(content)
136 };
137
138 let failures = run_rules(&compose, content, path, config, &pragmas);
140
141 result.failures = failures
143 .into_iter()
144 .filter(|f| {
145 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 if config.fixable_only { f.fixable } else { true }
154 })
155 .map(|mut f| {
156 f.severity = config.effective_severity(&f.code, f.severity);
158 f
159 })
160 .collect();
161
162 result.sort();
164 result.update_counts();
165
166 result
167}
168
169pub fn lint_file(path: &Path, config: &DclintConfig) -> LintResult {
171 let path_str = path.display().to_string();
172
173 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
190fn 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 if config.is_rule_ignored(rule.code()) {
205 continue;
206 }
207
208 let failures = rule.check(&ctx);
210 all_failures.extend(failures);
211 }
212
213 all_failures
214}
215
216pub fn fix_content(content: &str, config: &DclintConfig) -> String {
218 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 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
238pub 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 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); }
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 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 }
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 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 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 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 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 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 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}