1use super::types::{Config, Fix, LintError, Plugin, PluginSpec};
52use std::path::{Path, PathBuf};
53
54#[macro_export]
61macro_rules! fixtures_dir {
62 () => {
63 concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures")
64 };
65}
66
67pub struct PluginTestRunner<P: Plugin> {
101 plugin: P,
102}
103
104impl<P: Plugin> PluginTestRunner<P> {
105 pub fn new(plugin: P) -> Self {
107 Self { plugin }
108 }
109
110 pub fn spec(&self) -> PluginSpec {
112 self.plugin.spec()
113 }
114
115 pub fn check_string(&self, content: &str) -> Result<Vec<LintError>, String> {
117 let config: Config = nginx_lint_common::parse_string(content)
118 .map_err(|e| format!("Failed to parse config: {}", e))?;
119 Ok(self.plugin.check(&config, "test.conf"))
120 }
121
122 pub fn check_file(&self, path: &Path) -> Result<Vec<LintError>, String> {
124 let content =
125 std::fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
126 let config: Config = nginx_lint_common::parse_string(&content)
127 .map_err(|e| format!("Failed to parse config: {}", e))?;
128 Ok(self.plugin.check(&config, path.to_string_lossy().as_ref()))
129 }
130
131 pub fn test_fixtures(&self, fixtures_dir: &str) {
133 let fixtures_path = PathBuf::from(fixtures_dir);
134 if !fixtures_path.exists() {
135 panic!("Fixtures directory not found: {}", fixtures_dir);
136 }
137
138 let plugin_spec = self.plugin.spec();
139 let rule_name = &plugin_spec.name;
140
141 let entries = std::fs::read_dir(&fixtures_path)
142 .unwrap_or_else(|e| panic!("Failed to read fixtures directory: {}", e));
143
144 let mut tested_count = 0;
145
146 for entry in entries {
147 let entry = entry.expect("Failed to read directory entry");
148 let case_path = entry.path();
149
150 if !case_path.is_dir() {
151 continue;
152 }
153
154 let case_name = case_path.file_name().unwrap().to_string_lossy();
155 self.test_case(&case_path, rule_name, &case_name);
156 tested_count += 1;
157 }
158
159 if tested_count == 0 {
160 panic!("No test cases found in {}", fixtures_dir);
161 }
162 }
163
164 fn test_case(&self, case_path: &Path, rule_name: &str, case_name: &str) {
166 let error_path = case_path.join("error").join("nginx.conf");
167 let expected_path = case_path.join("expected").join("nginx.conf");
168
169 if error_path.exists() {
170 let errors = self
171 .check_file(&error_path)
172 .unwrap_or_else(|e| panic!("Failed to check error fixture {}: {}", case_name, e));
173
174 let rule_errors: Vec<_> = errors.iter().filter(|e| e.rule == rule_name).collect();
175
176 assert!(
177 !rule_errors.is_empty(),
178 "Expected {} errors in {}/error/nginx.conf, got none",
179 rule_name,
180 case_name
181 );
182 }
183
184 if expected_path.exists() {
185 let errors = self.check_file(&expected_path).unwrap_or_else(|e| {
186 panic!("Failed to check expected fixture {}: {}", case_name, e)
187 });
188
189 let rule_errors: Vec<_> = errors.iter().filter(|e| e.rule == rule_name).collect();
190
191 assert!(
192 rule_errors.is_empty(),
193 "Expected no {} errors in {}/expected/nginx.conf, got: {:?}",
194 rule_name,
195 case_name,
196 rule_errors
197 );
198 }
199 }
200
201 pub fn assert_errors(&self, content: &str, expected_count: usize) {
203 let errors = self.check_string(content).expect("Failed to check config");
204 let plugin_spec = self.plugin.spec();
205 let rule_errors: Vec<_> = errors
206 .iter()
207 .filter(|e| e.rule == plugin_spec.name)
208 .collect();
209
210 assert_eq!(
211 rule_errors.len(),
212 expected_count,
213 "Expected {} errors from {}, got {}: {:?}",
214 expected_count,
215 plugin_spec.name,
216 rule_errors.len(),
217 rule_errors
218 );
219 }
220
221 pub fn assert_no_errors(&self, content: &str) {
223 self.assert_errors(content, 0);
224 }
225
226 pub fn assert_has_errors(&self, content: &str) {
228 let errors = self.check_string(content).expect("Failed to check config");
229 let plugin_spec = self.plugin.spec();
230 let rule_errors: Vec<_> = errors
231 .iter()
232 .filter(|e| e.rule == plugin_spec.name)
233 .collect();
234
235 assert!(
236 !rule_errors.is_empty(),
237 "Expected at least one error from {}, got none",
238 plugin_spec.name
239 );
240 }
241
242 pub fn assert_error_on_line(&self, content: &str, expected_line: usize) {
244 let errors = self.check_string(content).expect("Failed to check config");
245 let plugin_spec = self.plugin.spec();
246 let rule_errors: Vec<_> = errors
247 .iter()
248 .filter(|e| e.rule == plugin_spec.name)
249 .collect();
250
251 let has_error_on_line = rule_errors.iter().any(|e| e.line == Some(expected_line));
252
253 assert!(
254 has_error_on_line,
255 "Expected error from {} on line {}, got errors on lines: {:?}",
256 plugin_spec.name,
257 expected_line,
258 rule_errors.iter().map(|e| e.line).collect::<Vec<_>>()
259 );
260 }
261
262 pub fn assert_error_message_contains(&self, content: &str, expected_substring: &str) {
264 let errors = self.check_string(content).expect("Failed to check config");
265 let plugin_spec = self.plugin.spec();
266 let rule_errors: Vec<_> = errors
267 .iter()
268 .filter(|e| e.rule == plugin_spec.name)
269 .collect();
270
271 let has_message = rule_errors
272 .iter()
273 .any(|e| e.message.contains(expected_substring));
274
275 assert!(
276 has_message,
277 "Expected error message containing '{}' from {}, got messages: {:?}",
278 expected_substring,
279 plugin_spec.name,
280 rule_errors.iter().map(|e| &e.message).collect::<Vec<_>>()
281 );
282 }
283
284 pub fn assert_has_fix(&self, content: &str) {
286 let errors = self.check_string(content).expect("Failed to check config");
287 let plugin_spec = self.plugin.spec();
288 let rule_errors: Vec<_> = errors
289 .iter()
290 .filter(|e| e.rule == plugin_spec.name)
291 .collect();
292
293 let has_fix = rule_errors.iter().any(|e| !e.fixes.is_empty());
294
295 assert!(
296 has_fix,
297 "Expected at least one error with fix from {}, got errors: {:?}",
298 plugin_spec.name, rule_errors
299 );
300 }
301
302 pub fn assert_fix_produces(&self, content: &str, expected: &str) {
304 let errors = self.check_string(content).expect("Failed to check config");
305 let plugin_spec = self.plugin.spec();
306
307 let fixes: Vec<_> = errors
308 .iter()
309 .filter(|e| e.rule == plugin_spec.name)
310 .flat_map(|e| e.fixes.iter())
311 .collect();
312
313 assert!(
314 !fixes.is_empty(),
315 "Expected at least one fix from {}, got none",
316 plugin_spec.name
317 );
318
319 let result = apply_fixes(content, &fixes);
320 let expected_normalized = expected.trim();
321 let result_normalized = result.trim();
322
323 assert_eq!(
324 result_normalized, expected_normalized,
325 "Fix did not produce expected output.\nExpected:\n{}\n\nGot:\n{}",
326 expected_normalized, result_normalized
327 );
328 }
329
330 pub fn test_examples(&self, bad_conf: &str, good_conf: &str) {
332 let plugin_spec = self.plugin.spec();
333
334 let errors = self
335 .check_string(bad_conf)
336 .expect("Failed to parse bad.conf");
337 let rule_errors: Vec<_> = errors
338 .iter()
339 .filter(|e| e.rule == plugin_spec.name)
340 .collect();
341 assert!(
342 !rule_errors.is_empty(),
343 "bad.conf should produce at least one {} error, got none",
344 plugin_spec.name
345 );
346
347 let errors = self
348 .check_string(good_conf)
349 .expect("Failed to parse good.conf");
350 let rule_errors: Vec<_> = errors
351 .iter()
352 .filter(|e| e.rule == plugin_spec.name)
353 .collect();
354 assert!(
355 rule_errors.is_empty(),
356 "good.conf should not produce {} errors, got: {:?}",
357 plugin_spec.name,
358 rule_errors
359 );
360 }
361
362 pub fn test_examples_with_fix(&self, bad_conf: &str, good_conf: &str) {
364 let plugin_spec = self.plugin.spec();
365
366 let errors = self
367 .check_string(bad_conf)
368 .expect("Failed to parse bad.conf");
369 let rule_errors: Vec<_> = errors
370 .iter()
371 .filter(|e| e.rule == plugin_spec.name)
372 .collect();
373 assert!(
374 !rule_errors.is_empty(),
375 "bad.conf should produce at least one {} error, got none",
376 plugin_spec.name
377 );
378
379 let fixes: Vec<_> = rule_errors.iter().flat_map(|e| e.fixes.iter()).collect();
380 assert!(
381 !fixes.is_empty(),
382 "bad.conf errors should have fixes, got none"
383 );
384
385 let errors = self
386 .check_string(good_conf)
387 .expect("Failed to parse good.conf");
388 let rule_errors: Vec<_> = errors
389 .iter()
390 .filter(|e| e.rule == plugin_spec.name)
391 .collect();
392 assert!(
393 rule_errors.is_empty(),
394 "good.conf should not produce {} errors, got: {:?}",
395 plugin_spec.name,
396 rule_errors
397 );
398
399 let fixed = apply_fixes(bad_conf, &fixes);
400 assert_eq!(
401 fixed.trim(),
402 good_conf.trim(),
403 "Applying fixes to bad.conf should produce good.conf.\nExpected:\n{}\n\nGot:\n{}",
404 good_conf.trim(),
405 fixed.trim()
406 );
407 }
408}
409
410pub struct TestCase {
454 content: String,
455 expected_error_count: Option<usize>,
456 expected_lines: Vec<usize>,
457 expected_message_contains: Vec<String>,
458 expect_has_fix: bool,
459 expected_fix_output: Option<String>,
460 expected_fix_on_lines: Vec<usize>,
461}
462
463impl TestCase {
464 pub fn new(content: impl Into<String>) -> Self {
466 Self {
467 content: content.into(),
468 expected_error_count: None,
469 expected_lines: Vec::new(),
470 expected_message_contains: Vec::new(),
471 expect_has_fix: false,
472 expected_fix_output: None,
473 expected_fix_on_lines: Vec::new(),
474 }
475 }
476
477 pub fn expect_error_count(mut self, count: usize) -> Self {
479 self.expected_error_count = Some(count);
480 self
481 }
482
483 pub fn expect_no_errors(self) -> Self {
485 self.expect_error_count(0)
486 }
487
488 pub fn expect_error_on_line(mut self, line: usize) -> Self {
490 self.expected_lines.push(line);
491 self
492 }
493
494 pub fn expect_message_contains(mut self, substring: impl Into<String>) -> Self {
496 self.expected_message_contains.push(substring.into());
497 self
498 }
499
500 pub fn expect_has_fix(mut self) -> Self {
502 self.expect_has_fix = true;
503 self
504 }
505
506 pub fn expect_fix_on_line(mut self, line: usize) -> Self {
508 self.expected_fix_on_lines.push(line);
509 self.expect_has_fix = true;
510 self
511 }
512
513 pub fn expect_fix_produces(mut self, expected: impl Into<String>) -> Self {
515 self.expected_fix_output = Some(expected.into());
516 self.expect_has_fix = true;
517 self
518 }
519
520 pub fn run<P: Plugin>(self, plugin: &P) {
522 let config: Config = nginx_lint_common::parse_string(&self.content)
523 .unwrap_or_else(|e| panic!("Failed to parse test config: {}", e));
524
525 let errors = plugin.check(&config, "test.conf");
526 let plugin_spec = plugin.spec();
527 let rule_errors: Vec<_> = errors
528 .iter()
529 .filter(|e| e.rule == plugin_spec.name)
530 .collect();
531
532 if let Some(expected_count) = self.expected_error_count {
533 assert_eq!(
534 rule_errors.len(),
535 expected_count,
536 "Expected {} errors, got {}: {:?}",
537 expected_count,
538 rule_errors.len(),
539 rule_errors
540 );
541 }
542
543 for expected_line in &self.expected_lines {
544 let has_error = rule_errors.iter().any(|e| e.line == Some(*expected_line));
545 assert!(
546 has_error,
547 "Expected error on line {}, got errors on lines: {:?}",
548 expected_line,
549 rule_errors.iter().map(|e| e.line).collect::<Vec<_>>()
550 );
551 }
552
553 for expected_msg in &self.expected_message_contains {
554 let has_message = rule_errors.iter().any(|e| e.message.contains(expected_msg));
555 assert!(
556 has_message,
557 "Expected error message containing '{}', got: {:?}",
558 expected_msg,
559 rule_errors.iter().map(|e| &e.message).collect::<Vec<_>>()
560 );
561 }
562
563 if self.expect_has_fix {
564 let has_fix = rule_errors.iter().any(|e| !e.fixes.is_empty());
565 assert!(
566 has_fix,
567 "Expected at least one error with fix, got errors: {:?}",
568 rule_errors
569 );
570 }
571
572 for expected_line in &self.expected_fix_on_lines {
573 let has_fix_on_line = rule_errors.iter().flat_map(|e| e.fixes.iter()).any(|f| {
574 if f.is_range_based() {
575 let start = f.start_offset.unwrap_or(0);
576 let line = offset_to_line(&self.content, start);
577 line == *expected_line
578 } else {
579 f.line == *expected_line
580 }
581 });
582 assert!(
583 has_fix_on_line,
584 "Expected fix on line {}, got fixes on lines: {:?}",
585 expected_line,
586 rule_errors
587 .iter()
588 .flat_map(|e| e.fixes.iter().map(|f| {
589 if f.is_range_based() {
590 let start = f.start_offset.unwrap_or(0);
591 offset_to_line(&self.content, start)
592 } else {
593 f.line
594 }
595 }))
596 .collect::<Vec<_>>()
597 );
598 }
599
600 if let Some(expected_output) = &self.expected_fix_output {
601 let fixes: Vec<_> = rule_errors.iter().flat_map(|e| e.fixes.iter()).collect();
602
603 assert!(
604 !fixes.is_empty(),
605 "Expected at least one fix to check output, got none"
606 );
607
608 let result = apply_fixes(&self.content, &fixes);
609 let expected_normalized = expected_output.trim();
610 let result_normalized = result.trim();
611
612 assert_eq!(
613 result_normalized, expected_normalized,
614 "Fix did not produce expected output.\nExpected:\n{}\n\nGot:\n{}",
615 expected_normalized, result_normalized
616 );
617 }
618 }
619}
620
621fn offset_to_line(content: &str, offset: usize) -> usize {
623 let offset = offset.min(content.len());
624 content[..offset].chars().filter(|&c| c == '\n').count() + 1
625}
626
627fn apply_fixes(content: &str, fixes: &[&Fix]) -> String {
629 let (range_fixes, line_fixes): (Vec<&&Fix>, Vec<&&Fix>) = fixes
630 .iter()
631 .partition(|f| f.start_offset.is_some() && f.end_offset.is_some());
632
633 let mut result = content.to_string();
634
635 if !range_fixes.is_empty() {
636 let mut sorted_range_fixes = range_fixes;
637 sorted_range_fixes.sort_by(|a, b| b.start_offset.unwrap().cmp(&a.start_offset.unwrap()));
638
639 let mut applied_ranges: Vec<(usize, usize)> = Vec::new();
640
641 for fix in sorted_range_fixes {
642 let start = fix.start_offset.unwrap();
643 let end = fix.end_offset.unwrap();
644
645 let overlaps = applied_ranges.iter().any(|(s, e)| start < *e && end > *s);
646 if overlaps {
647 continue;
648 }
649
650 if start <= result.len() && end <= result.len() && start <= end {
651 result.replace_range(start..end, &fix.new_text);
652 applied_ranges.push((start, start + fix.new_text.len()));
653 }
654 }
655 }
656
657 if !line_fixes.is_empty() {
658 let mut lines: Vec<String> = result.lines().map(|l| l.to_string()).collect();
659
660 let mut sorted_line_fixes = line_fixes;
661 sorted_line_fixes.sort_by(|a, b| b.line.cmp(&a.line));
662
663 for fix in sorted_line_fixes {
664 let line_idx = fix.line.saturating_sub(1);
665
666 if fix.delete_line {
667 if line_idx < lines.len() {
668 lines.remove(line_idx);
669 }
670 } else if fix.insert_after {
671 if line_idx < lines.len() {
672 lines.insert(line_idx + 1, fix.new_text.clone());
673 }
674 } else if let Some(old_text) = &fix.old_text {
675 if line_idx < lines.len() {
676 lines[line_idx] = lines[line_idx].replace(old_text, &fix.new_text);
677 }
678 } else if line_idx < lines.len() {
679 lines[line_idx] = fix.new_text.clone();
680 }
681 }
682
683 result = lines.join("\n");
684 }
685
686 result
687}