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# Color output settings
110[color]
111# Color mode: "auto", "always", or "never"
112ui = "auto"
113# Severity colors (available: black, red, green, yellow, blue, magenta, cyan, white,
114#                  bright_black, bright_red, bright_green, bright_yellow, bright_blue,
115#                  bright_magenta, bright_cyan, bright_white)
116error = "red"
117warning = "yellow"
118
119# =============================================================================
120# Include Resolution Settings
121# =============================================================================
122[include]
123
124# Base directory for resolving relative include paths (similar to nginx -p prefix).
125# When set, all relative include paths are resolved from this directory
126# instead of the directory containing the config file with the include directive.
127# prefix = "/etc/nginx"
128
129# Path mappings applied to include patterns before resolving them.
130# Mappings are applied in declaration order, each receiving the output of the
131# previous one (chained).  Useful when the config references a directory that
132# differs from where the actual files live (e.g. sites-enabled → sites-available).
133#
134# Example (for Debian nginx package):
135
136# [[include.path_map]]
137# from = "/etc/nginx/"
138# to   = ""
139#
140# [[include.path_map]]
141# from = "sites-enabled"
142# to   = "sites-available"
143#
144# [[include.path_map]]
145# from = "modules-enabled"
146# to   = "modules-available"
147
148# =============================================================================
149# Style Rules
150# =============================================================================
151
152[rules.indent]
153enabled = true
154# Indentation size: number or "auto" for auto-detection (default: "auto")
155# indent_size = 4
156indent_size = "auto"
157
158[rules.trailing-whitespace]
159enabled = true
160
161[rules.space-before-semicolon]
162enabled = true
163
164[rules.block-lines]
165enabled = true
166# Maximum number of lines allowed in a block (default: 100)
167# max_block_lines = 100
168
169# =============================================================================
170# Syntax Rules
171# =============================================================================
172
173[rules.duplicate-directive]
174enabled = true
175
176[rules.unmatched-braces]
177enabled = true
178
179[rules.unclosed-quote]
180enabled = true
181
182[rules.missing-semicolon]
183enabled = true
184
185[rules.invalid-directive-context]
186enabled = true
187# Additional valid parent contexts for directives (for extension modules like nginx-rtmp-module)
188# Example for nginx-rtmp-module:
189# additional_contexts = { server = ["rtmp"], upstream = ["rtmp"] }
190
191[rules.include-path-exists]
192enabled = true
193
194# =============================================================================
195# Security Rules
196# =============================================================================
197
198[rules.deprecated-ssl-protocol]
199enabled = true
200# Allowed protocols for auto-fix (default: ["TLSv1.2", "TLSv1.3"])
201allowed_protocols = ["TLSv1.2", "TLSv1.3"]
202
203[rules.server-tokens-enabled]
204enabled = true
205
206[rules.autoindex-enabled]
207enabled = true
208
209[rules.weak-ssl-ciphers]
210enabled = true
211# Weak cipher patterns to detect
212weak_ciphers = [
213    "NULL",
214    "EXPORT",
215    "DES",
216    "RC4",
217    "MD5",
218    "aNULL",
219    "eNULL",
220    "ADH",
221    "AECDH",
222    "PSK",
223    "SRP",
224    "CAMELLIA",
225]
226# Required exclusion patterns
227required_exclusions = ["!aNULL", "!eNULL", "!EXPORT", "!DES", "!RC4", "!MD5"]
228
229# =============================================================================
230# Best Practices
231# =============================================================================
232
233[rules.gzip-not-enabled]
234# Disabled by default: gzip is not always appropriate (CDN, CPU constraints, BREACH attack)
235enabled = false
236
237[rules.missing-error-log]
238# Disabled by default: error_log is typically set at top level in main config
239enabled = false
240
241[rules.proxy-pass-domain]
242enabled = true
243
244[rules.upstream-server-no-resolve]
245enabled = true
246
247[rules.directive-inheritance]
248enabled = true
249# Exclude specific directives from checking
250# excluded_directives = ["grpc_set_header", "uwsgi_param"]
251# Add custom directives to check (name is required, case_insensitive and multi_key default to false)
252# additional_directives = [
253#   { name = "proxy_set_cookie", case_insensitive = true },
254# ]
255
256[rules.root-in-location]
257enabled = true
258
259[rules.alias-location-slash-mismatch]
260enabled = true
261
262[rules.proxy-pass-with-uri]
263enabled = true
264
265[rules.proxy-keepalive]
266enabled = true
267
268[rules.try-files-with-proxy]
269enabled = true
270
271[rules.if-is-evil-in-location]
272enabled = true
273
274# =============================================================================
275# Parser Settings
276# =============================================================================
277
278[parser]
279# Additional block directives for extension modules
280# These are added to the built-in list (http, server, location, etc.)
281# Example for nginx-rtmp-module:
282# block_directives = ["rtmp", "application"]
283"#;
284
285/// Configuration for nginx-lint loaded from `.nginx-lint.toml`.
286///
287/// Use [`from_file`](Self::from_file) to load from a specific path, or
288/// [`find_and_load`](Self::find_and_load) to search the directory tree upward.
289/// A default `LintConfig` enables all rules except those listed in
290/// [`DISABLED_BY_DEFAULT`](Self::DISABLED_BY_DEFAULT).
291#[derive(Debug, Default, Deserialize, JsonSchema)]
292pub struct LintConfig {
293    /// Per-rule configuration keyed by rule name (e.g. `"indent"`, `"server-tokens-enabled"`).
294    #[serde(default)]
295    pub rules: HashMap<String, RuleConfig>,
296    /// Color output settings.
297    #[serde(default)]
298    pub color: ColorConfig,
299    /// Parser-level settings (e.g. additional block directives for extension modules).
300    #[serde(default)]
301    pub parser: ParserConfig,
302    /// Include resolution settings (e.g. path mappings for include directives).
303    #[serde(default)]
304    pub include: IncludeConfig,
305}
306
307/// Parser configuration
308#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
309pub struct ParserConfig {
310    /// Additional block directives (extension modules, etc.)
311    /// These are added to the built-in list of block directives
312    #[serde(default)]
313    pub block_directives: Vec<String>,
314}
315
316/// A single path mapping rule for include directive resolution.
317///
318/// When an `include` directive's path contains path segment(s) that exactly
319/// match `from`, they are replaced with `to` before the path is resolved on
320/// disk.  Matching is performed at the path-component level (split by `/`),
321/// so `from = "sites-enabled"` matches `.../sites-enabled/...` but does NOT
322/// match `.../asites-enabled/...`.  Multi-segment values like
323/// `from = "nginx/sites-enabled"` match consecutive components.
324///
325/// This is useful when the production config references a directory
326/// (e.g. `sites-enabled`) that is only populated at runtime via symlinks,
327/// and you want nginx-lint to evaluate the actual source files
328/// (e.g. `sites-available`) instead.
329///
330/// Mappings are applied in declaration order and chained, so the output of one
331/// mapping is fed into the next.
332#[derive(Debug, Clone, Deserialize, JsonSchema)]
333pub struct PathMapping {
334    /// Path segment(s) to match in the include path (compared component-wise)
335    pub from: String,
336    /// Replacement path segment(s)
337    pub to: String,
338}
339
340/// Include resolution configuration.
341#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
342pub struct IncludeConfig {
343    /// Path mappings applied to include patterns before resolving them.
344    /// Applied in declaration order; each mapping receives the output of the previous one.
345    #[serde(default)]
346    pub path_map: Vec<PathMapping>,
347    /// Base directory for resolving relative include paths (similar to nginx `-p` prefix).
348    /// When set, all relative include paths are resolved from this directory
349    /// instead of the directory containing the config file with the include directive.
350    pub prefix: Option<String>,
351}
352
353/// Color output configuration
354#[derive(Debug, Clone, Deserialize, JsonSchema)]
355pub struct ColorConfig {
356    /// Color mode: "auto" (default), "always", or "never"
357    #[serde(default)]
358    pub ui: ColorMode,
359    /// Color for error messages (default: "red")
360    #[serde(default = "default_error_color")]
361    pub error: Color,
362    /// Color for warning messages (default: "yellow")
363    #[serde(default = "default_warning_color")]
364    pub warning: Color,
365}
366
367impl Default for ColorConfig {
368    fn default() -> Self {
369        Self {
370            ui: ColorMode::Auto,
371            error: Color::Red,
372            warning: Color::Yellow,
373        }
374    }
375}
376
377fn default_error_color() -> Color {
378    Color::Red
379}
380
381fn default_warning_color() -> Color {
382    Color::Yellow
383}
384
385/// Available colors for output
386#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
387pub enum Color {
388    Black,
389    Red,
390    Green,
391    Yellow,
392    Blue,
393    Magenta,
394    Cyan,
395    #[default]
396    White,
397    BrightBlack,
398    BrightRed,
399    BrightGreen,
400    BrightYellow,
401    BrightBlue,
402    BrightMagenta,
403    BrightCyan,
404    BrightWhite,
405}
406
407impl JsonSchema for Color {
408    fn schema_name() -> std::borrow::Cow<'static, str> {
409        "Color".into()
410    }
411
412    fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
413        serde_json::from_value(serde_json::json!({
414            "type": "string",
415            "enum": [
416                "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white",
417                "bright_black", "bright_red", "bright_green", "bright_yellow",
418                "bright_blue", "bright_magenta", "bright_cyan", "bright_white"
419            ]
420        }))
421        .unwrap()
422    }
423}
424
425impl<'de> Deserialize<'de> for Color {
426    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
427    where
428        D: serde::Deserializer<'de>,
429    {
430        use serde::de::Error;
431
432        let s = String::deserialize(deserializer)?;
433        match s.to_lowercase().as_str() {
434            "black" => Ok(Color::Black),
435            "red" => Ok(Color::Red),
436            "green" => Ok(Color::Green),
437            "yellow" => Ok(Color::Yellow),
438            "blue" => Ok(Color::Blue),
439            "magenta" => Ok(Color::Magenta),
440            "cyan" => Ok(Color::Cyan),
441            "white" => Ok(Color::White),
442            "bright_black" | "brightblack" => Ok(Color::BrightBlack),
443            "bright_red" | "brightred" => Ok(Color::BrightRed),
444            "bright_green" | "brightgreen" => Ok(Color::BrightGreen),
445            "bright_yellow" | "brightyellow" => Ok(Color::BrightYellow),
446            "bright_blue" | "brightblue" => Ok(Color::BrightBlue),
447            "bright_magenta" | "brightmagenta" => Ok(Color::BrightMagenta),
448            "bright_cyan" | "brightcyan" => Ok(Color::BrightCyan),
449            "bright_white" | "brightwhite" => Ok(Color::BrightWhite),
450            _ => Err(D::Error::custom(format!(
451                "invalid color '{}', expected one of: black, red, green, yellow, blue, magenta, cyan, white, \
452                 bright_black, bright_red, bright_green, bright_yellow, bright_blue, bright_magenta, bright_cyan, bright_white",
453                s
454            ))),
455        }
456    }
457}
458
459/// Color mode for output
460#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
461pub enum ColorMode {
462    /// Automatically detect (default) - respects NO_COLOR env and terminal detection
463    #[default]
464    Auto,
465    /// Always use colors
466    Always,
467    /// Never use colors
468    Never,
469}
470
471impl JsonSchema for ColorMode {
472    fn schema_name() -> std::borrow::Cow<'static, str> {
473        "ColorMode".into()
474    }
475
476    fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
477        serde_json::from_value(serde_json::json!({
478            "type": "string",
479            "description": "Color mode: \"auto\" respects NO_COLOR env and terminal detection, \"always\" forces colors, \"never\" disables colors",
480            "default": "auto",
481            "enum": ["auto", "always", "never"]
482        }))
483        .unwrap()
484    }
485}
486
487impl<'de> Deserialize<'de> for ColorMode {
488    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
489    where
490        D: serde::Deserializer<'de>,
491    {
492        use serde::de::Error;
493
494        let s = String::deserialize(deserializer)?;
495        match s.as_str() {
496            "auto" => Ok(ColorMode::Auto),
497            "always" => Ok(ColorMode::Always),
498            "never" => Ok(ColorMode::Never),
499            _ => Err(D::Error::custom(format!(
500                "invalid color mode '{}', expected 'auto', 'always', or 'never'",
501                s
502            ))),
503        }
504    }
505}
506
507/// An additional directive to check for inheritance issues.
508///
509/// Used in `[rules.directive-inheritance]` configuration.
510#[derive(Debug, Clone, Deserialize, JsonSchema)]
511pub struct AdditionalDirective {
512    /// The directive name (e.g., "proxy_set_cookie")
513    pub name: String,
514    /// Whether the first argument key comparison is case-insensitive (default: false)
515    #[serde(default)]
516    pub case_insensitive: bool,
517    /// If true, all numeric arguments are separate keys like error_page (default: false)
518    #[serde(default)]
519    pub multi_key: bool,
520}
521
522/// Configuration for a specific lint rule.
523///
524/// Every `[rules.<name>]` section in `.nginx-lint.toml` is deserialized into
525/// a `RuleConfig`. The only universal field is [`enabled`](Self::enabled);
526/// the remaining fields are rule-specific options.
527#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
528pub struct RuleConfig {
529    /// Whether this rule is active (`true` by default for most rules).
530    #[serde(default = "default_true")]
531    pub enabled: bool,
532    /// For indent rule: number or "auto" for auto-detection
533    pub indent_size: Option<IndentSize>,
534    /// For deprecated-ssl-protocol rule: allowed protocols (default: ["TLSv1.2", "TLSv1.3"])
535    pub allowed_protocols: Option<Vec<String>>,
536    /// For weak-ssl-ciphers rule: weak cipher patterns to detect
537    pub weak_ciphers: Option<Vec<String>>,
538    /// For weak-ssl-ciphers rule: required exclusion patterns
539    pub required_exclusions: Option<Vec<String>>,
540    /// For invalid-directive-context rule: additional valid parent contexts
541    /// Format: { "server" = ["rtmp"], "upstream" = ["rtmp"] }
542    pub additional_contexts: Option<HashMap<String, Vec<String>>>,
543    /// For block-lines rule: maximum number of lines allowed in a block
544    pub max_block_lines: Option<usize>,
545    /// For directive-inheritance rule: directives to exclude from checking
546    pub excluded_directives: Option<Vec<String>>,
547    /// For directive-inheritance rule: additional directives to check
548    pub additional_directives: Option<Vec<AdditionalDirective>>,
549}
550
551fn default_true() -> bool {
552    true
553}
554
555impl LintConfig {
556    /// Load configuration from a file
557    pub fn from_file(path: &Path) -> Result<Self, ConfigError> {
558        let content = fs::read_to_string(path).map_err(|e| ConfigError::IoError {
559            path: path.to_path_buf(),
560            source: e,
561        })?;
562
563        toml::from_str(&content).map_err(|e| ConfigError::ParseError {
564            path: path.to_path_buf(),
565            source: e,
566        })
567    }
568
569    /// Parse configuration from a TOML string
570    pub fn parse(content: &str) -> Result<Self, String> {
571        toml::from_str(content).map_err(|e| e.to_string())
572    }
573
574    /// Find and load .nginx-lint.toml from the given directory or its parents.
575    ///
576    /// Returns the loaded config along with the path of the config file found.
577    pub fn find_and_load(dir: &Path) -> Option<(Self, std::path::PathBuf)> {
578        let mut current = dir.to_path_buf();
579
580        loop {
581            let config_path = current.join(".nginx-lint.toml");
582            if config_path.exists() {
583                return Self::from_file(&config_path)
584                    .ok()
585                    .map(|cfg| (cfg, config_path));
586            }
587
588            if !current.pop() {
589                break;
590            }
591        }
592
593        None
594    }
595
596    /// Rules that are disabled by default
597    pub const DISABLED_BY_DEFAULT: &'static [&'static str] = &[
598        "gzip-not-enabled", // gzip is not always appropriate (CDN, CPU constraints, security)
599        "missing-error-log", // error_log is typically set at top level in main config
600    ];
601
602    /// Check if a rule is enabled
603    pub fn is_rule_enabled(&self, name: &str) -> bool {
604        self.rules
605            .get(name)
606            .map(|r| r.enabled)
607            .unwrap_or_else(|| !Self::DISABLED_BY_DEFAULT.contains(&name))
608    }
609
610    /// Get the configuration for a specific rule
611    pub fn get_rule_config(&self, name: &str) -> Option<&RuleConfig> {
612        self.rules.get(name)
613    }
614
615    /// Get the color mode setting
616    pub fn color_mode(&self) -> ColorMode {
617        self.color.ui
618    }
619
620    /// Get additional block directives from config
621    pub fn additional_block_directives(&self) -> &[String] {
622        &self.parser.block_directives
623    }
624
625    /// Get include path mappings (applied in order to include patterns before resolving)
626    pub fn include_path_mappings(&self) -> &[PathMapping] {
627        &self.include.path_map
628    }
629
630    /// Generate the JSON Schema for the configuration file.
631    ///
632    /// The schema is derived from the Rust type definitions, so it automatically
633    /// stays in sync with the actual configuration structure.
634    pub fn json_schema() -> serde_json::Value {
635        let generator = schemars::SchemaGenerator::default();
636        let schema = generator.into_root_schema_for::<LintConfig>();
637        serde_json::to_value(schema).unwrap()
638    }
639
640    /// Get include prefix (base directory for resolving relative include paths)
641    pub fn include_prefix(&self) -> Option<&str> {
642        self.include.prefix.as_deref()
643    }
644
645    /// Get additional contexts for invalid-directive-context rule
646    pub fn additional_contexts(&self) -> Option<&HashMap<String, Vec<String>>> {
647        self.rules
648            .get("invalid-directive-context")
649            .and_then(|r| r.additional_contexts.as_ref())
650    }
651
652    /// Get excluded directives for directive-inheritance rule
653    pub fn directive_inheritance_excluded(&self) -> Option<&[String]> {
654        self.rules
655            .get("directive-inheritance")
656            .and_then(|r| r.excluded_directives.as_deref())
657    }
658
659    /// Get additional directives for directive-inheritance rule
660    pub fn directive_inheritance_additional(&self) -> Option<&[AdditionalDirective]> {
661        self.rules
662            .get("directive-inheritance")
663            .and_then(|r| r.additional_directives.as_deref())
664    }
665
666    /// Validate a configuration file and return any errors
667    pub fn validate_file(path: &Path) -> Result<Vec<ValidationError>, ConfigError> {
668        let content = fs::read_to_string(path).map_err(|e| ConfigError::IoError {
669            path: path.to_path_buf(),
670            source: e,
671        })?;
672
673        Self::validate_content(&content, path)
674    }
675
676    /// Validate configuration content and return any errors
677    fn validate_content(content: &str, path: &Path) -> Result<Vec<ValidationError>, ConfigError> {
678        let value: toml::Value = toml::from_str(content).map_err(|e| ConfigError::ParseError {
679            path: path.to_path_buf(),
680            source: e,
681        })?;
682
683        let mut errors = Vec::new();
684
685        if let toml::Value::Table(root) = value {
686            // Known top-level keys
687            let known_top_level: HashSet<&str> = ["rules", "color", "parser", "include"]
688                .into_iter()
689                .collect();
690
691            for key in root.keys() {
692                if !known_top_level.contains(key.as_str()) {
693                    let line = find_key_line(content, None, key);
694                    errors.push(ValidationError::UnknownField {
695                        path: key.clone(),
696                        line,
697                        suggestion: suggest_field(key, &known_top_level),
698                    });
699                }
700            }
701
702            // Validate [color] section
703            if let Some(toml::Value::Table(color)) = root.get("color") {
704                let known_color_keys: HashSet<&str> =
705                    ["ui", "error", "warning"].into_iter().collect();
706
707                for key in color.keys() {
708                    if !known_color_keys.contains(key.as_str()) {
709                        let line = find_key_line(content, Some("color"), key);
710                        errors.push(ValidationError::UnknownField {
711                            path: format!("color.{}", key),
712                            line,
713                            suggestion: suggest_field(key, &known_color_keys),
714                        });
715                    }
716                }
717            }
718
719            // Validate [parser] section
720            if let Some(toml::Value::Table(parser)) = root.get("parser") {
721                let known_parser_keys: HashSet<&str> = ["block_directives"].into_iter().collect();
722
723                for key in parser.keys() {
724                    if !known_parser_keys.contains(key.as_str()) {
725                        let line = find_key_line(content, Some("parser"), key);
726                        errors.push(ValidationError::UnknownField {
727                            path: format!("parser.{}", key),
728                            line,
729                            suggestion: suggest_field(key, &known_parser_keys),
730                        });
731                    }
732                }
733            }
734
735            // Validate [include] section
736            if let Some(toml::Value::Table(include)) = root.get("include") {
737                let known_include_keys: HashSet<&str> =
738                    ["path_map", "prefix"].into_iter().collect();
739
740                for key in include.keys() {
741                    if !known_include_keys.contains(key.as_str()) {
742                        let line = find_key_line(content, Some("include"), key);
743                        errors.push(ValidationError::UnknownField {
744                            path: format!("include.{}", key),
745                            line,
746                            suggestion: suggest_field(key, &known_include_keys),
747                        });
748                    }
749                }
750            }
751
752            // Validate [rules.*] sections
753            if let Some(toml::Value::Table(rules)) = root.get("rules") {
754                let known_rules: HashSet<&str> = [
755                    "duplicate-directive",
756                    "unmatched-braces",
757                    "unclosed-quote",
758                    "missing-semicolon",
759                    "invalid-directive-context",
760                    "deprecated-ssl-protocol",
761                    "server-tokens-enabled",
762                    "autoindex-enabled",
763                    "weak-ssl-ciphers",
764                    "indent",
765                    "trailing-whitespace",
766                    "space-before-semicolon",
767                    "block-lines",
768                    "gzip-not-enabled",
769                    "missing-error-log",
770                    "proxy-pass-domain",
771                    "upstream-server-no-resolve",
772                    "root-in-location",
773                    "alias-location-slash-mismatch",
774                    "proxy-pass-with-uri",
775                    "proxy-keepalive",
776                    "try-files-with-proxy",
777                    "if-is-evil-in-location",
778                    "directive-inheritance",
779                    "include-path-exists",
780                ]
781                .into_iter()
782                .collect();
783
784                for (rule_name, rule_value) in rules {
785                    if !known_rules.contains(rule_name.as_str()) {
786                        let line = find_key_line(content, Some("rules"), rule_name);
787                        errors.push(ValidationError::UnknownRule {
788                            name: rule_name.clone(),
789                            line,
790                            suggestion: suggest_field(rule_name, &known_rules),
791                        });
792                        continue;
793                    }
794
795                    // Validate rule options
796                    if let toml::Value::Table(rule_config) = rule_value {
797                        let known_rule_options = get_known_rule_options(rule_name);
798                        let section = format!("rules.{}", rule_name);
799
800                        for key in rule_config.keys() {
801                            if !known_rule_options.contains(key.as_str()) {
802                                let line = find_key_line(content, Some(&section), key);
803                                errors.push(ValidationError::UnknownRuleOption {
804                                    rule: rule_name.clone(),
805                                    option: key.clone(),
806                                    line,
807                                    suggestion: suggest_field(key, &known_rule_options),
808                                });
809                            }
810                        }
811                    }
812                }
813            }
814        }
815
816        Ok(errors)
817    }
818}
819
820/// Find the line number where a key is defined in the TOML content
821fn find_key_line(content: &str, section: Option<&str>, key: &str) -> Option<usize> {
822    let lines: Vec<&str> = content.lines().collect();
823
824    // For top-level sections (section is None), look for [key]
825    if section.is_none() {
826        let section_header = format!("[{}]", key);
827        for (i, line) in lines.iter().enumerate() {
828            if line.trim() == section_header {
829                return Some(i + 1);
830            }
831        }
832        return None;
833    }
834
835    let target_section = section.unwrap();
836    let mut in_section = false;
837
838    for (i, line) in lines.iter().enumerate() {
839        let trimmed = line.trim();
840
841        // Check for section header [section] or [section.subsection]
842        if trimmed.starts_with('[') && trimmed.ends_with(']') {
843            let section_name = &trimmed[1..trimmed.len() - 1];
844
845            // Check if this is a [rules.rule-name] style section
846            let full_section = format!("{}.{}", target_section, key);
847            if section_name == full_section {
848                return Some(i + 1);
849            }
850
851            in_section = section_name == target_section
852                || section_name.starts_with(&format!("{}.", target_section));
853            continue;
854        }
855
856        // Check for key = value within the section
857        if in_section && let Some((k, _)) = trimmed.split_once('=') {
858            let k = k.trim();
859            if k == key {
860                return Some(i + 1);
861            }
862        }
863    }
864
865    None
866}
867
868/// Get known options for a specific rule
869fn get_known_rule_options(rule_name: &str) -> HashSet<&'static str> {
870    let mut options: HashSet<&str> = ["enabled"].into_iter().collect();
871
872    match rule_name {
873        "indent" => {
874            options.insert("indent_size");
875        }
876        "deprecated-ssl-protocol" => {
877            options.insert("allowed_protocols");
878        }
879        "weak-ssl-ciphers" => {
880            options.insert("weak_ciphers");
881            options.insert("required_exclusions");
882        }
883        "block-lines" => {
884            options.insert("max_block_lines");
885        }
886        "directive-inheritance" => {
887            options.insert("excluded_directives");
888            options.insert("additional_directives");
889        }
890        _ => {}
891    }
892
893    options
894}
895
896/// Suggest a similar field name if one exists
897fn suggest_field(input: &str, known: &HashSet<&str>) -> Option<String> {
898    let input_lower = input.to_lowercase();
899
900    // Find the closest match using simple edit distance
901    known
902        .iter()
903        .filter(|&&k| {
904            let k_lower = k.to_lowercase();
905            // Simple heuristic: check if strings are similar
906            k_lower.contains(&input_lower)
907                || input_lower.contains(&k_lower)
908                || levenshtein_distance(&input_lower, &k_lower) <= 2
909        })
910        .min_by_key(|&&k| levenshtein_distance(&input.to_lowercase(), &k.to_lowercase()))
911        .map(|&s| s.to_string())
912}
913
914/// Simple Levenshtein distance implementation
915fn levenshtein_distance(a: &str, b: &str) -> usize {
916    let a_chars: Vec<char> = a.chars().collect();
917    let b_chars: Vec<char> = b.chars().collect();
918    let a_len = a_chars.len();
919    let b_len = b_chars.len();
920
921    if a_len == 0 {
922        return b_len;
923    }
924    if b_len == 0 {
925        return a_len;
926    }
927
928    let mut matrix = vec![vec![0; b_len + 1]; a_len + 1];
929
930    for (i, row) in matrix.iter_mut().enumerate().take(a_len + 1) {
931        row[0] = i;
932    }
933    for (j, cell) in matrix[0].iter_mut().enumerate().take(b_len + 1) {
934        *cell = j;
935    }
936
937    for i in 1..=a_len {
938        for j in 1..=b_len {
939            let cost = usize::from(a_chars[i - 1] != b_chars[j - 1]);
940            matrix[i][j] = (matrix[i - 1][j] + 1)
941                .min(matrix[i][j - 1] + 1)
942                .min(matrix[i - 1][j - 1] + cost);
943        }
944    }
945
946    matrix[a_len][b_len]
947}
948
949/// Validation error for configuration files.
950///
951/// Returned by [`LintConfig::validate_file`] when the TOML file contains
952/// unrecognised keys or rule names. Each variant includes an optional
953/// `suggestion` produced by fuzzy matching.
954#[derive(Debug, Clone)]
955pub enum ValidationError {
956    /// An unrecognised top-level or section-level field (e.g. `[colour]` instead of `[color]`).
957    UnknownField {
958        /// Dotted path to the field (e.g. `"color.ui_mode"`).
959        path: String,
960        /// 1-indexed line number where the field appears, if found.
961        line: Option<usize>,
962        /// A similar known field name, if one is close enough.
963        suggestion: Option<String>,
964    },
965    /// A `[rules.<name>]` section where `<name>` is not a known rule.
966    UnknownRule {
967        /// The unrecognised rule name.
968        name: String,
969        /// 1-indexed line number where the rule section appears, if found.
970        line: Option<usize>,
971        /// A similar known rule name, if one is close enough.
972        suggestion: Option<String>,
973    },
974    /// An unrecognised option inside a rule section (e.g. `indent_sizee` in `[rules.indent]`).
975    UnknownRuleOption {
976        /// The rule name containing the unknown option.
977        rule: String,
978        /// The unrecognised option key.
979        option: String,
980        /// 1-indexed line number where the option appears, if found.
981        line: Option<usize>,
982        /// A similar known option name, if one is close enough.
983        suggestion: Option<String>,
984    },
985}
986
987impl std::fmt::Display for ValidationError {
988    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
989        match self {
990            ValidationError::UnknownField {
991                path,
992                line,
993                suggestion,
994            } => {
995                if let Some(l) = line {
996                    write!(f, "line {}: ", l)?;
997                }
998                write!(f, "unknown field '{}'", path)?;
999                if let Some(s) = suggestion {
1000                    write!(f, ", did you mean '{}'?", s)?;
1001                }
1002                Ok(())
1003            }
1004            ValidationError::UnknownRule {
1005                name,
1006                line,
1007                suggestion,
1008            } => {
1009                if let Some(l) = line {
1010                    write!(f, "line {}: ", l)?;
1011                }
1012                write!(f, "unknown rule '{}'", name)?;
1013                if let Some(s) = suggestion {
1014                    write!(f, ", did you mean '{}'?", s)?;
1015                }
1016                Ok(())
1017            }
1018            ValidationError::UnknownRuleOption {
1019                rule,
1020                option,
1021                line,
1022                suggestion,
1023            } => {
1024                if let Some(l) = line {
1025                    write!(f, "line {}: ", l)?;
1026                }
1027                write!(f, "unknown option '{}' for rule '{}'", option, rule)?;
1028                if let Some(s) = suggestion {
1029                    write!(f, ", did you mean '{}'?", s)?;
1030                }
1031                Ok(())
1032            }
1033        }
1034    }
1035}
1036
1037/// Error returned when loading or parsing a configuration file fails.
1038#[derive(Debug)]
1039pub enum ConfigError {
1040    /// The file could not be read (missing, permission denied, etc.).
1041    IoError {
1042        /// Path that was attempted.
1043        path: std::path::PathBuf,
1044        /// Underlying I/O error.
1045        source: std::io::Error,
1046    },
1047    /// The file was read but contains invalid TOML.
1048    ParseError {
1049        /// Path that was parsed.
1050        path: std::path::PathBuf,
1051        /// Underlying TOML parse error.
1052        source: toml::de::Error,
1053    },
1054}
1055
1056impl std::fmt::Display for ConfigError {
1057    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1058        match self {
1059            ConfigError::IoError { path, source } => {
1060                write!(
1061                    f,
1062                    "Failed to read config file '{}': {}",
1063                    path.display(),
1064                    source
1065                )
1066            }
1067            ConfigError::ParseError { path, source } => {
1068                write!(
1069                    f,
1070                    "Failed to parse config file '{}': {}",
1071                    path.display(),
1072                    source
1073                )
1074            }
1075        }
1076    }
1077}
1078
1079impl std::error::Error for ConfigError {
1080    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1081        match self {
1082            ConfigError::IoError { source, .. } => Some(source),
1083            ConfigError::ParseError { source, .. } => Some(source),
1084        }
1085    }
1086}
1087
1088#[cfg(test)]
1089mod tests {
1090    use super::*;
1091    use std::io::Write;
1092    use tempfile::NamedTempFile;
1093
1094    #[test]
1095    fn test_default_config() {
1096        let config = LintConfig::default();
1097        assert!(config.is_rule_enabled("any-rule"));
1098    }
1099
1100    #[test]
1101    fn test_disabled_by_default_rules() {
1102        let config = LintConfig::default();
1103        // These rules should be disabled by default
1104        assert!(!config.is_rule_enabled("gzip-not-enabled"));
1105        assert!(!config.is_rule_enabled("missing-error-log"));
1106        // Other rules should still be enabled by default
1107        assert!(config.is_rule_enabled("server-tokens-enabled"));
1108    }
1109
1110    #[test]
1111    fn test_parse_config() {
1112        let toml_content = r#"
1113[rules.indent]
1114enabled = true
1115indent_size = 2
1116
1117[rules.server-tokens-enabled]
1118enabled = false
1119"#;
1120        let mut file = NamedTempFile::new().unwrap();
1121        write!(file, "{}", toml_content).unwrap();
1122
1123        let config = LintConfig::from_file(file.path()).unwrap();
1124
1125        assert!(config.is_rule_enabled("indent"));
1126        assert!(!config.is_rule_enabled("server-tokens-enabled"));
1127        assert!(config.is_rule_enabled("unknown-rule"));
1128
1129        let indent_config = config.get_rule_config("indent").unwrap();
1130        assert_eq!(indent_config.indent_size, Some(IndentSize::Fixed(2)));
1131    }
1132
1133    #[test]
1134    fn test_empty_config() {
1135        let toml_content = "";
1136        let mut file = NamedTempFile::new().unwrap();
1137        write!(file, "{}", toml_content).unwrap();
1138
1139        let config = LintConfig::from_file(file.path()).unwrap();
1140        assert!(config.is_rule_enabled("any-rule"));
1141    }
1142
1143    #[test]
1144    fn test_indent_size_auto() {
1145        let toml_content = r#"
1146[rules.indent]
1147enabled = true
1148indent_size = "auto"
1149"#;
1150        let mut file = NamedTempFile::new().unwrap();
1151        write!(file, "{}", toml_content).unwrap();
1152
1153        let config = LintConfig::from_file(file.path()).unwrap();
1154        let indent_config = config.get_rule_config("indent").unwrap();
1155        assert_eq!(indent_config.indent_size, Some(IndentSize::Auto));
1156    }
1157
1158    #[test]
1159    fn test_color_config_default() {
1160        let config = LintConfig::default();
1161        assert_eq!(config.color_mode(), ColorMode::Auto);
1162    }
1163
1164    #[test]
1165    fn test_color_config_auto() {
1166        let toml_content = r#"
1167[color]
1168ui = "auto"
1169"#;
1170        let mut file = NamedTempFile::new().unwrap();
1171        write!(file, "{}", toml_content).unwrap();
1172
1173        let config = LintConfig::from_file(file.path()).unwrap();
1174        assert_eq!(config.color_mode(), ColorMode::Auto);
1175    }
1176
1177    #[test]
1178    fn test_color_config_never() {
1179        let toml_content = r#"
1180[color]
1181ui = "never"
1182"#;
1183        let mut file = NamedTempFile::new().unwrap();
1184        write!(file, "{}", toml_content).unwrap();
1185
1186        let config = LintConfig::from_file(file.path()).unwrap();
1187        assert_eq!(config.color_mode(), ColorMode::Never);
1188    }
1189
1190    #[test]
1191    fn test_color_config_always() {
1192        let toml_content = r#"
1193[color]
1194ui = "always"
1195"#;
1196        let mut file = NamedTempFile::new().unwrap();
1197        write!(file, "{}", toml_content).unwrap();
1198
1199        let config = LintConfig::from_file(file.path()).unwrap();
1200        assert_eq!(config.color_mode(), ColorMode::Always);
1201    }
1202
1203    #[test]
1204    fn test_color_config_default_colors() {
1205        let config = LintConfig::default();
1206        assert_eq!(config.color.error, Color::Red);
1207        assert_eq!(config.color.warning, Color::Yellow);
1208    }
1209
1210    #[test]
1211    fn test_color_config_custom_colors() {
1212        let toml_content = r#"
1213[color]
1214error = "magenta"
1215warning = "cyan"
1216"#;
1217        let mut file = NamedTempFile::new().unwrap();
1218        write!(file, "{}", toml_content).unwrap();
1219
1220        let config = LintConfig::from_file(file.path()).unwrap();
1221        assert_eq!(config.color.error, Color::Magenta);
1222        assert_eq!(config.color.warning, Color::Cyan);
1223    }
1224
1225    #[test]
1226    fn test_color_config_bright_colors() {
1227        let toml_content = r#"
1228[color]
1229error = "bright_red"
1230warning = "bright_yellow"
1231"#;
1232        let mut file = NamedTempFile::new().unwrap();
1233        write!(file, "{}", toml_content).unwrap();
1234
1235        let config = LintConfig::from_file(file.path()).unwrap();
1236        assert_eq!(config.color.error, Color::BrightRed);
1237        assert_eq!(config.color.warning, Color::BrightYellow);
1238    }
1239
1240    #[test]
1241    fn test_block_lines_max_block_lines_parsing() {
1242        let toml_content = r#"
1243[rules.block-lines]
1244enabled = true
1245max_block_lines = 50
1246"#;
1247        let mut file = NamedTempFile::new().unwrap();
1248        write!(file, "{}", toml_content).unwrap();
1249
1250        let config = LintConfig::from_file(file.path()).unwrap();
1251        assert!(config.is_rule_enabled("block-lines"));
1252        let rule_config = config.get_rule_config("block-lines").unwrap();
1253        assert_eq!(rule_config.max_block_lines, Some(50));
1254    }
1255
1256    #[test]
1257    fn test_block_lines_default_no_max() {
1258        let toml_content = r#"
1259[rules.block-lines]
1260enabled = true
1261"#;
1262        let mut file = NamedTempFile::new().unwrap();
1263        write!(file, "{}", toml_content).unwrap();
1264
1265        let config = LintConfig::from_file(file.path()).unwrap();
1266        let rule_config = config.get_rule_config("block-lines").unwrap();
1267        assert_eq!(rule_config.max_block_lines, None);
1268    }
1269
1270    #[test]
1271    fn test_block_lines_validation_rejects_unknown_option() {
1272        let toml_content = r#"
1273[rules.block-lines]
1274enabled = true
1275unknown_option = 42
1276"#;
1277        let mut file = NamedTempFile::new().unwrap();
1278        write!(file, "{}", toml_content).unwrap();
1279
1280        let errors = LintConfig::validate_file(file.path()).unwrap();
1281        assert_eq!(errors.len(), 1);
1282        match &errors[0] {
1283            ValidationError::UnknownRuleOption { rule, option, .. } => {
1284                assert_eq!(rule, "block-lines");
1285                assert_eq!(option, "unknown_option");
1286            }
1287            other => panic!("expected UnknownRuleOption, got: {:?}", other),
1288        }
1289    }
1290
1291    #[test]
1292    fn test_include_path_map_empty_by_default() {
1293        let config = LintConfig::default();
1294        assert!(config.include_path_mappings().is_empty());
1295    }
1296
1297    #[test]
1298    fn test_include_path_map_single_entry() {
1299        let toml_content = r#"
1300[[include.path_map]]
1301from = "sites-enabled"
1302to   = "sites-available"
1303"#;
1304        let config = LintConfig::parse(toml_content).unwrap();
1305        let mappings = config.include_path_mappings();
1306        assert_eq!(mappings.len(), 1);
1307        assert_eq!(mappings[0].from, "sites-enabled");
1308        assert_eq!(mappings[0].to, "sites-available");
1309    }
1310
1311    #[test]
1312    fn test_include_path_map_multiple_entries_preserve_order() {
1313        let toml_content = r#"
1314[[include.path_map]]
1315from = "sites-enabled"
1316to   = "sites-available"
1317
1318[[include.path_map]]
1319from = "/etc/nginx"
1320to   = "/usr/local/nginx"
1321"#;
1322        let config = LintConfig::parse(toml_content).unwrap();
1323        let mappings = config.include_path_mappings();
1324        assert_eq!(mappings.len(), 2);
1325        assert_eq!(mappings[0].from, "sites-enabled");
1326        assert_eq!(mappings[0].to, "sites-available");
1327        assert_eq!(mappings[1].from, "/etc/nginx");
1328        assert_eq!(mappings[1].to, "/usr/local/nginx");
1329    }
1330
1331    #[test]
1332    fn test_include_validation_rejects_unknown_field() {
1333        let toml_content = r#"
1334[include]
1335unknown_key = "value"
1336"#;
1337        let mut file = NamedTempFile::new().unwrap();
1338        write!(file, "{}", toml_content).unwrap();
1339
1340        let errors = LintConfig::validate_file(file.path()).unwrap();
1341        assert_eq!(errors.len(), 1);
1342        match &errors[0] {
1343            ValidationError::UnknownField { path, .. } => {
1344                assert_eq!(path, "include.unknown_key");
1345            }
1346            other => panic!("expected UnknownField, got: {:?}", other),
1347        }
1348    }
1349
1350    #[test]
1351    fn test_include_prefix_none_by_default() {
1352        let config = LintConfig::default();
1353        assert!(config.include_prefix().is_none());
1354    }
1355
1356    #[test]
1357    fn test_include_prefix_parsed() {
1358        let toml_content = r#"
1359[include]
1360prefix = "/etc/nginx"
1361"#;
1362        let config = LintConfig::parse(toml_content).unwrap();
1363        assert_eq!(config.include_prefix(), Some("/etc/nginx"));
1364    }
1365
1366    #[test]
1367    fn test_include_prefix_with_path_map() {
1368        let toml_content = r#"
1369[include]
1370prefix = "."
1371
1372[[include.path_map]]
1373from = "sites-enabled"
1374to   = "sites-available"
1375"#;
1376        let config = LintConfig::parse(toml_content).unwrap();
1377        assert_eq!(config.include_prefix(), Some("."));
1378        assert_eq!(config.include_path_mappings().len(), 1);
1379    }
1380
1381    #[test]
1382    fn test_include_prefix_validation_accepted() {
1383        let toml_content = r#"
1384[include]
1385prefix = "/etc/nginx"
1386"#;
1387        let mut file = NamedTempFile::new().unwrap();
1388        write!(file, "{}", toml_content).unwrap();
1389
1390        let errors = LintConfig::validate_file(file.path()).unwrap();
1391        assert!(
1392            errors.is_empty(),
1393            "prefix should be a valid include field, got errors: {:?}",
1394            errors
1395        );
1396    }
1397
1398    #[test]
1399    fn test_json_schema_is_valid() {
1400        let schema = LintConfig::json_schema();
1401
1402        // Should have $schema key
1403        assert_eq!(
1404            schema.get("$schema").and_then(|v| v.as_str()),
1405            Some("https://json-schema.org/draft/2020-12/schema")
1406        );
1407
1408        // Should have properties for all top-level config sections
1409        let props = schema.get("properties").unwrap().as_object().unwrap();
1410        assert!(props.contains_key("rules"), "missing 'rules' property");
1411        assert!(props.contains_key("color"), "missing 'color' property");
1412        assert!(props.contains_key("parser"), "missing 'parser' property");
1413        assert!(props.contains_key("include"), "missing 'include' property");
1414    }
1415
1416    #[test]
1417    fn test_json_schema_rule_config_has_all_fields() {
1418        let schema = LintConfig::json_schema();
1419
1420        // RuleConfig definition should contain all fields from the struct
1421        // schemars v1 uses "$defs" instead of "definitions"
1422        let rule_config_def = schema
1423            .pointer("/$defs/RuleConfig")
1424            .expect("RuleConfig definition missing from schema");
1425
1426        let props = rule_config_def
1427            .get("properties")
1428            .unwrap()
1429            .as_object()
1430            .unwrap();
1431
1432        let expected_fields = [
1433            "enabled",
1434            "indent_size",
1435            "allowed_protocols",
1436            "weak_ciphers",
1437            "required_exclusions",
1438            "additional_contexts",
1439            "max_block_lines",
1440            "excluded_directives",
1441            "additional_directives",
1442        ];
1443
1444        for field in &expected_fields {
1445            assert!(
1446                props.contains_key(*field),
1447                "RuleConfig schema missing field '{field}'"
1448            );
1449        }
1450    }
1451}