Skip to main content

nginx_lint_common/
config.rs

1//! Configuration management for nginx-lint.
2//!
3//! This module handles loading and validating the `.nginx-lint.toml`
4//! configuration file. The main entry point is [`LintConfig`], which can be
5//! loaded from a file with [`LintConfig::from_file`] or discovered
6//! automatically with [`LintConfig::find_and_load`].
7
8use schemars::JsonSchema;
9use serde::{Deserialize, Deserializer};
10use std::collections::{HashMap, HashSet};
11use std::fmt;
12use std::fs;
13use std::path::Path;
14
15/// Indent size configuration: either a fixed number or "auto" for auto-detection
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
17pub enum IndentSize {
18    /// Auto-detect indent size from the first indented line
19    #[default]
20    Auto,
21    /// Fixed indent size (number of spaces)
22    Fixed(usize),
23}
24
25impl fmt::Display for IndentSize {
26    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27        match self {
28            IndentSize::Auto => write!(f, "auto"),
29            IndentSize::Fixed(n) => write!(f, "{}", n),
30        }
31    }
32}
33
34impl<'de> Deserialize<'de> for IndentSize {
35    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
36    where
37        D: Deserializer<'de>,
38    {
39        use serde::de::{self, Visitor};
40
41        struct IndentSizeVisitor;
42
43        impl<'de> Visitor<'de> for IndentSizeVisitor {
44            type Value = IndentSize;
45
46            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
47                formatter.write_str("a positive integer or \"auto\"")
48            }
49
50            fn visit_u64<E>(self, value: u64) -> Result<IndentSize, E>
51            where
52                E: de::Error,
53            {
54                Ok(IndentSize::Fixed(value as usize))
55            }
56
57            fn visit_i64<E>(self, value: i64) -> Result<IndentSize, E>
58            where
59                E: de::Error,
60            {
61                if value > 0 {
62                    Ok(IndentSize::Fixed(value as usize))
63                } else {
64                    Err(de::Error::custom("indent_size must be positive"))
65                }
66            }
67
68            fn visit_str<E>(self, value: &str) -> Result<IndentSize, E>
69            where
70                E: de::Error,
71            {
72                if value.eq_ignore_ascii_case("auto") {
73                    Ok(IndentSize::Auto)
74                } else {
75                    Err(de::Error::custom(
76                        "expected \"auto\" or a positive integer for indent_size",
77                    ))
78                }
79            }
80        }
81
82        deserializer.deserialize_any(IndentSizeVisitor)
83    }
84}
85
86impl JsonSchema for IndentSize {
87    fn schema_name() -> std::borrow::Cow<'static, str> {
88        "IndentSize".into()
89    }
90
91    fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
92        serde_json::from_value(serde_json::json!({
93            "description": "Indentation size: a positive integer or \"auto\" for auto-detection",
94            "default": "auto",
95            "oneOf": [
96                { "type": "integer", "minimum": 1 },
97                { "type": "string", "enum": ["auto"] }
98            ]
99        }))
100        .unwrap()
101    }
102}
103
104/// Default configuration template for nginx-lint
105pub const DEFAULT_CONFIG_TEMPLATE: &str = r#"# nginx-lint configuration file
106# This file was generated by `nginx-lint config init`
107# See https://github.com/walf443/nginx-lint for more documentation
108
109# Target nginx version your config is deployed against (e.g. "1.31.0").
110# When set, rules that don't apply to this version are automatically skipped.
111# Per-rule `skip_version_check = true` forces a rule to run regardless.
112# target_nginx_version = "1.31.0"
113
114# Color output settings
115[color]
116# Color mode: "auto", "always", or "never"
117ui = "auto"
118# Severity colors (available: black, red, green, yellow, blue, magenta, cyan, white,
119#                  bright_black, bright_red, bright_green, bright_yellow, bright_blue,
120#                  bright_magenta, bright_cyan, bright_white)
121error = "red"
122warning = "yellow"
123
124# =============================================================================
125# Include Resolution Settings
126# =============================================================================
127[include]
128
129# Base directory for resolving relative include paths (similar to nginx -p prefix).
130# When set, all relative include paths are resolved from this directory
131# instead of the directory containing the config file with the include directive.
132# prefix = "/etc/nginx"
133
134# Path mappings applied to include patterns before resolving them.
135# Mappings are applied in declaration order, each receiving the output of the
136# previous one (chained).  Useful when the config references a directory that
137# differs from where the actual files live (e.g. sites-enabled → sites-available).
138#
139# Example (for Debian nginx package):
140
141# [[include.path_map]]
142# from = "/etc/nginx/"
143# to   = ""
144#
145# [[include.path_map]]
146# from = "sites-enabled"
147# to   = "sites-available"
148#
149# [[include.path_map]]
150# from = "modules-enabled"
151# to   = "modules-available"
152
153# =============================================================================
154# Style Rules
155# =============================================================================
156
157[rules.indent]
158enabled = true
159# Indentation size: number or "auto" for auto-detection (default: "auto")
160# indent_size = 4
161indent_size = "auto"
162
163[rules.trailing-whitespace]
164enabled = true
165
166[rules.space-before-semicolon]
167enabled = true
168
169[rules.block-lines]
170enabled = true
171# Maximum number of lines allowed in a block (default: 100)
172# max_block_lines = 100
173
174# =============================================================================
175# Syntax Rules
176# =============================================================================
177
178[rules.duplicate-directive]
179enabled = true
180
181[rules.unmatched-braces]
182enabled = true
183
184[rules.unclosed-quote]
185enabled = true
186
187[rules.missing-semicolon]
188enabled = true
189
190[rules.invalid-directive-context]
191enabled = true
192# Additional valid parent contexts for directives (for extension modules like nginx-rtmp-module)
193# Example for nginx-rtmp-module:
194# additional_contexts = { server = ["rtmp"], upstream = ["rtmp"] }
195
196[rules.include-path-exists]
197enabled = true
198
199# =============================================================================
200# Security Rules
201# =============================================================================
202
203[rules.deprecated-ssl-protocol]
204enabled = true
205# Allowed protocols for auto-fix (default: ["TLSv1.2", "TLSv1.3"])
206allowed_protocols = ["TLSv1.2", "TLSv1.3"]
207
208[rules.server-tokens-enabled]
209enabled = true
210
211[rules.autoindex-enabled]
212enabled = true
213
214[rules.weak-ssl-ciphers]
215enabled = true
216# Weak cipher patterns to detect
217weak_ciphers = [
218    "NULL",
219    "EXPORT",
220    "DES",
221    "RC4",
222    "MD5",
223    "aNULL",
224    "eNULL",
225    "ADH",
226    "AECDH",
227    "PSK",
228    "SRP",
229    "CAMELLIA",
230]
231# Required exclusion patterns
232required_exclusions = ["!aNULL", "!eNULL", "!EXPORT", "!DES", "!RC4", "!MD5"]
233
234[rules.nginx-rift]
235# CVE-2026-42945 / CVE-2026-9256: detects the rewrite-with-`?` +
236# capture-consumer pattern that triggers a heap buffer overflow on
237# nginx 0.6.27 .. 1.30.1 (CVE-2026-42945 fixed in 1.30.1 / 1.31.0; the
238# redirect-path CVE-2026-9256 those releases left open is fixed in
239# 1.30.2 / 1.31.1). The rule declares its applicable nginx version
240# range, so setting `target_nginx_version >= 1.30.2` above disables it
241# automatically. To run it anyway (e.g. on a mixed fleet), add
242# `skip_version_check = true` here.
243enabled = true
244# skip_version_check = true
245
246# =============================================================================
247# Best Practices
248# =============================================================================
249
250[rules.gzip-not-enabled]
251# Disabled by default: gzip is not always appropriate (CDN, CPU constraints, BREACH attack)
252enabled = false
253
254[rules.missing-error-log]
255# Disabled by default: error_log is typically set at top level in main config
256enabled = false
257
258[rules.proxy-pass-domain]
259enabled = true
260
261[rules.upstream-server-no-resolve]
262enabled = true
263
264[rules.directive-inheritance]
265enabled = true
266# Exclude specific directives from checking
267# excluded_directives = ["grpc_set_header", "uwsgi_param"]
268# Add custom directives to check (name is required, case_insensitive and multi_key default to false)
269# additional_directives = [
270#   { name = "proxy_set_cookie", case_insensitive = true },
271# ]
272
273[rules.root-in-location]
274enabled = true
275
276[rules.alias-location-slash-mismatch]
277enabled = true
278
279[rules.proxy-pass-with-uri]
280enabled = true
281
282[rules.proxy-keepalive]
283enabled = true
284
285[rules.try-files-with-proxy]
286enabled = true
287
288[rules.if-is-evil-in-location]
289enabled = true
290
291# =============================================================================
292# Parser Settings
293# =============================================================================
294
295[parser]
296# Additional block directives for extension modules
297# These are added to the built-in list (http, server, location, etc.)
298# Example for nginx-rtmp-module:
299# block_directives = ["rtmp", "application"]
300"#;
301
302/// Configuration for nginx-lint loaded from `.nginx-lint.toml`.
303///
304/// Use [`from_file`](Self::from_file) to load from a specific path, or
305/// [`find_and_load`](Self::find_and_load) to search the directory tree upward.
306/// A default `LintConfig` enables all rules except those listed in
307/// [`DISABLED_BY_DEFAULT`](Self::DISABLED_BY_DEFAULT).
308#[derive(Debug, Default, Deserialize, JsonSchema)]
309pub struct LintConfig {
310    /// Per-rule configuration keyed by rule name (e.g. `"indent"`, `"server-tokens-enabled"`).
311    #[serde(default)]
312    pub rules: HashMap<String, RuleConfig>,
313    /// Color output settings.
314    #[serde(default)]
315    pub color: ColorConfig,
316    /// Parser-level settings (e.g. additional block directives for extension modules).
317    #[serde(default)]
318    pub parser: ParserConfig,
319    /// Include resolution settings (e.g. path mappings for include directives).
320    #[serde(default)]
321    pub include: IncludeConfig,
322    /// Target nginx version (e.g. `"1.31.0"`).
323    ///
324    /// When set, rules whose declared version range does not include this
325    /// version are automatically skipped. Stored as a raw string so an
326    /// unparseable value does not fail config loading — the linter parses it
327    /// lazily and emits a single warning if invalid.
328    #[serde(default)]
329    pub target_nginx_version: Option<String>,
330}
331
332/// Parser configuration
333#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
334pub struct ParserConfig {
335    /// Additional block directives (extension modules, etc.)
336    /// These are added to the built-in list of block directives
337    #[serde(default)]
338    pub block_directives: Vec<String>,
339}
340
341/// A single path mapping rule for include directive resolution.
342///
343/// When an `include` directive's path contains path segment(s) that exactly
344/// match `from`, they are replaced with `to` before the path is resolved on
345/// disk.  Matching is performed at the path-component level (split by `/`),
346/// so `from = "sites-enabled"` matches `.../sites-enabled/...` but does NOT
347/// match `.../asites-enabled/...`.  Multi-segment values like
348/// `from = "nginx/sites-enabled"` match consecutive components.
349///
350/// This is useful when the production config references a directory
351/// (e.g. `sites-enabled`) that is only populated at runtime via symlinks,
352/// and you want nginx-lint to evaluate the actual source files
353/// (e.g. `sites-available`) instead.
354///
355/// Mappings are applied in declaration order and chained, so the output of one
356/// mapping is fed into the next.
357#[derive(Debug, Clone, Deserialize, JsonSchema)]
358pub struct PathMapping {
359    /// Path segment(s) to match in the include path (compared component-wise)
360    pub from: String,
361    /// Replacement path segment(s)
362    pub to: String,
363}
364
365/// Include resolution configuration.
366#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
367pub struct IncludeConfig {
368    /// Path mappings applied to include patterns before resolving them.
369    /// Applied in declaration order; each mapping receives the output of the previous one.
370    #[serde(default)]
371    pub path_map: Vec<PathMapping>,
372    /// Base directory for resolving relative include paths (similar to nginx `-p` prefix).
373    /// When set, all relative include paths are resolved from this directory
374    /// instead of the directory containing the config file with the include directive.
375    pub prefix: Option<String>,
376}
377
378/// Color output configuration
379#[derive(Debug, Clone, Deserialize, JsonSchema)]
380pub struct ColorConfig {
381    /// Color mode: "auto" (default), "always", or "never"
382    #[serde(default)]
383    pub ui: ColorMode,
384    /// Color for error messages (default: "red")
385    #[serde(default = "default_error_color")]
386    pub error: Color,
387    /// Color for warning messages (default: "yellow")
388    #[serde(default = "default_warning_color")]
389    pub warning: Color,
390}
391
392impl Default for ColorConfig {
393    fn default() -> Self {
394        Self {
395            ui: ColorMode::Auto,
396            error: Color::Red,
397            warning: Color::Yellow,
398        }
399    }
400}
401
402fn default_error_color() -> Color {
403    Color::Red
404}
405
406fn default_warning_color() -> Color {
407    Color::Yellow
408}
409
410/// Available colors for output
411#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
412pub enum Color {
413    Black,
414    Red,
415    Green,
416    Yellow,
417    Blue,
418    Magenta,
419    Cyan,
420    #[default]
421    White,
422    BrightBlack,
423    BrightRed,
424    BrightGreen,
425    BrightYellow,
426    BrightBlue,
427    BrightMagenta,
428    BrightCyan,
429    BrightWhite,
430}
431
432impl JsonSchema for Color {
433    fn schema_name() -> std::borrow::Cow<'static, str> {
434        "Color".into()
435    }
436
437    fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
438        serde_json::from_value(serde_json::json!({
439            "type": "string",
440            "enum": [
441                "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white",
442                "bright_black", "bright_red", "bright_green", "bright_yellow",
443                "bright_blue", "bright_magenta", "bright_cyan", "bright_white"
444            ]
445        }))
446        .unwrap()
447    }
448}
449
450impl<'de> Deserialize<'de> for Color {
451    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
452    where
453        D: serde::Deserializer<'de>,
454    {
455        use serde::de::Error;
456
457        let s = String::deserialize(deserializer)?;
458        match s.to_lowercase().as_str() {
459            "black" => Ok(Color::Black),
460            "red" => Ok(Color::Red),
461            "green" => Ok(Color::Green),
462            "yellow" => Ok(Color::Yellow),
463            "blue" => Ok(Color::Blue),
464            "magenta" => Ok(Color::Magenta),
465            "cyan" => Ok(Color::Cyan),
466            "white" => Ok(Color::White),
467            "bright_black" | "brightblack" => Ok(Color::BrightBlack),
468            "bright_red" | "brightred" => Ok(Color::BrightRed),
469            "bright_green" | "brightgreen" => Ok(Color::BrightGreen),
470            "bright_yellow" | "brightyellow" => Ok(Color::BrightYellow),
471            "bright_blue" | "brightblue" => Ok(Color::BrightBlue),
472            "bright_magenta" | "brightmagenta" => Ok(Color::BrightMagenta),
473            "bright_cyan" | "brightcyan" => Ok(Color::BrightCyan),
474            "bright_white" | "brightwhite" => Ok(Color::BrightWhite),
475            _ => Err(D::Error::custom(format!(
476                "invalid color '{}', expected one of: black, red, green, yellow, blue, magenta, cyan, white, \
477                 bright_black, bright_red, bright_green, bright_yellow, bright_blue, bright_magenta, bright_cyan, bright_white",
478                s
479            ))),
480        }
481    }
482}
483
484/// Color mode for output
485#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
486pub enum ColorMode {
487    /// Automatically detect (default) - respects NO_COLOR env and terminal detection
488    #[default]
489    Auto,
490    /// Always use colors
491    Always,
492    /// Never use colors
493    Never,
494}
495
496impl JsonSchema for ColorMode {
497    fn schema_name() -> std::borrow::Cow<'static, str> {
498        "ColorMode".into()
499    }
500
501    fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
502        serde_json::from_value(serde_json::json!({
503            "type": "string",
504            "description": "Color mode: \"auto\" respects NO_COLOR env and terminal detection, \"always\" forces colors, \"never\" disables colors",
505            "default": "auto",
506            "enum": ["auto", "always", "never"]
507        }))
508        .unwrap()
509    }
510}
511
512impl<'de> Deserialize<'de> for ColorMode {
513    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
514    where
515        D: serde::Deserializer<'de>,
516    {
517        use serde::de::Error;
518
519        let s = String::deserialize(deserializer)?;
520        match s.as_str() {
521            "auto" => Ok(ColorMode::Auto),
522            "always" => Ok(ColorMode::Always),
523            "never" => Ok(ColorMode::Never),
524            _ => Err(D::Error::custom(format!(
525                "invalid color mode '{}', expected 'auto', 'always', or 'never'",
526                s
527            ))),
528        }
529    }
530}
531
532/// An additional directive to check for inheritance issues.
533///
534/// Used in `[rules.directive-inheritance]` configuration.
535#[derive(Debug, Clone, Deserialize, JsonSchema)]
536pub struct AdditionalDirective {
537    /// The directive name (e.g., "proxy_set_cookie")
538    pub name: String,
539    /// Whether the first argument key comparison is case-insensitive (default: false)
540    #[serde(default)]
541    pub case_insensitive: bool,
542    /// If true, all numeric arguments are separate keys like error_page (default: false)
543    #[serde(default)]
544    pub multi_key: bool,
545}
546
547/// Configuration for a specific lint rule.
548///
549/// Every `[rules.<name>]` section in `.nginx-lint.toml` is deserialized into
550/// a `RuleConfig`. The only universal field is [`enabled`](Self::enabled);
551/// the remaining fields are rule-specific options.
552#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
553pub struct RuleConfig {
554    /// Whether this rule is active (`true` by default for most rules).
555    #[serde(default = "default_true")]
556    pub enabled: bool,
557    /// When true, run this rule regardless of the configured
558    /// [`target_nginx_version`](LintConfig::target_nginx_version) and the rule's
559    /// declared version range. Useful for opt-in overrides on rules that would
560    /// otherwise be filtered out as not applicable to the target version.
561    #[serde(default)]
562    pub skip_version_check: bool,
563    /// For indent rule: number or "auto" for auto-detection
564    pub indent_size: Option<IndentSize>,
565    /// For deprecated-ssl-protocol rule: allowed protocols (default: ["TLSv1.2", "TLSv1.3"])
566    pub allowed_protocols: Option<Vec<String>>,
567    /// For weak-ssl-ciphers rule: weak cipher patterns to detect
568    pub weak_ciphers: Option<Vec<String>>,
569    /// For weak-ssl-ciphers rule: required exclusion patterns
570    pub required_exclusions: Option<Vec<String>>,
571    /// For invalid-directive-context rule: additional valid parent contexts
572    /// Format: { "server" = ["rtmp"], "upstream" = ["rtmp"] }
573    pub additional_contexts: Option<HashMap<String, Vec<String>>>,
574    /// For block-lines rule: maximum number of lines allowed in a block
575    pub max_block_lines: Option<usize>,
576    /// For directive-inheritance rule: directives to exclude from checking
577    pub excluded_directives: Option<Vec<String>>,
578    /// For directive-inheritance rule: additional directives to check
579    pub additional_directives: Option<Vec<AdditionalDirective>>,
580}
581
582fn default_true() -> bool {
583    true
584}
585
586impl LintConfig {
587    /// Load configuration from a file
588    pub fn from_file(path: &Path) -> Result<Self, ConfigError> {
589        let content = fs::read_to_string(path).map_err(|e| ConfigError::IoError {
590            path: path.to_path_buf(),
591            source: e,
592        })?;
593
594        toml::from_str(&content).map_err(|e| ConfigError::ParseError {
595            path: path.to_path_buf(),
596            source: e,
597        })
598    }
599
600    /// Parse configuration from a TOML string
601    pub fn parse(content: &str) -> Result<Self, String> {
602        toml::from_str(content).map_err(|e| e.to_string())
603    }
604
605    /// Find and load .nginx-lint.toml from the given directory or its parents.
606    ///
607    /// Returns the loaded config along with the path of the config file found.
608    pub fn find_and_load(dir: &Path) -> Option<(Self, std::path::PathBuf)> {
609        let mut current = dir.to_path_buf();
610
611        loop {
612            let config_path = current.join(".nginx-lint.toml");
613            if config_path.exists() {
614                return Self::from_file(&config_path)
615                    .ok()
616                    .map(|cfg| (cfg, config_path));
617            }
618
619            if !current.pop() {
620                break;
621            }
622        }
623
624        None
625    }
626
627    /// Rules that are disabled by default
628    pub const DISABLED_BY_DEFAULT: &'static [&'static str] = &[
629        "gzip-not-enabled", // gzip is not always appropriate (CDN, CPU constraints, security)
630        "missing-error-log", // error_log is typically set at top level in main config
631    ];
632
633    /// Native lint rules implemented directly in the top-level crate
634    /// (i.e. not packaged as plugins under `plugins/builtin/`).
635    ///
636    /// Exposed as a `pub const` so the drift-detection test in the top-level
637    /// crate (`tests/known_rules_drift_test.rs`) can distinguish "native rule"
638    /// from "stale builtin plugin entry" without duplicating this list.
639    pub const NATIVE_RULE_NAMES: &'static [&'static str] = &[
640        "unmatched-braces",
641        "unclosed-quote",
642        "missing-semicolon",
643        "indent",
644        "include-path-exists",
645    ];
646
647    /// All rule names recognised by `nginx-lint config validate`.
648    ///
649    /// This is the union of [`NATIVE_RULE_NAMES`](Self::NATIVE_RULE_NAMES) and
650    /// the builtin plugin names. The builtin plugin list lives in the
651    /// top-level crate (`nginx-lint`) as `BUILTIN_PLUGIN_NAMES`; because this
652    /// module is a downstream dependency it cannot reference that symbol
653    /// directly, so the two lists are kept in sync by a drift-detection unit
654    /// test in the top-level crate (`tests/known_rules_drift_test.rs`).
655    ///
656    /// Order: native rules first, then builtin plugins in the same order as
657    /// `BUILTIN_PLUGIN_NAMES` so review diffs against that file are obvious.
658    pub const KNOWN_RULE_NAMES: &'static [&'static str] = &[
659        // Native rules — must match `NATIVE_RULE_NAMES` above
660        "unmatched-braces",
661        "unclosed-quote",
662        "missing-semicolon",
663        "indent",
664        "include-path-exists",
665        // Builtin plugins — must match `BUILTIN_PLUGIN_NAMES` in
666        // `src/plugin/mod.rs` (same order for easier review)
667        "server-tokens-enabled",
668        "autoindex-enabled",
669        "gzip-not-enabled",
670        "duplicate-directive",
671        "space-before-semicolon",
672        "trailing-whitespace",
673        "block-lines",
674        "proxy-pass-domain",
675        "upstream-server-no-resolve",
676        "directive-inheritance",
677        "root-in-location",
678        "alias-location-slash-mismatch",
679        "proxy-pass-with-uri",
680        "proxy-keepalive",
681        "try-files-with-proxy",
682        "if-is-evil-in-location",
683        "unreachable-location",
684        "missing-error-log",
685        "deprecated-ssl-protocol",
686        "weak-ssl-ciphers",
687        "invalid-directive-context",
688        "map-missing-default",
689        "ssl-on-deprecated",
690        "listen-http2-deprecated",
691        "proxy-missing-host-header",
692        "client-max-body-size-not-set",
693        "nginx-rift",
694    ];
695
696    /// Check if a rule is enabled
697    pub fn is_rule_enabled(&self, name: &str) -> bool {
698        self.rules
699            .get(name)
700            .map(|r| r.enabled)
701            .unwrap_or_else(|| !Self::DISABLED_BY_DEFAULT.contains(&name))
702    }
703
704    /// Whether the user explicitly wrote a `[rules.<name>]` section for this
705    /// rule (irrespective of which options it contains). Used to distinguish
706    /// "explicitly enabled" from "enabled by default" when warning about
707    /// rules that fall outside the configured nginx version range.
708    pub fn rule_explicitly_configured(&self, name: &str) -> bool {
709        self.rules.contains_key(name)
710    }
711
712    /// Whether a rule has `skip_version_check = true` in its configuration.
713    pub fn rule_skip_version_check(&self, name: &str) -> bool {
714        self.rules
715            .get(name)
716            .map(|r| r.skip_version_check)
717            .unwrap_or(false)
718    }
719
720    /// The configured target nginx version as a raw string, if any.
721    pub fn target_nginx_version(&self) -> Option<&str> {
722        self.target_nginx_version.as_deref()
723    }
724
725    /// Get the configuration for a specific rule
726    pub fn get_rule_config(&self, name: &str) -> Option<&RuleConfig> {
727        self.rules.get(name)
728    }
729
730    /// Get the color mode setting
731    pub fn color_mode(&self) -> ColorMode {
732        self.color.ui
733    }
734
735    /// Get additional block directives from config
736    pub fn additional_block_directives(&self) -> &[String] {
737        &self.parser.block_directives
738    }
739
740    /// Get include path mappings (applied in order to include patterns before resolving)
741    pub fn include_path_mappings(&self) -> &[PathMapping] {
742        &self.include.path_map
743    }
744
745    /// Generate the JSON Schema for the configuration file.
746    ///
747    /// The schema is derived from the Rust type definitions, so it automatically
748    /// stays in sync with the actual configuration structure.
749    pub fn json_schema() -> serde_json::Value {
750        let generator = schemars::SchemaGenerator::default();
751        let schema = generator.into_root_schema_for::<LintConfig>();
752        serde_json::to_value(schema).unwrap()
753    }
754
755    /// Get include prefix (base directory for resolving relative include paths)
756    pub fn include_prefix(&self) -> Option<&str> {
757        self.include.prefix.as_deref()
758    }
759
760    /// Get additional contexts for invalid-directive-context rule
761    pub fn additional_contexts(&self) -> Option<&HashMap<String, Vec<String>>> {
762        self.rules
763            .get("invalid-directive-context")
764            .and_then(|r| r.additional_contexts.as_ref())
765    }
766
767    /// Get excluded directives for directive-inheritance rule
768    pub fn directive_inheritance_excluded(&self) -> Option<&[String]> {
769        self.rules
770            .get("directive-inheritance")
771            .and_then(|r| r.excluded_directives.as_deref())
772    }
773
774    /// Get additional directives for directive-inheritance rule
775    pub fn directive_inheritance_additional(&self) -> Option<&[AdditionalDirective]> {
776        self.rules
777            .get("directive-inheritance")
778            .and_then(|r| r.additional_directives.as_deref())
779    }
780
781    /// Validate a configuration file and return any errors
782    pub fn validate_file(path: &Path) -> Result<Vec<ValidationError>, ConfigError> {
783        let content = fs::read_to_string(path).map_err(|e| ConfigError::IoError {
784            path: path.to_path_buf(),
785            source: e,
786        })?;
787
788        Self::validate_content(&content, path)
789    }
790
791    /// Validate configuration content and return any errors
792    fn validate_content(content: &str, path: &Path) -> Result<Vec<ValidationError>, ConfigError> {
793        let value: toml::Value = toml::from_str(content).map_err(|e| ConfigError::ParseError {
794            path: path.to_path_buf(),
795            source: e,
796        })?;
797
798        let mut errors = Vec::new();
799
800        if let toml::Value::Table(root) = value {
801            // Known top-level keys
802            let known_top_level: HashSet<&str> = [
803                "rules",
804                "color",
805                "parser",
806                "include",
807                "target_nginx_version",
808            ]
809            .into_iter()
810            .collect();
811
812            for key in root.keys() {
813                if !known_top_level.contains(key.as_str()) {
814                    let line = find_key_line(content, None, key);
815                    errors.push(ValidationError::UnknownField {
816                        path: key.clone(),
817                        line,
818                        suggestion: suggest_field(key, &known_top_level),
819                    });
820                }
821            }
822
823            // Validate [color] section
824            if let Some(toml::Value::Table(color)) = root.get("color") {
825                let known_color_keys: HashSet<&str> =
826                    ["ui", "error", "warning"].into_iter().collect();
827
828                for key in color.keys() {
829                    if !known_color_keys.contains(key.as_str()) {
830                        let line = find_key_line(content, Some("color"), key);
831                        errors.push(ValidationError::UnknownField {
832                            path: format!("color.{}", key),
833                            line,
834                            suggestion: suggest_field(key, &known_color_keys),
835                        });
836                    }
837                }
838            }
839
840            // Validate [parser] section
841            if let Some(toml::Value::Table(parser)) = root.get("parser") {
842                let known_parser_keys: HashSet<&str> = ["block_directives"].into_iter().collect();
843
844                for key in parser.keys() {
845                    if !known_parser_keys.contains(key.as_str()) {
846                        let line = find_key_line(content, Some("parser"), key);
847                        errors.push(ValidationError::UnknownField {
848                            path: format!("parser.{}", key),
849                            line,
850                            suggestion: suggest_field(key, &known_parser_keys),
851                        });
852                    }
853                }
854            }
855
856            // Validate [include] section
857            if let Some(toml::Value::Table(include)) = root.get("include") {
858                let known_include_keys: HashSet<&str> =
859                    ["path_map", "prefix"].into_iter().collect();
860
861                for key in include.keys() {
862                    if !known_include_keys.contains(key.as_str()) {
863                        let line = find_key_line(content, Some("include"), key);
864                        errors.push(ValidationError::UnknownField {
865                            path: format!("include.{}", key),
866                            line,
867                            suggestion: suggest_field(key, &known_include_keys),
868                        });
869                    }
870                }
871            }
872
873            // Validate [rules.*] sections
874            if let Some(toml::Value::Table(rules)) = root.get("rules") {
875                let known_rules: HashSet<&str> = Self::KNOWN_RULE_NAMES.iter().copied().collect();
876
877                for (rule_name, rule_value) in rules {
878                    if !known_rules.contains(rule_name.as_str()) {
879                        let line = find_key_line(content, Some("rules"), rule_name);
880                        errors.push(ValidationError::UnknownRule {
881                            name: rule_name.clone(),
882                            line,
883                            suggestion: suggest_field(rule_name, &known_rules),
884                        });
885                        continue;
886                    }
887
888                    // Validate rule options
889                    if let toml::Value::Table(rule_config) = rule_value {
890                        let known_rule_options = get_known_rule_options(rule_name);
891                        let section = format!("rules.{}", rule_name);
892
893                        for key in rule_config.keys() {
894                            if !known_rule_options.contains(key.as_str()) {
895                                let line = find_key_line(content, Some(&section), key);
896                                errors.push(ValidationError::UnknownRuleOption {
897                                    rule: rule_name.clone(),
898                                    option: key.clone(),
899                                    line,
900                                    suggestion: suggest_field(key, &known_rule_options),
901                                });
902                            }
903                        }
904                    }
905                }
906            }
907        }
908
909        Ok(errors)
910    }
911}
912
913/// Find the line number where a key is defined in the TOML content
914fn find_key_line(content: &str, section: Option<&str>, key: &str) -> Option<usize> {
915    let lines: Vec<&str> = content.lines().collect();
916
917    // For top-level sections (section is None), look for [key]
918    if section.is_none() {
919        let section_header = format!("[{}]", key);
920        for (i, line) in lines.iter().enumerate() {
921            if line.trim() == section_header {
922                return Some(i + 1);
923            }
924        }
925        return None;
926    }
927
928    let target_section = section.unwrap();
929    let mut in_section = false;
930
931    for (i, line) in lines.iter().enumerate() {
932        let trimmed = line.trim();
933
934        // Check for section header [section] or [section.subsection]
935        if trimmed.starts_with('[') && trimmed.ends_with(']') {
936            let section_name = &trimmed[1..trimmed.len() - 1];
937
938            // Check if this is a [rules.rule-name] style section
939            let full_section = format!("{}.{}", target_section, key);
940            if section_name == full_section {
941                return Some(i + 1);
942            }
943
944            in_section = section_name == target_section
945                || section_name.starts_with(&format!("{}.", target_section));
946            continue;
947        }
948
949        // Check for key = value within the section
950        if in_section && let Some((k, _)) = trimmed.split_once('=') {
951            let k = k.trim();
952            if k == key {
953                return Some(i + 1);
954            }
955        }
956    }
957
958    None
959}
960
961/// Get known options for a specific rule
962fn get_known_rule_options(rule_name: &str) -> HashSet<&'static str> {
963    let mut options: HashSet<&str> = ["enabled", "skip_version_check"].into_iter().collect();
964
965    match rule_name {
966        "indent" => {
967            options.insert("indent_size");
968        }
969        "deprecated-ssl-protocol" => {
970            options.insert("allowed_protocols");
971        }
972        "weak-ssl-ciphers" => {
973            options.insert("weak_ciphers");
974            options.insert("required_exclusions");
975        }
976        "block-lines" => {
977            options.insert("max_block_lines");
978        }
979        "directive-inheritance" => {
980            options.insert("excluded_directives");
981            options.insert("additional_directives");
982        }
983        _ => {}
984    }
985
986    options
987}
988
989/// Suggest a similar field name if one exists
990fn suggest_field(input: &str, known: &HashSet<&str>) -> Option<String> {
991    let input_lower = input.to_lowercase();
992
993    // Find the closest match using simple edit distance
994    known
995        .iter()
996        .filter(|&&k| {
997            let k_lower = k.to_lowercase();
998            // Simple heuristic: check if strings are similar
999            k_lower.contains(&input_lower)
1000                || input_lower.contains(&k_lower)
1001                || levenshtein_distance(&input_lower, &k_lower) <= 2
1002        })
1003        .min_by_key(|&&k| levenshtein_distance(&input.to_lowercase(), &k.to_lowercase()))
1004        .map(|&s| s.to_string())
1005}
1006
1007/// Simple Levenshtein distance implementation
1008fn levenshtein_distance(a: &str, b: &str) -> usize {
1009    let a_chars: Vec<char> = a.chars().collect();
1010    let b_chars: Vec<char> = b.chars().collect();
1011    let a_len = a_chars.len();
1012    let b_len = b_chars.len();
1013
1014    if a_len == 0 {
1015        return b_len;
1016    }
1017    if b_len == 0 {
1018        return a_len;
1019    }
1020
1021    let mut matrix = vec![vec![0; b_len + 1]; a_len + 1];
1022
1023    for (i, row) in matrix.iter_mut().enumerate().take(a_len + 1) {
1024        row[0] = i;
1025    }
1026    for (j, cell) in matrix[0].iter_mut().enumerate().take(b_len + 1) {
1027        *cell = j;
1028    }
1029
1030    for i in 1..=a_len {
1031        for j in 1..=b_len {
1032            let cost = usize::from(a_chars[i - 1] != b_chars[j - 1]);
1033            matrix[i][j] = (matrix[i - 1][j] + 1)
1034                .min(matrix[i][j - 1] + 1)
1035                .min(matrix[i - 1][j - 1] + cost);
1036        }
1037    }
1038
1039    matrix[a_len][b_len]
1040}
1041
1042/// Validation error for configuration files.
1043///
1044/// Returned by [`LintConfig::validate_file`] when the TOML file contains
1045/// unrecognised keys or rule names. Each variant includes an optional
1046/// `suggestion` produced by fuzzy matching.
1047#[derive(Debug, Clone)]
1048pub enum ValidationError {
1049    /// An unrecognised top-level or section-level field (e.g. `[colour]` instead of `[color]`).
1050    UnknownField {
1051        /// Dotted path to the field (e.g. `"color.ui_mode"`).
1052        path: String,
1053        /// 1-indexed line number where the field appears, if found.
1054        line: Option<usize>,
1055        /// A similar known field name, if one is close enough.
1056        suggestion: Option<String>,
1057    },
1058    /// A `[rules.<name>]` section where `<name>` is not a known rule.
1059    UnknownRule {
1060        /// The unrecognised rule name.
1061        name: String,
1062        /// 1-indexed line number where the rule section appears, if found.
1063        line: Option<usize>,
1064        /// A similar known rule name, if one is close enough.
1065        suggestion: Option<String>,
1066    },
1067    /// An unrecognised option inside a rule section (e.g. `indent_sizee` in `[rules.indent]`).
1068    UnknownRuleOption {
1069        /// The rule name containing the unknown option.
1070        rule: String,
1071        /// The unrecognised option key.
1072        option: String,
1073        /// 1-indexed line number where the option appears, if found.
1074        line: Option<usize>,
1075        /// A similar known option name, if one is close enough.
1076        suggestion: Option<String>,
1077    },
1078}
1079
1080impl std::fmt::Display for ValidationError {
1081    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1082        match self {
1083            ValidationError::UnknownField {
1084                path,
1085                line,
1086                suggestion,
1087            } => {
1088                if let Some(l) = line {
1089                    write!(f, "line {}: ", l)?;
1090                }
1091                write!(f, "unknown field '{}'", path)?;
1092                if let Some(s) = suggestion {
1093                    write!(f, ", did you mean '{}'?", s)?;
1094                }
1095                Ok(())
1096            }
1097            ValidationError::UnknownRule {
1098                name,
1099                line,
1100                suggestion,
1101            } => {
1102                if let Some(l) = line {
1103                    write!(f, "line {}: ", l)?;
1104                }
1105                write!(f, "unknown rule '{}'", name)?;
1106                if let Some(s) = suggestion {
1107                    write!(f, ", did you mean '{}'?", s)?;
1108                }
1109                Ok(())
1110            }
1111            ValidationError::UnknownRuleOption {
1112                rule,
1113                option,
1114                line,
1115                suggestion,
1116            } => {
1117                if let Some(l) = line {
1118                    write!(f, "line {}: ", l)?;
1119                }
1120                write!(f, "unknown option '{}' for rule '{}'", option, rule)?;
1121                if let Some(s) = suggestion {
1122                    write!(f, ", did you mean '{}'?", s)?;
1123                }
1124                Ok(())
1125            }
1126        }
1127    }
1128}
1129
1130/// Error returned when loading or parsing a configuration file fails.
1131#[derive(Debug)]
1132pub enum ConfigError {
1133    /// The file could not be read (missing, permission denied, etc.).
1134    IoError {
1135        /// Path that was attempted.
1136        path: std::path::PathBuf,
1137        /// Underlying I/O error.
1138        source: std::io::Error,
1139    },
1140    /// The file was read but contains invalid TOML.
1141    ParseError {
1142        /// Path that was parsed.
1143        path: std::path::PathBuf,
1144        /// Underlying TOML parse error.
1145        source: toml::de::Error,
1146    },
1147}
1148
1149impl std::fmt::Display for ConfigError {
1150    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1151        match self {
1152            ConfigError::IoError { path, source } => {
1153                write!(
1154                    f,
1155                    "Failed to read config file '{}': {}",
1156                    path.display(),
1157                    source
1158                )
1159            }
1160            ConfigError::ParseError { path, source } => {
1161                write!(
1162                    f,
1163                    "Failed to parse config file '{}': {}",
1164                    path.display(),
1165                    source
1166                )
1167            }
1168        }
1169    }
1170}
1171
1172impl std::error::Error for ConfigError {
1173    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1174        match self {
1175            ConfigError::IoError { source, .. } => Some(source),
1176            ConfigError::ParseError { source, .. } => Some(source),
1177        }
1178    }
1179}
1180
1181#[cfg(test)]
1182mod tests {
1183    use super::*;
1184    use std::io::Write;
1185    use tempfile::NamedTempFile;
1186
1187    #[test]
1188    fn test_default_config() {
1189        let config = LintConfig::default();
1190        assert!(config.is_rule_enabled("any-rule"));
1191    }
1192
1193    #[test]
1194    fn test_disabled_by_default_rules() {
1195        let config = LintConfig::default();
1196        // These rules should be disabled by default
1197        assert!(!config.is_rule_enabled("gzip-not-enabled"));
1198        assert!(!config.is_rule_enabled("missing-error-log"));
1199        // Other rules should still be enabled by default
1200        assert!(config.is_rule_enabled("server-tokens-enabled"));
1201    }
1202
1203    #[test]
1204    fn test_parse_config() {
1205        let toml_content = r#"
1206[rules.indent]
1207enabled = true
1208indent_size = 2
1209
1210[rules.server-tokens-enabled]
1211enabled = false
1212"#;
1213        let mut file = NamedTempFile::new().unwrap();
1214        write!(file, "{}", toml_content).unwrap();
1215
1216        let config = LintConfig::from_file(file.path()).unwrap();
1217
1218        assert!(config.is_rule_enabled("indent"));
1219        assert!(!config.is_rule_enabled("server-tokens-enabled"));
1220        assert!(config.is_rule_enabled("unknown-rule"));
1221
1222        let indent_config = config.get_rule_config("indent").unwrap();
1223        assert_eq!(indent_config.indent_size, Some(IndentSize::Fixed(2)));
1224    }
1225
1226    #[test]
1227    fn test_empty_config() {
1228        let toml_content = "";
1229        let mut file = NamedTempFile::new().unwrap();
1230        write!(file, "{}", toml_content).unwrap();
1231
1232        let config = LintConfig::from_file(file.path()).unwrap();
1233        assert!(config.is_rule_enabled("any-rule"));
1234    }
1235
1236    #[test]
1237    fn test_indent_size_auto() {
1238        let toml_content = r#"
1239[rules.indent]
1240enabled = true
1241indent_size = "auto"
1242"#;
1243        let mut file = NamedTempFile::new().unwrap();
1244        write!(file, "{}", toml_content).unwrap();
1245
1246        let config = LintConfig::from_file(file.path()).unwrap();
1247        let indent_config = config.get_rule_config("indent").unwrap();
1248        assert_eq!(indent_config.indent_size, Some(IndentSize::Auto));
1249    }
1250
1251    #[test]
1252    fn test_color_config_default() {
1253        let config = LintConfig::default();
1254        assert_eq!(config.color_mode(), ColorMode::Auto);
1255    }
1256
1257    #[test]
1258    fn test_color_config_auto() {
1259        let toml_content = r#"
1260[color]
1261ui = "auto"
1262"#;
1263        let mut file = NamedTempFile::new().unwrap();
1264        write!(file, "{}", toml_content).unwrap();
1265
1266        let config = LintConfig::from_file(file.path()).unwrap();
1267        assert_eq!(config.color_mode(), ColorMode::Auto);
1268    }
1269
1270    #[test]
1271    fn test_color_config_never() {
1272        let toml_content = r#"
1273[color]
1274ui = "never"
1275"#;
1276        let mut file = NamedTempFile::new().unwrap();
1277        write!(file, "{}", toml_content).unwrap();
1278
1279        let config = LintConfig::from_file(file.path()).unwrap();
1280        assert_eq!(config.color_mode(), ColorMode::Never);
1281    }
1282
1283    #[test]
1284    fn test_color_config_always() {
1285        let toml_content = r#"
1286[color]
1287ui = "always"
1288"#;
1289        let mut file = NamedTempFile::new().unwrap();
1290        write!(file, "{}", toml_content).unwrap();
1291
1292        let config = LintConfig::from_file(file.path()).unwrap();
1293        assert_eq!(config.color_mode(), ColorMode::Always);
1294    }
1295
1296    #[test]
1297    fn test_color_config_default_colors() {
1298        let config = LintConfig::default();
1299        assert_eq!(config.color.error, Color::Red);
1300        assert_eq!(config.color.warning, Color::Yellow);
1301    }
1302
1303    #[test]
1304    fn test_color_config_custom_colors() {
1305        let toml_content = r#"
1306[color]
1307error = "magenta"
1308warning = "cyan"
1309"#;
1310        let mut file = NamedTempFile::new().unwrap();
1311        write!(file, "{}", toml_content).unwrap();
1312
1313        let config = LintConfig::from_file(file.path()).unwrap();
1314        assert_eq!(config.color.error, Color::Magenta);
1315        assert_eq!(config.color.warning, Color::Cyan);
1316    }
1317
1318    #[test]
1319    fn test_color_config_bright_colors() {
1320        let toml_content = r#"
1321[color]
1322error = "bright_red"
1323warning = "bright_yellow"
1324"#;
1325        let mut file = NamedTempFile::new().unwrap();
1326        write!(file, "{}", toml_content).unwrap();
1327
1328        let config = LintConfig::from_file(file.path()).unwrap();
1329        assert_eq!(config.color.error, Color::BrightRed);
1330        assert_eq!(config.color.warning, Color::BrightYellow);
1331    }
1332
1333    #[test]
1334    fn test_block_lines_max_block_lines_parsing() {
1335        let toml_content = r#"
1336[rules.block-lines]
1337enabled = true
1338max_block_lines = 50
1339"#;
1340        let mut file = NamedTempFile::new().unwrap();
1341        write!(file, "{}", toml_content).unwrap();
1342
1343        let config = LintConfig::from_file(file.path()).unwrap();
1344        assert!(config.is_rule_enabled("block-lines"));
1345        let rule_config = config.get_rule_config("block-lines").unwrap();
1346        assert_eq!(rule_config.max_block_lines, Some(50));
1347    }
1348
1349    #[test]
1350    fn test_block_lines_default_no_max() {
1351        let toml_content = r#"
1352[rules.block-lines]
1353enabled = true
1354"#;
1355        let mut file = NamedTempFile::new().unwrap();
1356        write!(file, "{}", toml_content).unwrap();
1357
1358        let config = LintConfig::from_file(file.path()).unwrap();
1359        let rule_config = config.get_rule_config("block-lines").unwrap();
1360        assert_eq!(rule_config.max_block_lines, None);
1361    }
1362
1363    #[test]
1364    fn test_block_lines_validation_rejects_unknown_option() {
1365        let toml_content = r#"
1366[rules.block-lines]
1367enabled = true
1368unknown_option = 42
1369"#;
1370        let mut file = NamedTempFile::new().unwrap();
1371        write!(file, "{}", toml_content).unwrap();
1372
1373        let errors = LintConfig::validate_file(file.path()).unwrap();
1374        assert_eq!(errors.len(), 1);
1375        match &errors[0] {
1376            ValidationError::UnknownRuleOption { rule, option, .. } => {
1377                assert_eq!(rule, "block-lines");
1378                assert_eq!(option, "unknown_option");
1379            }
1380            other => panic!("expected UnknownRuleOption, got: {:?}", other),
1381        }
1382    }
1383
1384    #[test]
1385    fn test_include_path_map_empty_by_default() {
1386        let config = LintConfig::default();
1387        assert!(config.include_path_mappings().is_empty());
1388    }
1389
1390    #[test]
1391    fn test_include_path_map_single_entry() {
1392        let toml_content = r#"
1393[[include.path_map]]
1394from = "sites-enabled"
1395to   = "sites-available"
1396"#;
1397        let config = LintConfig::parse(toml_content).unwrap();
1398        let mappings = config.include_path_mappings();
1399        assert_eq!(mappings.len(), 1);
1400        assert_eq!(mappings[0].from, "sites-enabled");
1401        assert_eq!(mappings[0].to, "sites-available");
1402    }
1403
1404    #[test]
1405    fn test_include_path_map_multiple_entries_preserve_order() {
1406        let toml_content = r#"
1407[[include.path_map]]
1408from = "sites-enabled"
1409to   = "sites-available"
1410
1411[[include.path_map]]
1412from = "/etc/nginx"
1413to   = "/usr/local/nginx"
1414"#;
1415        let config = LintConfig::parse(toml_content).unwrap();
1416        let mappings = config.include_path_mappings();
1417        assert_eq!(mappings.len(), 2);
1418        assert_eq!(mappings[0].from, "sites-enabled");
1419        assert_eq!(mappings[0].to, "sites-available");
1420        assert_eq!(mappings[1].from, "/etc/nginx");
1421        assert_eq!(mappings[1].to, "/usr/local/nginx");
1422    }
1423
1424    #[test]
1425    fn test_include_validation_rejects_unknown_field() {
1426        let toml_content = r#"
1427[include]
1428unknown_key = "value"
1429"#;
1430        let mut file = NamedTempFile::new().unwrap();
1431        write!(file, "{}", toml_content).unwrap();
1432
1433        let errors = LintConfig::validate_file(file.path()).unwrap();
1434        assert_eq!(errors.len(), 1);
1435        match &errors[0] {
1436            ValidationError::UnknownField { path, .. } => {
1437                assert_eq!(path, "include.unknown_key");
1438            }
1439            other => panic!("expected UnknownField, got: {:?}", other),
1440        }
1441    }
1442
1443    #[test]
1444    fn test_include_prefix_none_by_default() {
1445        let config = LintConfig::default();
1446        assert!(config.include_prefix().is_none());
1447    }
1448
1449    #[test]
1450    fn test_include_prefix_parsed() {
1451        let toml_content = r#"
1452[include]
1453prefix = "/etc/nginx"
1454"#;
1455        let config = LintConfig::parse(toml_content).unwrap();
1456        assert_eq!(config.include_prefix(), Some("/etc/nginx"));
1457    }
1458
1459    #[test]
1460    fn test_include_prefix_with_path_map() {
1461        let toml_content = r#"
1462[include]
1463prefix = "."
1464
1465[[include.path_map]]
1466from = "sites-enabled"
1467to   = "sites-available"
1468"#;
1469        let config = LintConfig::parse(toml_content).unwrap();
1470        assert_eq!(config.include_prefix(), Some("."));
1471        assert_eq!(config.include_path_mappings().len(), 1);
1472    }
1473
1474    #[test]
1475    fn test_include_prefix_validation_accepted() {
1476        let toml_content = r#"
1477[include]
1478prefix = "/etc/nginx"
1479"#;
1480        let mut file = NamedTempFile::new().unwrap();
1481        write!(file, "{}", toml_content).unwrap();
1482
1483        let errors = LintConfig::validate_file(file.path()).unwrap();
1484        assert!(
1485            errors.is_empty(),
1486            "prefix should be a valid include field, got errors: {:?}",
1487            errors
1488        );
1489    }
1490
1491    #[test]
1492    fn test_json_schema_is_valid() {
1493        let schema = LintConfig::json_schema();
1494
1495        // Should have $schema key
1496        assert_eq!(
1497            schema.get("$schema").and_then(|v| v.as_str()),
1498            Some("https://json-schema.org/draft/2020-12/schema")
1499        );
1500
1501        // Should have properties for all top-level config sections
1502        let props = schema.get("properties").unwrap().as_object().unwrap();
1503        assert!(props.contains_key("rules"), "missing 'rules' property");
1504        assert!(props.contains_key("color"), "missing 'color' property");
1505        assert!(props.contains_key("parser"), "missing 'parser' property");
1506        assert!(props.contains_key("include"), "missing 'include' property");
1507    }
1508
1509    /// Regression test for https://github.com/walf443/nginx-lint/issues/172:
1510    /// previously, `config validate` rejected configs that referenced real builtin
1511    /// plugins whose names were missing from the validator's `known_rules`
1512    /// whitelist. These names must remain recognised.
1513    #[test]
1514    fn test_validate_accepts_previously_drifted_builtin_plugins() {
1515        // Names that were drifted out of `known_rules` before the fix for #172.
1516        let previously_missing = [
1517            "client-max-body-size-not-set",
1518            "listen-http2-deprecated",
1519            "map-missing-default",
1520            "proxy-missing-host-header",
1521            "ssl-on-deprecated",
1522            "unreachable-location",
1523        ];
1524
1525        for rule_name in previously_missing {
1526            let toml_content = format!("[rules.{rule_name}]\nenabled = false\n");
1527            let mut file = NamedTempFile::new().unwrap();
1528            write!(file, "{}", toml_content).unwrap();
1529
1530            let errors = LintConfig::validate_file(file.path()).unwrap();
1531            assert!(
1532                errors.is_empty(),
1533                "rule '{rule_name}' should be a known builtin plugin name, \
1534                 but `validate_file` reported errors: {errors:?}"
1535            );
1536        }
1537    }
1538
1539    /// Sanity check that `KNOWN_RULE_NAMES` actually drives the validator
1540    /// (rather than an inline list that can drift again).
1541    #[test]
1542    fn test_known_rules_constant_drives_validator() {
1543        for rule_name in LintConfig::KNOWN_RULE_NAMES {
1544            let toml_content = format!("[rules.{rule_name}]\nenabled = true\n");
1545            let mut file = NamedTempFile::new().unwrap();
1546            write!(file, "{}", toml_content).unwrap();
1547
1548            let errors = LintConfig::validate_file(file.path()).unwrap();
1549            assert!(
1550                errors.is_empty(),
1551                "rule '{rule_name}' is listed in KNOWN_RULE_NAMES but the validator \
1552                 rejected it: {errors:?}"
1553            );
1554        }
1555    }
1556
1557    /// `NATIVE_RULE_NAMES` must be a subset of `KNOWN_RULE_NAMES`: editing either
1558    /// list independently would mean the validator forgets about a native rule.
1559    #[test]
1560    fn test_native_rule_names_subset_of_known_rules() {
1561        let known: HashSet<&str> = LintConfig::KNOWN_RULE_NAMES.iter().copied().collect();
1562        let missing: Vec<&str> = LintConfig::NATIVE_RULE_NAMES
1563            .iter()
1564            .copied()
1565            .filter(|name| !known.contains(name))
1566            .collect();
1567        assert!(
1568            missing.is_empty(),
1569            "NATIVE_RULE_NAMES entries missing from KNOWN_RULE_NAMES: {missing:?}"
1570        );
1571    }
1572
1573    /// Negative case: a rule name that is in neither `NATIVE_RULE_NAMES` nor
1574    /// `BUILTIN_PLUGIN_NAMES` must still produce an `UnknownRule` error —
1575    /// otherwise the whitelist would silently degrade to "accept anything".
1576    #[test]
1577    fn test_validate_rejects_unknown_rule_name() {
1578        let toml_content = "[rules.no-such-rule-zzz]\nenabled = true\n";
1579        let mut file = NamedTempFile::new().unwrap();
1580        write!(file, "{}", toml_content).unwrap();
1581
1582        let errors = LintConfig::validate_file(file.path()).unwrap();
1583        assert_eq!(
1584            errors.len(),
1585            1,
1586            "expected exactly one error, got: {errors:?}"
1587        );
1588        match &errors[0] {
1589            ValidationError::UnknownRule { name, .. } => {
1590                assert_eq!(name, "no-such-rule-zzz");
1591            }
1592            other => panic!("expected UnknownRule, got: {other:?}"),
1593        }
1594    }
1595
1596    /// `KNOWN_RULE_NAMES` must not contain duplicate entries — a duplicated name
1597    /// would silently mask a typo when the list is edited.
1598    #[test]
1599    fn test_known_rules_has_no_duplicates() {
1600        let mut seen: HashSet<&str> = HashSet::new();
1601        for name in LintConfig::KNOWN_RULE_NAMES {
1602            assert!(
1603                seen.insert(name),
1604                "duplicate entry in KNOWN_RULE_NAMES: '{name}'"
1605            );
1606        }
1607    }
1608
1609    #[test]
1610    fn test_json_schema_rule_config_has_all_fields() {
1611        let schema = LintConfig::json_schema();
1612
1613        // RuleConfig definition should contain all fields from the struct
1614        // schemars v1 uses "$defs" instead of "definitions"
1615        let rule_config_def = schema
1616            .pointer("/$defs/RuleConfig")
1617            .expect("RuleConfig definition missing from schema");
1618
1619        let props = rule_config_def
1620            .get("properties")
1621            .unwrap()
1622            .as_object()
1623            .unwrap();
1624
1625        let expected_fields = [
1626            "enabled",
1627            "skip_version_check",
1628            "indent_size",
1629            "allowed_protocols",
1630            "weak_ciphers",
1631            "required_exclusions",
1632            "additional_contexts",
1633            "max_block_lines",
1634            "excluded_directives",
1635            "additional_directives",
1636        ];
1637
1638        for field in &expected_fields {
1639            assert!(
1640                props.contains_key(*field),
1641                "RuleConfig schema missing field '{field}'"
1642            );
1643        }
1644    }
1645
1646    #[test]
1647    fn test_target_nginx_version_parsed() {
1648        let toml_content = r#"
1649target_nginx_version = "1.31.0"
1650"#;
1651        let config = LintConfig::parse(toml_content).unwrap();
1652        assert_eq!(config.target_nginx_version(), Some("1.31.0"));
1653    }
1654
1655    #[test]
1656    fn test_target_nginx_version_default_none() {
1657        let config = LintConfig::default();
1658        assert!(config.target_nginx_version().is_none());
1659    }
1660
1661    #[test]
1662    fn test_skip_version_check_per_rule() {
1663        let toml_content = r#"
1664[rules.nginx-rift]
1665enabled = true
1666skip_version_check = true
1667"#;
1668        let config = LintConfig::parse(toml_content).unwrap();
1669        assert!(config.rule_skip_version_check("nginx-rift"));
1670        assert!(!config.rule_skip_version_check("server-tokens-enabled"));
1671    }
1672
1673    #[test]
1674    fn test_rule_explicitly_configured() {
1675        let toml_content = r#"
1676[rules.indent]
1677enabled = true
1678"#;
1679        let config = LintConfig::parse(toml_content).unwrap();
1680        assert!(config.rule_explicitly_configured("indent"));
1681        assert!(!config.rule_explicitly_configured("server-tokens-enabled"));
1682    }
1683
1684    #[test]
1685    fn test_validator_accepts_target_nginx_version() {
1686        let toml_content = r#"
1687target_nginx_version = "1.31.0"
1688"#;
1689        let mut file = NamedTempFile::new().unwrap();
1690        write!(file, "{}", toml_content).unwrap();
1691
1692        let errors = LintConfig::validate_file(file.path()).unwrap();
1693        assert!(
1694            errors.is_empty(),
1695            "target_nginx_version should be a valid top-level field, got: {errors:?}"
1696        );
1697    }
1698
1699    #[test]
1700    fn test_validator_accepts_skip_version_check() {
1701        for rule_name in LintConfig::KNOWN_RULE_NAMES {
1702            let toml_content =
1703                format!("[rules.{rule_name}]\nenabled = true\nskip_version_check = true\n");
1704            let mut file = NamedTempFile::new().unwrap();
1705            write!(file, "{}", toml_content).unwrap();
1706
1707            let errors = LintConfig::validate_file(file.path()).unwrap();
1708            assert!(
1709                errors.is_empty(),
1710                "skip_version_check should be valid for rule '{rule_name}', got: {errors:?}"
1711            );
1712        }
1713    }
1714}