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()
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
239pub 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 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); }
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 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 }
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 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 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 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 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 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 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}