Skip to main content

nginx_lint_common/
linter.rs

1//! Core types for the lint engine: rule definitions, error reporting, and fix proposals.
2//!
3//! This module contains the fundamental abstractions used by both native Rust
4//! rules (in `src/rules/`) and WASM plugin rules:
5//!
6//! - [`LintRule`] — trait that every rule implements
7//! - [`LintError`] — a single diagnostic produced by a rule
8//! - [`Severity`] — error vs. warning classification
9//! - [`Fix`] — an auto-fix action attached to a diagnostic
10//! - [`Linter`] — collects rules and runs them against a parsed config
11
12use crate::parser::ast::Config;
13use serde::Serialize;
14use std::path::Path;
15
16/// Display-ordered list of rule categories for UI output.
17///
18/// Used by the CLI and documentation generator to group rules consistently.
19pub const RULE_CATEGORIES: &[&str] = &[
20    "style",
21    "syntax",
22    "security",
23    "best-practices",
24    "deprecation",
25];
26
27/// Severity level of a lint diagnostic.
28///
29/// # Variants
30///
31/// - `Error` — the configuration is broken or has a critical security issue.
32/// - `Warning` — the configuration works but uses discouraged settings or could be improved.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
34pub enum Severity {
35    /// The configuration will not work correctly, or there is a critical security issue.
36    Error,
37    /// A discouraged setting, potential problem, or improvement suggestion.
38    Warning,
39}
40
41impl std::fmt::Display for Severity {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        match self {
44            Severity::Error => write!(f, "ERROR"),
45            Severity::Warning => write!(f, "WARNING"),
46        }
47    }
48}
49
50/// Represents a fix that can be applied to resolve a lint error
51#[derive(Debug, Clone, Serialize)]
52pub struct Fix {
53    /// Line number where the fix should be applied (1-indexed)
54    pub line: usize,
55    /// The original text to replace (if None and new_text is empty, delete the line)
56    pub old_text: Option<String>,
57    /// The new text to insert (empty string with old_text=None means delete)
58    pub new_text: String,
59    /// Whether to delete the entire line
60    #[serde(skip_serializing_if = "std::ops::Not::not")]
61    pub delete_line: bool,
62    /// Whether to insert new_text as a new line after the specified line
63    #[serde(skip_serializing_if = "std::ops::Not::not")]
64    pub insert_after: bool,
65    /// Start byte offset for range-based fix (0-indexed, inclusive)
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub start_offset: Option<usize>,
68    /// End byte offset for range-based fix (0-indexed, exclusive)
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub end_offset: Option<usize>,
71}
72
73impl Fix {
74    /// Create a fix that replaces text on a specific line
75    #[deprecated(note = "Use Fix::replace_range() for offset-based fixes instead")]
76    pub fn replace(line: usize, old_text: &str, new_text: &str) -> Self {
77        Self {
78            line,
79            old_text: Some(old_text.to_string()),
80            new_text: new_text.to_string(),
81            delete_line: false,
82            insert_after: false,
83            start_offset: None,
84            end_offset: None,
85        }
86    }
87
88    /// Create a fix that replaces an entire line
89    #[deprecated(note = "Use Fix::replace_range() for offset-based fixes instead")]
90    pub fn replace_line(line: usize, new_text: &str) -> Self {
91        Self {
92            line,
93            old_text: None,
94            new_text: new_text.to_string(),
95            delete_line: false,
96            insert_after: false,
97            start_offset: None,
98            end_offset: None,
99        }
100    }
101
102    /// Create a fix that deletes an entire line
103    #[deprecated(note = "Use Fix::replace_range() for offset-based fixes instead")]
104    pub fn delete(line: usize) -> Self {
105        Self {
106            line,
107            old_text: None,
108            new_text: String::new(),
109            delete_line: true,
110            insert_after: false,
111            start_offset: None,
112            end_offset: None,
113        }
114    }
115
116    /// Create a fix that inserts a new line after the specified line
117    #[deprecated(note = "Use Fix::replace_range() for offset-based fixes instead")]
118    pub fn insert_after(line: usize, new_text: &str) -> Self {
119        Self {
120            line,
121            old_text: None,
122            new_text: new_text.to_string(),
123            delete_line: false,
124            insert_after: true,
125            start_offset: None,
126            end_offset: None,
127        }
128    }
129
130    /// Create a range-based fix that replaces bytes from start to end offset
131    ///
132    /// This allows multiple fixes on the same line as long as their ranges don't overlap.
133    pub fn replace_range(start_offset: usize, end_offset: usize, new_text: &str) -> Self {
134        Self {
135            line: 0, // Not used for range-based fixes
136            old_text: None,
137            new_text: new_text.to_string(),
138            delete_line: false,
139            insert_after: false,
140            start_offset: Some(start_offset),
141            end_offset: Some(end_offset),
142        }
143    }
144
145    /// Check if this is a range-based fix
146    pub fn is_range_based(&self) -> bool {
147        self.start_offset.is_some() && self.end_offset.is_some()
148    }
149}
150
151/// A single lint diagnostic produced by a rule.
152///
153/// Every [`LintRule::check`] call returns a `Vec<LintError>`. Each error
154/// carries the rule name, category, a human-readable message, severity, an
155/// optional source location, and zero or more [`Fix`] proposals.
156///
157/// # Building errors
158///
159/// ```
160/// use nginx_lint_common::linter::{LintError, Severity, Fix};
161///
162/// let error = LintError::new("my-rule", "style", "trailing whitespace", Severity::Warning)
163///     .with_location(10, 1)
164///     .with_fix(Fix::replace(10, "value  ", "value"));
165/// ```
166#[derive(Debug, Clone, Serialize)]
167pub struct LintError {
168    /// Rule identifier (e.g. `"server-tokens-enabled"`).
169    pub rule: String,
170    /// Category the rule belongs to (e.g. `"security"`, `"style"`).
171    pub category: String,
172    /// Human-readable description of the problem.
173    pub message: String,
174    /// Whether this is an error or a warning.
175    pub severity: Severity,
176    /// 1-indexed line number where the problem was detected.
177    pub line: Option<usize>,
178    /// 1-indexed column number where the problem was detected.
179    pub column: Option<usize>,
180    /// Auto-fix proposals that can resolve this diagnostic.
181    #[serde(default, skip_serializing_if = "Vec::is_empty")]
182    pub fixes: Vec<Fix>,
183}
184
185impl LintError {
186    /// Create a new lint error without a source location.
187    ///
188    /// Use [`with_location`](Self::with_location) to attach line/column info
189    /// and [`with_fix`](Self::with_fix) to attach auto-fix proposals.
190    pub fn new(rule: &str, category: &str, message: &str, severity: Severity) -> Self {
191        Self {
192            rule: rule.to_string(),
193            category: category.to_string(),
194            message: message.to_string(),
195            severity,
196            line: None,
197            column: None,
198            fixes: Vec::new(),
199        }
200    }
201
202    /// Attach a source location (1-indexed line and column) to this error.
203    pub fn with_location(mut self, line: usize, column: usize) -> Self {
204        self.line = Some(line);
205        self.column = Some(column);
206        self
207    }
208
209    /// Append a single [`Fix`] proposal to this error.
210    pub fn with_fix(mut self, fix: Fix) -> Self {
211        self.fixes.push(fix);
212        self
213    }
214
215    /// Append multiple [`Fix`] proposals to this error.
216    pub fn with_fixes(mut self, fixes: Vec<Fix>) -> Self {
217        self.fixes.extend(fixes);
218        self
219    }
220}
221
222/// A lint rule that can be checked against a parsed nginx configuration.
223///
224/// Every rule — whether implemented as a native Rust struct or as a WASM
225/// plugin — implements this trait. The four required methods supply metadata
226/// and the check logic; the optional methods provide documentation and
227/// plugin-specific overrides.
228///
229/// # Required methods
230///
231/// | Method | Purpose |
232/// |--------|---------|
233/// | [`name`](Self::name) | Unique rule identifier (e.g. `"server-tokens-enabled"`) |
234/// | [`category`](Self::category) | Category for grouping (e.g. `"security"`) |
235/// | [`description`](Self::description) | One-line human-readable summary |
236/// | [`check`](Self::check) | Run the rule and return diagnostics |
237pub trait LintRule: Send + Sync {
238    /// Unique identifier for this rule (e.g. `"server-tokens-enabled"`).
239    fn name(&self) -> &'static str;
240    /// Category this rule belongs to (e.g. `"security"`, `"style"`).
241    fn category(&self) -> &'static str;
242    /// One-line human-readable description of what this rule checks.
243    fn description(&self) -> &'static str;
244    /// Run the rule against `config` (parsed from `path`) and return diagnostics.
245    fn check(&self, config: &Config, path: &Path) -> Vec<LintError>;
246
247    /// Check with pre-serialized config JSON (optimization for WASM plugins)
248    ///
249    /// This method allows passing a pre-serialized config JSON to avoid
250    /// repeated serialization when running multiple plugins.
251    /// Default implementation ignores the serialized config and calls check().
252    #[deprecated(
253        since = "0.16.0",
254        note = "no longer called by the linter; the serialized config was only used by \
255                legacy core-module plugins. Implement check() or check_shared() instead."
256    )]
257    fn check_with_serialized_config(
258        &self,
259        config: &Config,
260        path: &Path,
261        _serialized_config: &str,
262    ) -> Vec<LintError> {
263        self.check(config, path)
264    }
265
266    /// Whether this rule wants the config as a shared `Arc` handle.
267    ///
268    /// Rules that hand the config to another owner (e.g. WASM plugin rules,
269    /// which store it in the sandbox's resource table) should return `true`
270    /// so the linter shares one `Arc<Config>` across all such rules instead
271    /// of each rule deep-cloning the AST per check.
272    fn wants_shared_config(&self) -> bool {
273        false
274    }
275
276    /// Run the rule with a shared config handle.
277    ///
278    /// The linter calls this instead of [`check`](Self::check) when
279    /// [`wants_shared_config`](Self::wants_shared_config) returns `true`.
280    /// Default implementation borrows the config and calls `check()`.
281    fn check_shared(&self, config: &std::sync::Arc<Config>, path: &Path) -> Vec<LintError> {
282        self.check(config, path)
283    }
284
285    /// Get detailed explanation of why this rule exists
286    fn why(&self) -> Option<&str> {
287        None
288    }
289
290    /// Get example of bad configuration
291    fn bad_example(&self) -> Option<&str> {
292        None
293    }
294
295    /// Get example of good configuration
296    fn good_example(&self) -> Option<&str> {
297        None
298    }
299
300    /// Get reference URLs
301    fn references(&self) -> Option<Vec<String>> {
302        None
303    }
304
305    /// Get severity level (for plugins)
306    fn severity(&self) -> Option<&str> {
307        None
308    }
309
310    /// Minimum nginx version this rule applies to (inclusive).
311    ///
312    /// `None` means the rule applies regardless of how old the nginx version is.
313    /// Used by the linter's version-based rule filter to decide whether to
314    /// run this rule against a config whose
315    /// [`target_nginx_version`](crate::config::LintConfig::target_nginx_version)
316    /// is set.
317    fn min_nginx_version(&self) -> Option<&str> {
318        None
319    }
320
321    /// Maximum nginx version this rule applies to (inclusive).
322    ///
323    /// `None` means the rule applies regardless of how new the nginx version is.
324    fn max_nginx_version(&self) -> Option<&str> {
325        None
326    }
327}
328
329/// Container that holds [`LintRule`]s and runs them against a parsed config.
330///
331/// Create a `Linter`, register rules with [`add_rule`](Self::add_rule), then
332/// call [`lint`](Self::lint) to collect all diagnostics.
333pub struct Linter {
334    rules: Vec<Box<dyn LintRule>>,
335}
336
337impl Linter {
338    /// Create an empty linter with no rules registered.
339    pub fn new() -> Self {
340        Self { rules: Vec::new() }
341    }
342
343    /// Register a lint rule. Rules are executed in registration order.
344    pub fn add_rule(&mut self, rule: Box<dyn LintRule>) {
345        self.rules.push(rule);
346    }
347
348    /// Remove rules that match the predicate
349    pub fn remove_rules_by_name<F>(&mut self, should_remove: F)
350    where
351        F: Fn(&str) -> bool,
352    {
353        self.rules.retain(|rule| !should_remove(rule.name()));
354    }
355
356    /// Get a reference to all rules
357    pub fn rules(&self) -> &[Box<dyn LintRule>] {
358        &self.rules
359    }
360
361    /// Run all lint rules and collect errors (sequential version)
362    pub fn lint(&self, config: &Config, path: &Path) -> Vec<LintError> {
363        let shared_config = std::sync::OnceLock::new();
364
365        self.rules
366            .iter()
367            .flat_map(|rule| run_rule(rule.as_ref(), config, path, &shared_config))
368            .collect()
369    }
370}
371
372/// Run a single rule, dispatching to [`LintRule::check_shared`] with one
373/// lazily-created `Arc<Config>` for rules that
374/// [want a shared handle](LintRule::wants_shared_config), and to
375/// [`LintRule::check`] otherwise.
376///
377/// The `Arc` is created at most once per `shared_config` cell (i.e. per
378/// linted file), so purely native rule sets never pay for the clone. Linter
379/// implementations should route every rule invocation through this function
380/// so the dispatch policy stays in one place.
381pub fn run_rule(
382    rule: &dyn LintRule,
383    config: &Config,
384    path: &Path,
385    shared_config: &std::sync::OnceLock<std::sync::Arc<Config>>,
386) -> Vec<LintError> {
387    if rule.wants_shared_config() {
388        let shared = shared_config.get_or_init(|| std::sync::Arc::new(config.clone()));
389        rule.check_shared(shared, path)
390    } else {
391        rule.check(config, path)
392    }
393}
394
395impl Default for Linter {
396    fn default() -> Self {
397        Self::new()
398    }
399}
400
401/// Compute the byte offset of the start of each line (1-indexed).
402///
403/// Returns a vector where `line_starts[0]` is always `0` (start of line 1),
404/// `line_starts[1]` is the byte offset of line 2, etc.
405/// An extra entry at the end equals `content.len()` for convenience.
406pub fn compute_line_starts(content: &str) -> Vec<usize> {
407    let mut starts = vec![0];
408    for (i, b) in content.bytes().enumerate() {
409        if b == b'\n' {
410            starts.push(i + 1);
411        }
412    }
413    starts.push(content.len());
414    starts
415}
416
417/// Convert a line-based [`Fix`] into an offset-based one using precomputed line starts.
418///
419/// Line-based fixes (created via deprecated `Fix::replace`, `Fix::delete`, etc.) are
420/// normalized to `Fix::replace_range` using the provided `line_starts` offsets.
421///
422/// Returns `None` if the fix references an out-of-range line or the `old_text` is not found.
423pub fn normalize_line_fix(fix: &Fix, content: &str, line_starts: &[usize]) -> Option<Fix> {
424    if fix.line == 0 {
425        return None;
426    }
427
428    let num_lines = line_starts.len() - 1; // last entry is content.len()
429
430    if fix.delete_line {
431        if fix.line > num_lines {
432            return None;
433        }
434        let start = line_starts[fix.line - 1];
435        let end = if fix.line < num_lines {
436            line_starts[fix.line] // includes the trailing \n
437        } else {
438            // Last line: also remove the preceding \n if there is one
439            let end = line_starts[fix.line]; // == content.len()
440            if start > 0 && content.as_bytes().get(start - 1) == Some(&b'\n') {
441                return Some(Fix::replace_range(start - 1, end, ""));
442            }
443            end
444        };
445        return Some(Fix::replace_range(start, end, ""));
446    }
447
448    if fix.insert_after {
449        if fix.line > num_lines {
450            return None;
451        }
452        // Insert point: right after the \n at end of the target line
453        let insert_offset = if fix.line < num_lines {
454            line_starts[fix.line]
455        } else {
456            content.len()
457        };
458        let new_text = if insert_offset == content.len() && !content.ends_with('\n') {
459            format!("\n{}", fix.new_text)
460        } else {
461            format!("{}\n", fix.new_text)
462        };
463        return Some(Fix::replace_range(insert_offset, insert_offset, &new_text));
464    }
465
466    if fix.line > num_lines {
467        return None;
468    }
469
470    let line_start = line_starts[fix.line - 1];
471    let line_end_with_newline = line_starts[fix.line];
472    // Line content without trailing newline
473    let line_end = if line_end_with_newline > line_start
474        && content.as_bytes().get(line_end_with_newline - 1) == Some(&b'\n')
475    {
476        line_end_with_newline - 1
477    } else {
478        line_end_with_newline
479    };
480
481    if let Some(ref old_text) = fix.old_text {
482        // Replace first occurrence of old_text within the line
483        let line_content = &content[line_start..line_end];
484        if let Some(pos) = line_content.find(old_text.as_str()) {
485            let start = line_start + pos;
486            let end = start + old_text.len();
487            return Some(Fix::replace_range(start, end, &fix.new_text));
488        }
489        return None;
490    }
491
492    // Replace entire line content (not including newline)
493    Some(Fix::replace_range(line_start, line_end, &fix.new_text))
494}
495
496/// Result of applying fixes to content, with detailed counts.
497#[derive(Debug, Clone)]
498pub struct FixApplyResult {
499    /// Content after applying the fixes
500    pub content: String,
501    /// Number of fixes applied
502    pub applied: usize,
503    /// Number of fixes skipped because they could not be applied: offsets out
504    /// of range or not on UTF-8 character boundaries, or a line-based fix
505    /// referencing a missing line or `old_text` (e.g. produced by a buggy
506    /// plugin). Does not include fixes skipped due to overlap with an applied
507    /// fix.
508    pub skipped_invalid: usize,
509}
510
511/// Apply fixes to content string.
512///
513/// Convenience wrapper around [`apply_fixes_to_content_detailed`] for callers
514/// that do not need the skipped-fix count.
515///
516/// Returns `(modified_content, number_of_fixes_applied)`.
517pub fn apply_fixes_to_content(content: &str, fixes: &[&Fix]) -> (String, usize) {
518    let result = apply_fixes_to_content_detailed(content, fixes);
519    (result.content, result.applied)
520}
521
522/// Apply fixes to content string, reporting skipped fixes.
523///
524/// All fixes (both line-based and offset-based) are normalized to offset-based,
525/// then applied in reverse order to avoid index shifts. Overlapping fixes are skipped.
526/// Fixes that cannot be applied (invalid offsets, or line-based fixes that fail
527/// normalization) are skipped and counted in [`FixApplyResult::skipped_invalid`].
528pub fn apply_fixes_to_content_detailed(content: &str, fixes: &[&Fix]) -> FixApplyResult {
529    let line_starts = compute_line_starts(content);
530    let mut skipped_invalid = 0;
531
532    // Normalize all fixes to range-based
533    let mut range_fixes: Vec<Fix> = Vec::with_capacity(fixes.len());
534    for fix in fixes {
535        if fix.is_range_based() {
536            range_fixes.push((*fix).clone());
537        } else if let Some(normalized) = normalize_line_fix(fix, content, &line_starts) {
538            range_fixes.push(normalized);
539        } else {
540            skipped_invalid += 1;
541        }
542    }
543
544    // Sort by start_offset descending to avoid index shifts.
545    // For same-offset insertions (start == end), sort by indent ascending so that
546    // the more-indented text is processed last and ends up first in the file.
547    range_fixes.sort_by(|a, b| {
548        let a_start = a.start_offset.unwrap();
549        let b_start = b.start_offset.unwrap();
550        match b_start.cmp(&a_start) {
551            std::cmp::Ordering::Equal => {
552                let a_is_insert = a.end_offset.unwrap() == a_start;
553                let b_is_insert = b.end_offset.unwrap() == b_start;
554                if a_is_insert && b_is_insert {
555                    // For insertions at the same point: ascending indent order
556                    // so more-indented text is processed last (appears first in output)
557                    let a_indent = a.new_text.len() - a.new_text.trim_start().len();
558                    let b_indent = b.new_text.len() - b.new_text.trim_start().len();
559                    a_indent.cmp(&b_indent)
560                } else {
561                    std::cmp::Ordering::Equal
562                }
563            }
564            other => other,
565        }
566    });
567
568    let mut fix_count = 0;
569    let mut result = content.to_string();
570    let mut applied_ranges: Vec<(usize, usize)> = Vec::new();
571
572    for fix in &range_fixes {
573        let start = fix.start_offset.unwrap();
574        let end = fix.end_offset.unwrap();
575
576        // Check if this range overlaps with any already applied range
577        let overlaps = applied_ranges.iter().any(|(s, e)| start < *e && end > *s);
578        if overlaps {
579            continue;
580        }
581
582        // Offsets must lie on UTF-8 char boundaries: replace_range panics
583        // otherwise, and plugin-provided fixes are untrusted input.
584        if start <= end
585            && end <= result.len()
586            && result.is_char_boundary(start)
587            && result.is_char_boundary(end)
588        {
589            result.replace_range(start..end, &fix.new_text);
590            applied_ranges.push((start, start + fix.new_text.len()));
591            fix_count += 1;
592        } else {
593            skipped_invalid += 1;
594        }
595    }
596
597    // Ensure trailing newline
598    if !result.ends_with('\n') {
599        result.push('\n');
600    }
601
602    FixApplyResult {
603        content: result,
604        applied: fix_count,
605        skipped_invalid,
606    }
607}
608
609#[cfg(test)]
610mod fix_tests {
611    use super::*;
612
613    #[test]
614    fn test_compute_line_starts() {
615        let starts = compute_line_starts("abc\ndef\nghi");
616        // line 1 starts at 0, line 2 at 4, line 3 at 8, sentinel at 11
617        assert_eq!(starts, vec![0, 4, 8, 11]);
618    }
619
620    #[test]
621    fn test_compute_line_starts_trailing_newline() {
622        let starts = compute_line_starts("abc\n");
623        // line 1 at 0, line 2 at 4 (empty), sentinel at 4
624        assert_eq!(starts, vec![0, 4, 4]);
625    }
626
627    #[test]
628    #[allow(deprecated)]
629    fn test_normalize_replace() {
630        let content = "listen 80;\nserver_name example.com;\n";
631        let line_starts = compute_line_starts(content);
632        let fix = Fix::replace(1, "80", "8080");
633        let normalized = normalize_line_fix(&fix, content, &line_starts).unwrap();
634        assert!(normalized.is_range_based());
635        assert_eq!(normalized.start_offset, Some(7));
636        assert_eq!(normalized.end_offset, Some(9));
637        assert_eq!(normalized.new_text, "8080");
638    }
639
640    #[test]
641    #[allow(deprecated)]
642    fn test_normalize_delete() {
643        let content = "line1\nline2\nline3\n";
644        let line_starts = compute_line_starts(content);
645        let fix = Fix::delete(2);
646        let normalized = normalize_line_fix(&fix, content, &line_starts).unwrap();
647        assert!(normalized.is_range_based());
648        // Should delete "line2\n" (offset 6..12)
649        assert_eq!(normalized.start_offset, Some(6));
650        assert_eq!(normalized.end_offset, Some(12));
651    }
652
653    #[test]
654    #[allow(deprecated)]
655    fn test_normalize_insert_after() {
656        let content = "line1\nline2\n";
657        let line_starts = compute_line_starts(content);
658        let fix = Fix::insert_after(1, "inserted");
659        let normalized = normalize_line_fix(&fix, content, &line_starts).unwrap();
660        assert!(normalized.is_range_based());
661        // Insert at offset 6 (start of line 2)
662        assert_eq!(normalized.start_offset, Some(6));
663        assert_eq!(normalized.end_offset, Some(6));
664        assert_eq!(normalized.new_text, "inserted\n");
665    }
666
667    #[test]
668    #[allow(deprecated)]
669    fn test_normalize_out_of_range() {
670        let content = "line1\n";
671        let line_starts = compute_line_starts(content);
672        let fix = Fix::delete(99);
673        assert!(normalize_line_fix(&fix, content, &line_starts).is_none());
674    }
675
676    #[test]
677    #[allow(deprecated)]
678    fn test_normalize_replace_not_found() {
679        let content = "listen 80;\n";
680        let line_starts = compute_line_starts(content);
681        let fix = Fix::replace(1, "nonexistent", "new");
682        assert!(normalize_line_fix(&fix, content, &line_starts).is_none());
683    }
684
685    #[test]
686    fn test_apply_range_fix() {
687        let content = "listen 80;\n";
688        let fix = Fix::replace_range(7, 9, "8080");
689        let fixes: Vec<&Fix> = vec![&fix];
690        let (result, count) = apply_fixes_to_content(content, &fixes);
691        assert_eq!(result, "listen 8080;\n");
692        assert_eq!(count, 1);
693    }
694
695    #[test]
696    fn test_apply_multiple_fixes_same_line() {
697        // Two fixes on the same line should both apply
698        let content = "proxy_set_header Host $host;\n";
699        let fix1 = Fix::replace_range(17, 21, "X-Real-IP");
700        let fix2 = Fix::replace_range(22, 27, "$remote_addr");
701        let fixes: Vec<&Fix> = vec![&fix1, &fix2];
702        let (result, count) = apply_fixes_to_content(content, &fixes);
703        assert_eq!(result, "proxy_set_header X-Real-IP $remote_addr;\n");
704        assert_eq!(count, 2);
705    }
706
707    #[test]
708    fn test_apply_overlapping_fixes_skips() {
709        let content = "abcdef\n";
710        let fix1 = Fix::replace_range(0, 3, "XYZ"); // replace "abc"
711        let fix2 = Fix::replace_range(2, 5, "QQQ"); // overlaps with fix1
712        let fixes: Vec<&Fix> = vec![&fix1, &fix2];
713        let (_, count) = apply_fixes_to_content(content, &fixes);
714        // Only one fix should apply (the other is skipped due to overlap)
715        assert_eq!(count, 1);
716    }
717
718    #[test]
719    fn test_apply_fix_non_char_boundary_skipped() {
720        // "あ" is 3 bytes (0..3); offsets 1 and 2 are not char boundaries.
721        // Such fixes (e.g. from a malicious plugin) must be skipped, not panic.
722        let content = "あいう;\n";
723        let fix = Fix::replace_range(1, 2, "x");
724        let fixes: Vec<&Fix> = vec![&fix];
725        let (result, count) = apply_fixes_to_content(content, &fixes);
726        assert_eq!(result, content);
727        assert_eq!(count, 0);
728    }
729
730    #[test]
731    fn test_apply_fix_non_char_boundary_end_skipped() {
732        // start is on a boundary but end is mid-character
733        let content = "あいう;\n";
734        let fix = Fix::replace_range(0, 4, "x");
735        let fixes: Vec<&Fix> = vec![&fix];
736        let (result, count) = apply_fixes_to_content(content, &fixes);
737        assert_eq!(result, content);
738        assert_eq!(count, 0);
739    }
740
741    #[test]
742    fn test_apply_fix_multibyte_on_boundary_applies() {
743        // Offsets on char boundaries within multibyte content still work
744        let content = "あいう;\n";
745        let fix = Fix::replace_range(3, 6, "x");
746        let fixes: Vec<&Fix> = vec![&fix];
747        let (result, count) = apply_fixes_to_content(content, &fixes);
748        assert_eq!(result, "あxう;\n");
749        assert_eq!(count, 1);
750    }
751
752    #[test]
753    fn test_detailed_counts_invalid_fixes() {
754        // One valid fix, one non-boundary fix, one out-of-range fix
755        let content = "あいう;\n";
756        let valid = Fix::replace_range(3, 6, "x");
757        let non_boundary = Fix::replace_range(1, 2, "y");
758        let out_of_range = Fix::replace_range(100, 200, "z");
759        let fixes: Vec<&Fix> = vec![&valid, &non_boundary, &out_of_range];
760        let result = apply_fixes_to_content_detailed(content, &fixes);
761        assert_eq!(result.content, "あxう;\n");
762        assert_eq!(result.applied, 1);
763        assert_eq!(result.skipped_invalid, 2);
764    }
765
766    #[test]
767    #[allow(deprecated)]
768    fn test_detailed_counts_failed_line_normalization() {
769        let content = "listen 80;\n";
770        let missing_old_text = Fix::replace(1, "nonexistent", "x");
771        let out_of_range_line = Fix::replace(99, "listen", "x");
772        let fixes: Vec<&Fix> = vec![&missing_old_text, &out_of_range_line];
773        let result = apply_fixes_to_content_detailed(content, &fixes);
774        assert_eq!(result.content, content);
775        assert_eq!(result.applied, 0);
776        assert_eq!(result.skipped_invalid, 2);
777    }
778
779    #[test]
780    fn test_detailed_overlap_not_counted_as_invalid() {
781        let content = "abcdef\n";
782        let fix1 = Fix::replace_range(0, 3, "XYZ");
783        let fix2 = Fix::replace_range(2, 5, "QQQ"); // overlaps with fix1
784        let fixes: Vec<&Fix> = vec![&fix1, &fix2];
785        let result = apply_fixes_to_content_detailed(content, &fixes);
786        assert_eq!(result.applied, 1);
787        assert_eq!(result.skipped_invalid, 0);
788    }
789
790    #[test]
791    #[allow(deprecated)]
792    fn test_apply_deprecated_fix_via_normalization() {
793        let content = "listen 80;\nserver_name old;\n";
794        let fix = Fix::replace(2, "old", "new");
795        let fixes: Vec<&Fix> = vec![&fix];
796        let (result, count) = apply_fixes_to_content(content, &fixes);
797        assert_eq!(result, "listen 80;\nserver_name new;\n");
798        assert_eq!(count, 1);
799    }
800}