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 fix_covers_line(&self.content, f, *expected_line)
576 } else {
577 f.line == *expected_line
578 }
579 });
580 assert!(
581 has_fix_on_line,
582 "Expected fix on line {}, got fixes on lines: {:?}",
583 expected_line,
584 rule_errors
585 .iter()
586 .flat_map(|e| e.fixes.iter().map(|f| {
587 if f.is_range_based() {
588 let start = f.start_offset.unwrap_or(0);
589 let end = f.end_offset.unwrap_or(start);
590 let start_line = offset_to_line(&self.content, start);
591 let end_line = offset_to_line(&self.content, end);
592 if start_line == end_line {
593 start_line
594 } else {
595 let first_byte = self.content.as_bytes().get(start);
597 if first_byte == Some(&b'\n') {
598 start_line + 1
599 } else {
600 start_line
601 }
602 }
603 } else {
604 f.line
605 }
606 }))
607 .collect::<Vec<_>>()
608 );
609 }
610
611 if let Some(expected_output) = &self.expected_fix_output {
612 let fixes: Vec<_> = rule_errors.iter().flat_map(|e| e.fixes.iter()).collect();
613
614 assert!(
615 !fixes.is_empty(),
616 "Expected at least one fix to check output, got none"
617 );
618
619 let result = apply_fixes(&self.content, &fixes);
620 let expected_normalized = expected_output.trim();
621 let result_normalized = result.trim();
622
623 assert_eq!(
624 result_normalized, expected_normalized,
625 "Fix did not produce expected output.\nExpected:\n{}\n\nGot:\n{}",
626 expected_normalized, result_normalized
627 );
628 }
629 }
630}
631
632fn offset_to_line(content: &str, offset: usize) -> usize {
634 let offset = offset.min(content.len());
635 content[..offset].chars().filter(|&c| c == '\n').count() + 1
636}
637
638fn fix_covers_line(content: &str, fix: &Fix, line: usize) -> bool {
644 let start = fix.start_offset.unwrap_or(0);
645 let end = fix.end_offset.unwrap_or(start);
646 let start_line = offset_to_line(content, start);
647 let end_line = offset_to_line(content, end.max(1) - if end > start { 1 } else { 0 });
649 line >= start_line && line <= end_line
650}
651
652fn apply_fixes(content: &str, fixes: &[&Fix]) -> String {
657 let common_fixes: Vec<nginx_lint_common::Fix> = fixes
658 .iter()
659 .map(|f| nginx_lint_common::Fix {
660 line: f.line,
661 old_text: f.old_text.clone(),
662 new_text: f.new_text.clone(),
663 delete_line: f.delete_line,
664 insert_after: f.insert_after,
665 start_offset: f.start_offset,
666 end_offset: f.end_offset,
667 })
668 .collect();
669 let common_refs: Vec<&nginx_lint_common::Fix> = common_fixes.iter().collect();
670 let (result, _) = nginx_lint_common::apply_fixes_to_content(content, &common_refs);
671 result
672}