nginx_lint_plugin/
testing.rs

1//! Testing utilities for plugin development.
2//!
3//! This module provides two complementary approaches for testing plugins:
4//!
5//! - [`PluginTestRunner`] - A test runner with assertion methods and fixture-based testing
6//! - [`TestCase`] - A builder for inline, declarative test assertions
7//!
8//! # Quick Example
9//!
10//! ```
11//! use nginx_lint_plugin::prelude::*;
12//! use nginx_lint_plugin::testing::{PluginTestRunner, TestCase};
13//!
14//! // Define a simple plugin for demonstration
15//! # #[derive(Default)]
16//! # struct MyPlugin;
17//! # impl Plugin for MyPlugin {
18//! #     fn spec(&self) -> PluginSpec {
19//! #         PluginSpec::new("my-rule", "test", "Test rule").with_severity("warning")
20//! #     }
21//! #     fn check(&self, config: &Config, _path: &str) -> Vec<LintError> {
22//! #         let err = self.spec().error_builder();
23//! #         config.all_directives()
24//! #             .filter(|d| d.is("bad_directive"))
25//! #             .map(|d| err.warning_at("bad", d))
26//! #             .collect()
27//! #     }
28//! # }
29//!
30//! // Use PluginTestRunner for quick assertions
31//! let runner = PluginTestRunner::new(MyPlugin);
32//! runner.assert_has_errors("http {\n    bad_directive on;\n}");
33//! runner.assert_no_errors("http {\n    good_directive on;\n}");
34//!
35//! // Use TestCase for declarative, detailed assertions
36//! TestCase::new("http {\n    bad_directive on;\n}")
37//!     .expect_error_count(1)
38//!     .expect_error_on_line(2)
39//!     .run(&MyPlugin);
40//! ```
41//!
42//! # Fixture Directory Structure
43//!
44//! ```text
45//! tests/fixtures/
46//! └── 001_basic/
47//!     ├── error/nginx.conf      # Config that should trigger errors
48//!     └── expected/nginx.conf   # Config after applying fixes (no errors expected)
49//! ```
50
51use super::types::{Config, Fix, LintError, Plugin, PluginSpec};
52use std::path::{Path, PathBuf};
53
54/// Macro to get the fixtures directory path relative to the plugin's Cargo.toml
55///
56/// Usage in plugin tests:
57/// ```ignore
58/// runner.test_fixtures(nginx_lint_plugin::fixtures_dir!());
59/// ```
60#[macro_export]
61macro_rules! fixtures_dir {
62    () => {
63        concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures")
64    };
65}
66
67/// Test runner for plugins.
68///
69/// Provides assertion methods for testing plugin behavior against nginx config strings
70/// and fixture directories.
71///
72/// # Example
73///
74/// ```
75/// use nginx_lint_plugin::prelude::*;
76/// use nginx_lint_plugin::testing::PluginTestRunner;
77///
78/// # #[derive(Default)]
79/// # struct MyPlugin;
80/// # impl Plugin for MyPlugin {
81/// #     fn spec(&self) -> PluginSpec {
82/// #         PluginSpec::new("my-rule", "test", "Test rule")
83/// #     }
84/// #     fn check(&self, config: &Config, _path: &str) -> Vec<LintError> {
85/// #         let err = self.spec().error_builder();
86/// #         config.all_directives()
87/// #             .filter(|d| d.is("bad"))
88/// #             .map(|d| err.warning_at("bad", d))
89/// #             .collect()
90/// #     }
91/// # }
92/// let runner = PluginTestRunner::new(MyPlugin);
93///
94/// // Test a config string
95/// runner.assert_has_errors("http {\n    bad on;\n}");
96/// runner.assert_no_errors("http {\n    good on;\n}");
97/// runner.assert_errors("http {\n    bad on;\n    bad on;\n}", 2);
98/// runner.assert_error_on_line("http {\n    bad on;\n}", 2);
99/// ```
100pub struct PluginTestRunner<P: Plugin> {
101    plugin: P,
102}
103
104impl<P: Plugin> PluginTestRunner<P> {
105    /// Create a new test runner for a plugin
106    pub fn new(plugin: P) -> Self {
107        Self { plugin }
108    }
109
110    /// Get plugin spec
111    pub fn spec(&self) -> PluginSpec {
112        self.plugin.spec()
113    }
114
115    /// Run the plugin check on a config string
116    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    /// Run the plugin check on a file
123    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    /// Test all fixtures in a directory
132    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    /// Test a single fixture case
165    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    /// Assert that a config string produces specific errors
202    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    /// Assert that a config string produces no errors
222    pub fn assert_no_errors(&self, content: &str) {
223        self.assert_errors(content, 0);
224    }
225
226    /// Assert that a config string produces at least one error
227    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    /// Assert that a config string produces an error on a specific line
243    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    /// Assert that errors contain a specific message substring
263    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    /// Assert that errors have fixes
285    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    /// Assert that applying fixes produces the expected output
303    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    /// Test using bad.conf and good.conf example content
331    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    /// Test using bad.conf and good.conf, and verify fix converts bad to good
363    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
410/// Declarative test builder for inline plugin tests.
411///
412/// Chain expectations and then call [`run()`](TestCase::run) to execute:
413///
414/// ```
415/// use nginx_lint_plugin::prelude::*;
416/// use nginx_lint_plugin::testing::TestCase;
417///
418/// # #[derive(Default)]
419/// # struct MyPlugin;
420/// # impl Plugin for MyPlugin {
421/// #     fn spec(&self) -> PluginSpec {
422/// #         PluginSpec::new("my-rule", "test", "Test rule")
423/// #     }
424/// #     fn check(&self, config: &Config, _path: &str) -> Vec<LintError> {
425/// #         let err = self.spec().error_builder();
426/// #         config.all_directives()
427/// #             .filter(|d| d.is("autoindex") && d.first_arg_is("on"))
428/// #             .map(|d| err.warning_at("autoindex should be off", d)
429/// #                 .with_fix(d.replace_with("autoindex off;")))
430/// #             .collect()
431/// #     }
432/// # }
433/// TestCase::new("http {\n    autoindex on;\n}")
434///     .expect_error_count(1)
435///     .expect_error_on_line(2)
436///     .expect_message_contains("autoindex")
437///     .expect_has_fix()
438///     .expect_fix_produces("http {\n    autoindex off;\n}")
439///     .run(&MyPlugin);
440/// ```
441///
442/// # Available Expectations
443///
444/// | Method | Description |
445/// |--------|-------------|
446/// | [`expect_error_count(n)`](TestCase::expect_error_count) | Exact error count |
447/// | [`expect_no_errors()`](TestCase::expect_no_errors) | No errors |
448/// | [`expect_error_on_line(n)`](TestCase::expect_error_on_line) | Error on specific line |
449/// | [`expect_message_contains(s)`](TestCase::expect_message_contains) | Error message substring |
450/// | [`expect_has_fix()`](TestCase::expect_has_fix) | At least one error has a fix |
451/// | [`expect_fix_on_line(n)`](TestCase::expect_fix_on_line) | Fix targets specific line |
452/// | [`expect_fix_produces(s)`](TestCase::expect_fix_produces) | Verify fix output |
453pub 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    /// Create a new test case with the given config content
465    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    /// Expect a specific number of errors
478    pub fn expect_error_count(mut self, count: usize) -> Self {
479        self.expected_error_count = Some(count);
480        self
481    }
482
483    /// Expect no errors
484    pub fn expect_no_errors(self) -> Self {
485        self.expect_error_count(0)
486    }
487
488    /// Expect at least one error on the given line
489    pub fn expect_error_on_line(mut self, line: usize) -> Self {
490        self.expected_lines.push(line);
491        self
492    }
493
494    /// Expect error messages to contain the given substring
495    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    /// Expect at least one error to have a fix
501    pub fn expect_has_fix(mut self) -> Self {
502        self.expect_has_fix = true;
503        self
504    }
505
506    /// Expect a fix on a specific line
507    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    /// Expect that applying all fixes produces the given output
514    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    /// Run the test case with the given plugin
521    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                                // Show the primary target line (after any leading newline)
596                                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
632/// Convert a byte offset to a 1-based line number
633fn 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
638/// Check if a range-based fix covers (affects) the given line.
639///
640/// A fix that deletes a line often includes the preceding `\n`, so checking
641/// only `start_offset` would point to the previous line. This function checks
642/// whether the fix's byte range [start, end) spans any byte on the target line.
643fn 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    // end is exclusive, so subtract 1 to get the line of the last affected byte
648    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
652/// Apply fixes to content and return the result.
653///
654/// Converts plugin `Fix` to common `Fix` and delegates to
655/// `nginx_lint_common::apply_fixes_to_content` for normalization, overlap detection, and ordering.
656fn 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}