Skip to main content

agnix_core/
diagnostics.rs

1//! Diagnostic types and error reporting for lint results
2
3#[cfg(feature = "filesystem")]
4use rust_i18n::t;
5use serde::{Deserialize, Serialize};
6use std::path::PathBuf;
7use thiserror::Error;
8
9pub type LintResult<T> = Result<T, LintError>;
10
11/// An automatic fix for a diagnostic
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct Fix {
14    /// Byte offset start (inclusive)
15    pub start_byte: usize,
16    /// Byte offset end (exclusive)
17    pub end_byte: usize,
18    /// Text to insert/replace with
19    pub replacement: String,
20    /// Human-readable description of what this fix does
21    pub description: String,
22    /// Legacy safety flag retained for backwards compatibility.
23    /// New code should prefer `confidence`, `is_safe()`, and `confidence_tier()`.
24    pub safe: bool,
25    /// Confidence score (0.0 to 1.0).
26    ///
27    /// - HIGH: >= 0.95
28    /// - MEDIUM: >= 0.75 and < 0.95
29    /// - LOW: < 0.75
30    ///
31    /// When this is `None` (legacy serialized payloads), confidence is inferred
32    /// from `safe` for compatibility.
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub confidence: Option<f32>,
35    /// Optional group key. Fixes in the same group are treated as alternatives.
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub group: Option<String>,
38    /// Optional dependency key (group or description) required before applying this fix.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub depends_on: Option<String>,
41}
42
43pub const FIX_CONFIDENCE_HIGH_THRESHOLD: f32 = 0.95;
44pub const FIX_CONFIDENCE_MEDIUM_THRESHOLD: f32 = 0.75;
45const LEGACY_UNSAFE_CONFIDENCE: f32 = 0.80;
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48pub enum FixConfidenceTier {
49    High,
50    Medium,
51    Low,
52}
53
54impl Fix {
55    /// Create a replacement fix
56    pub fn replace(
57        start: usize,
58        end: usize,
59        replacement: impl Into<String>,
60        description: impl Into<String>,
61        safe: bool,
62    ) -> Self {
63        debug_assert!(
64            start <= end,
65            "Fix::replace: start_byte ({start}) must be <= end_byte ({end})"
66        );
67        let confidence = if safe { 1.0 } else { LEGACY_UNSAFE_CONFIDENCE };
68        Self {
69            start_byte: start,
70            end_byte: end,
71            replacement: replacement.into(),
72            description: description.into(),
73            safe,
74            confidence: Some(confidence),
75            group: None,
76            depends_on: None,
77        }
78    }
79
80    /// Create a replacement fix with explicit confidence.
81    pub fn replace_with_confidence(
82        start: usize,
83        end: usize,
84        replacement: impl Into<String>,
85        description: impl Into<String>,
86        confidence: f32,
87    ) -> Self {
88        debug_assert!(
89            start <= end,
90            "Fix::replace_with_confidence: start_byte ({start}) must be <= end_byte ({end})"
91        );
92        Self {
93            start_byte: start,
94            end_byte: end,
95            replacement: replacement.into(),
96            description: description.into(),
97            safe: confidence >= FIX_CONFIDENCE_HIGH_THRESHOLD,
98            confidence: Some(clamp_confidence(confidence)),
99            group: None,
100            depends_on: None,
101        }
102    }
103
104    /// Create an insertion fix (start == end)
105    pub fn insert(
106        position: usize,
107        text: impl Into<String>,
108        description: impl Into<String>,
109        safe: bool,
110    ) -> Self {
111        let confidence = if safe { 1.0 } else { LEGACY_UNSAFE_CONFIDENCE };
112        Self {
113            start_byte: position,
114            end_byte: position,
115            replacement: text.into(),
116            description: description.into(),
117            safe,
118            confidence: Some(confidence),
119            group: None,
120            depends_on: None,
121        }
122    }
123
124    /// Create an insertion fix with explicit confidence.
125    pub fn insert_with_confidence(
126        position: usize,
127        text: impl Into<String>,
128        description: impl Into<String>,
129        confidence: f32,
130    ) -> Self {
131        Self {
132            start_byte: position,
133            end_byte: position,
134            replacement: text.into(),
135            description: description.into(),
136            safe: confidence >= FIX_CONFIDENCE_HIGH_THRESHOLD,
137            confidence: Some(clamp_confidence(confidence)),
138            group: None,
139            depends_on: None,
140        }
141    }
142
143    /// Create a deletion fix (replacement is empty)
144    pub fn delete(start: usize, end: usize, description: impl Into<String>, safe: bool) -> Self {
145        debug_assert!(
146            start <= end,
147            "Fix::delete: start_byte ({start}) must be <= end_byte ({end})"
148        );
149        let confidence = if safe { 1.0 } else { LEGACY_UNSAFE_CONFIDENCE };
150        Self {
151            start_byte: start,
152            end_byte: end,
153            replacement: String::new(),
154            description: description.into(),
155            safe,
156            confidence: Some(confidence),
157            group: None,
158            depends_on: None,
159        }
160    }
161
162    /// Create a deletion fix with explicit confidence.
163    pub fn delete_with_confidence(
164        start: usize,
165        end: usize,
166        description: impl Into<String>,
167        confidence: f32,
168    ) -> Self {
169        debug_assert!(
170            start <= end,
171            "Fix::delete_with_confidence: start_byte ({start}) must be <= end_byte ({end})"
172        );
173        Self {
174            start_byte: start,
175            end_byte: end,
176            replacement: String::new(),
177            description: description.into(),
178            safe: confidence >= FIX_CONFIDENCE_HIGH_THRESHOLD,
179            confidence: Some(clamp_confidence(confidence)),
180            group: None,
181            depends_on: None,
182        }
183    }
184
185    /// Internal helper for debug-only validation of byte ranges and UTF-8 char boundaries.
186    ///
187    /// Used by `_checked` constructors to keep assertions and error messages consistent.
188    /// No-op in release builds since it only contains `debug_assert!` calls.
189    fn debug_assert_valid_range(content: &str, start: usize, end: usize, context: &'static str) {
190        debug_assert!(
191            start <= end,
192            "{context}: start_byte ({start}) must be <= end_byte ({end})"
193        );
194        debug_assert!(
195            start <= content.len(),
196            "{context}: start_byte ({start}) is out of bounds (len={})",
197            content.len()
198        );
199        debug_assert!(
200            content.is_char_boundary(start),
201            "{context}: start_byte ({start}) is not on a UTF-8 char boundary"
202        );
203        debug_assert!(
204            end <= content.len(),
205            "{context}: end_byte ({end}) is out of bounds (len={})",
206            content.len()
207        );
208        debug_assert!(
209            content.is_char_boundary(end),
210            "{context}: end_byte ({end}) is not on a UTF-8 char boundary"
211        );
212    }
213
214    /// Internal helper for debug-only validation of a single byte position and UTF-8 char boundary.
215    ///
216    /// Used by insert `_checked` constructors. No-op in release builds.
217    fn debug_assert_valid_position(content: &str, position: usize, context: &'static str) {
218        debug_assert!(
219            position <= content.len(),
220            "{context}: position ({position}) is out of bounds (len={})",
221            content.len()
222        );
223        debug_assert!(
224            content.is_char_boundary(position),
225            "{context}: position ({position}) is not on a UTF-8 char boundary"
226        );
227    }
228
229    /// Create a replacement fix, asserting UTF-8 char boundary alignment in debug builds.
230    ///
231    /// Validates that both `start` and `end` land on UTF-8 char boundaries in `content`.
232    /// These checks are no-ops in release builds; the function otherwise behaves identically to its unchecked counterpart. Use [`Self::replace`] when `content` is not available.
233    pub fn replace_checked(
234        content: &str,
235        start: usize,
236        end: usize,
237        replacement: impl Into<String>,
238        description: impl Into<String>,
239        safe: bool,
240    ) -> Self {
241        Self::debug_assert_valid_range(content, start, end, "Fix::replace_checked");
242        Self::replace(start, end, replacement, description, safe)
243    }
244
245    /// Create a replacement fix with explicit confidence, asserting UTF-8 char boundary
246    /// alignment in debug builds.
247    ///
248    /// Validates that both `start` and `end` land on UTF-8 char boundaries in `content`.
249    /// These checks are no-ops in release builds; the function otherwise behaves identically to its unchecked counterpart. Use [`Self::replace_with_confidence`] when
250    /// `content` is not available.
251    pub fn replace_with_confidence_checked(
252        content: &str,
253        start: usize,
254        end: usize,
255        replacement: impl Into<String>,
256        description: impl Into<String>,
257        confidence: f32,
258    ) -> Self {
259        Self::debug_assert_valid_range(content, start, end, "Fix::replace_with_confidence_checked");
260        Self::replace_with_confidence(start, end, replacement, description, confidence)
261    }
262
263    /// Create an insertion fix, asserting UTF-8 char boundary alignment in debug builds.
264    ///
265    /// Validates that `position` lands on a UTF-8 char boundary in `content`.
266    /// These checks are no-ops in release builds; the function otherwise behaves identically to its unchecked counterpart. Use [`Self::insert`] when `content` is not available.
267    pub fn insert_checked(
268        content: &str,
269        position: usize,
270        text: impl Into<String>,
271        description: impl Into<String>,
272        safe: bool,
273    ) -> Self {
274        Self::debug_assert_valid_position(content, position, "Fix::insert_checked");
275        Self::insert(position, text, description, safe)
276    }
277
278    /// Create an insertion fix with explicit confidence, asserting UTF-8 char boundary
279    /// alignment in debug builds.
280    ///
281    /// Validates that `position` lands on a UTF-8 char boundary in `content`.
282    /// These checks are no-ops in release builds; the function otherwise behaves identically to its unchecked counterpart. Use [`Self::insert_with_confidence`] when
283    /// `content` is not available.
284    pub fn insert_with_confidence_checked(
285        content: &str,
286        position: usize,
287        text: impl Into<String>,
288        description: impl Into<String>,
289        confidence: f32,
290    ) -> Self {
291        Self::debug_assert_valid_position(content, position, "Fix::insert_with_confidence_checked");
292        Self::insert_with_confidence(position, text, description, confidence)
293    }
294
295    /// Create a deletion fix, asserting UTF-8 char boundary alignment in debug builds.
296    ///
297    /// Validates that both `start` and `end` land on UTF-8 char boundaries in `content`.
298    /// These checks are no-ops in release builds; the function otherwise behaves identically to its unchecked counterpart. Use [`Self::delete`] when `content` is not available.
299    pub fn delete_checked(
300        content: &str,
301        start: usize,
302        end: usize,
303        description: impl Into<String>,
304        safe: bool,
305    ) -> Self {
306        Self::debug_assert_valid_range(content, start, end, "Fix::delete_checked");
307        Self::delete(start, end, description, safe)
308    }
309
310    /// Create a deletion fix with explicit confidence, asserting UTF-8 char boundary
311    /// alignment in debug builds.
312    ///
313    /// Validates that both `start` and `end` land on UTF-8 char boundaries in `content`.
314    /// These checks are no-ops in release builds; the function otherwise behaves identically to its unchecked counterpart. Use [`Self::delete_with_confidence`] when
315    /// `content` is not available.
316    pub fn delete_with_confidence_checked(
317        content: &str,
318        start: usize,
319        end: usize,
320        description: impl Into<String>,
321        confidence: f32,
322    ) -> Self {
323        Self::debug_assert_valid_range(content, start, end, "Fix::delete_with_confidence_checked");
324        Self::delete_with_confidence(start, end, description, confidence)
325    }
326
327    /// Override confidence for this fix and sync legacy `safe`.
328    pub fn with_confidence(mut self, confidence: f32) -> Self {
329        let clamped = clamp_confidence(confidence);
330        self.confidence = Some(clamped);
331        self.safe = clamped >= FIX_CONFIDENCE_HIGH_THRESHOLD;
332        self
333    }
334
335    /// Mark this fix as part of an alternatives group.
336    pub fn with_group(mut self, group: impl Into<String>) -> Self {
337        self.group = Some(group.into());
338        self
339    }
340
341    /// Set a dependency key (group or description) that must be applied first.
342    pub fn with_dependency(mut self, depends_on: impl Into<String>) -> Self {
343        self.depends_on = Some(depends_on.into());
344        self
345    }
346
347    /// Resolve confidence score with legacy fallback.
348    pub fn confidence_score(&self) -> f32 {
349        self.confidence.unwrap_or({
350            if self.safe {
351                1.0
352            } else {
353                LEGACY_UNSAFE_CONFIDENCE
354            }
355        })
356    }
357
358    /// Derived safety check based on confidence threshold.
359    pub fn is_safe(&self) -> bool {
360        self.confidence_score() >= FIX_CONFIDENCE_HIGH_THRESHOLD
361    }
362
363    /// Confidence tier used for certainty filtering.
364    pub fn confidence_tier(&self) -> FixConfidenceTier {
365        let confidence = self.confidence_score();
366        if confidence >= FIX_CONFIDENCE_HIGH_THRESHOLD {
367            FixConfidenceTier::High
368        } else if confidence >= FIX_CONFIDENCE_MEDIUM_THRESHOLD {
369            FixConfidenceTier::Medium
370        } else {
371            FixConfidenceTier::Low
372        }
373    }
374
375    /// Check if this is an insertion (start == end)
376    pub fn is_insertion(&self) -> bool {
377        self.start_byte == self.end_byte && !self.replacement.is_empty()
378    }
379
380    /// Check if this is a deletion (empty replacement)
381    pub fn is_deletion(&self) -> bool {
382        self.replacement.is_empty() && self.start_byte < self.end_byte
383    }
384}
385
386impl PartialEq for Fix {
387    fn eq(&self, other: &Self) -> bool {
388        self.start_byte == other.start_byte
389            && self.end_byte == other.end_byte
390            && self.replacement == other.replacement
391            && self.description == other.description
392            && self.safe == other.safe
393            && confidence_option_eq(self.confidence, other.confidence)
394            && self.group == other.group
395            && self.depends_on == other.depends_on
396    }
397}
398
399impl Eq for Fix {}
400
401fn clamp_confidence(confidence: f32) -> f32 {
402    confidence.clamp(0.0, 1.0)
403}
404
405fn confidence_option_eq(a: Option<f32>, b: Option<f32>) -> bool {
406    match (a, b) {
407        (Some(left), Some(right)) => left.to_bits() == right.to_bits(),
408        (None, None) => true,
409        _ => false,
410    }
411}
412
413/// Structured metadata about the rule that triggered a diagnostic.
414///
415/// Populated automatically from `agnix-rules` build-time data when using
416/// the `Diagnostic::error()`, `warning()`, or `info()` constructors, or
417/// manually via `Diagnostic::with_metadata()`.
418#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
419pub struct RuleMetadata {
420    /// Rule category (e.g., "agent-skills", "claude-code-hooks").
421    pub category: String,
422    /// Rule severity from the rules catalog (e.g., "HIGH", "MEDIUM", "LOW").
423    pub severity: String,
424    /// Tool this rule specifically applies to (e.g., "claude-code", "cursor").
425    /// `None` for generic rules that apply to all tools.
426    #[serde(default, skip_serializing_if = "Option::is_none")]
427    pub applies_to_tool: Option<String>,
428}
429
430/// A diagnostic message from the linter
431#[derive(Debug, Clone, Serialize, Deserialize)]
432pub struct Diagnostic {
433    pub level: DiagnosticLevel,
434    pub message: String,
435    pub file: PathBuf,
436    pub line: usize,
437    pub column: usize,
438    pub rule: String,
439    pub suggestion: Option<String>,
440    /// Automatic fixes for this diagnostic
441    #[serde(default)]
442    pub fixes: Vec<Fix>,
443    /// Assumption note for version-aware validation
444    ///
445    /// When tool/spec versions are not pinned, validators may use default
446    /// assumptions. This field documents those assumptions to help users
447    /// understand what behavior is expected and how to get version-specific
448    /// validation.
449    #[serde(default, skip_serializing_if = "Option::is_none")]
450    pub assumption: Option<String>,
451    /// Structured metadata about the rule (category, severity, tool).
452    ///
453    /// Auto-populated from `agnix-rules` at construction time when using the
454    /// `error()`, `warning()`, or `info()` constructors. Can also be set
455    /// manually via `with_metadata()`.
456    #[serde(default, skip_serializing_if = "Option::is_none")]
457    pub metadata: Option<RuleMetadata>,
458}
459
460#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
461pub enum DiagnosticLevel {
462    Error,
463    Warning,
464    Info,
465}
466
467/// Build a `RuleMetadata` from the compile-time rules catalog.
468fn lookup_rule_metadata(rule_id: &str) -> Option<RuleMetadata> {
469    agnix_rules::get_rule_metadata(rule_id).map(|(category, severity, tool)| RuleMetadata {
470        category: category.to_string(),
471        severity: severity.to_string(),
472        applies_to_tool: (!tool.is_empty()).then_some(tool.to_string()),
473    })
474}
475
476impl Diagnostic {
477    pub fn error(
478        file: PathBuf,
479        line: usize,
480        column: usize,
481        rule: &str,
482        message: impl Into<String>,
483    ) -> Self {
484        let metadata = lookup_rule_metadata(rule);
485        Self {
486            level: DiagnosticLevel::Error,
487            message: message.into(),
488            file,
489            line,
490            column,
491            rule: rule.to_string(),
492            suggestion: None,
493            fixes: Vec::new(),
494            assumption: None,
495            metadata,
496        }
497    }
498
499    pub fn warning(
500        file: PathBuf,
501        line: usize,
502        column: usize,
503        rule: &str,
504        message: impl Into<String>,
505    ) -> Self {
506        let metadata = lookup_rule_metadata(rule);
507        Self {
508            level: DiagnosticLevel::Warning,
509            message: message.into(),
510            file,
511            line,
512            column,
513            rule: rule.to_string(),
514            suggestion: None,
515            fixes: Vec::new(),
516            assumption: None,
517            metadata,
518        }
519    }
520
521    pub fn info(
522        file: PathBuf,
523        line: usize,
524        column: usize,
525        rule: &str,
526        message: impl Into<String>,
527    ) -> Self {
528        let metadata = lookup_rule_metadata(rule);
529        Self {
530            level: DiagnosticLevel::Info,
531            message: message.into(),
532            file,
533            line,
534            column,
535            rule: rule.to_string(),
536            suggestion: None,
537            fixes: Vec::new(),
538            assumption: None,
539            metadata,
540        }
541    }
542
543    pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
544        self.suggestion = Some(suggestion.into());
545        self
546    }
547
548    /// Add an assumption note for version-aware validation
549    ///
550    /// Used when tool/spec versions are not pinned to document what
551    /// default behavior the validator is assuming.
552    pub fn with_assumption(mut self, assumption: impl Into<String>) -> Self {
553        self.assumption = Some(assumption.into());
554        self
555    }
556
557    /// Add an automatic fix to this diagnostic
558    pub fn with_fix(mut self, fix: Fix) -> Self {
559        self.fixes.push(fix);
560        self
561    }
562
563    /// Add multiple automatic fixes to this diagnostic
564    pub fn with_fixes(mut self, fixes: impl IntoIterator<Item = Fix>) -> Self {
565        self.fixes.extend(fixes);
566        self
567    }
568
569    /// Set structured rule metadata on this diagnostic
570    pub fn with_metadata(mut self, metadata: RuleMetadata) -> Self {
571        self.metadata = Some(metadata);
572        self
573    }
574
575    /// Check if this diagnostic has any fixes available
576    pub fn has_fixes(&self) -> bool {
577        !self.fixes.is_empty()
578    }
579
580    /// Check if this diagnostic has any safe fixes available
581    pub fn has_safe_fixes(&self) -> bool {
582        self.fixes.iter().any(Fix::is_safe)
583    }
584}
585
586/// File operation errors
587#[derive(Error, Debug)]
588pub enum FileError {
589    #[error("Failed to read file: {path}")]
590    Read {
591        path: PathBuf,
592        #[source]
593        source: std::io::Error,
594    },
595
596    #[error("Failed to write file: {path}")]
597    Write {
598        path: PathBuf,
599        #[source]
600        source: std::io::Error,
601    },
602
603    #[error("Refusing to read symlink: {path}")]
604    Symlink { path: PathBuf },
605
606    #[error("File too large: {path} ({size} bytes, limit {limit} bytes)")]
607    TooBig {
608        path: PathBuf,
609        size: u64,
610        limit: u64,
611    },
612
613    #[error("Not a regular file: {path}")]
614    NotRegular { path: PathBuf },
615}
616
617/// Validation errors
618#[derive(Error, Debug)]
619pub enum ValidationError {
620    #[error("Too many files to validate: {count} files found, limit is {limit}")]
621    TooManyFiles { count: usize, limit: usize },
622
623    #[error("Validation root not found: {path}")]
624    RootNotFound { path: PathBuf },
625
626    #[error(transparent)]
627    Other(#[from] anyhow::Error),
628}
629
630/// Configuration errors
631#[derive(Error, Debug)]
632pub enum ConfigError {
633    #[error("Invalid exclude pattern: {pattern} ({message})")]
634    InvalidExcludePattern { pattern: String, message: String },
635
636    #[error("Failed to parse configuration")]
637    ParseError(#[from] anyhow::Error),
638}
639
640/// Core error type hierarchy
641#[derive(Error, Debug)]
642pub enum CoreError {
643    #[error(transparent)]
644    File(#[from] FileError),
645
646    #[error(transparent)]
647    Validation(#[from] ValidationError),
648
649    #[error(transparent)]
650    Config(#[from] ConfigError),
651}
652
653impl CoreError {
654    /// Extract file-level errors from this error.
655    ///
656    /// Returns a vector containing the FileError if this is a File variant,
657    /// or an empty vector for other error types.
658    pub fn source_diagnostics(&self) -> Vec<&FileError> {
659        match self {
660            CoreError::File(e) => vec![e],
661            _ => vec![],
662        }
663    }
664
665    /// Get the path associated with this error, if any.
666    pub fn path(&self) -> Option<&PathBuf> {
667        match self {
668            CoreError::File(FileError::Read { path, .. })
669            | CoreError::File(FileError::Write { path, .. })
670            | CoreError::File(FileError::Symlink { path })
671            | CoreError::File(FileError::TooBig { path, .. })
672            | CoreError::File(FileError::NotRegular { path }) => Some(path),
673            CoreError::Validation(ValidationError::RootNotFound { path }) => Some(path),
674            _ => None,
675        }
676    }
677}
678
679/// `LintError` is the canonical public name for [`CoreError`].
680///
681/// Both names are re-exported at the crate root. Internal code constructs
682/// variants via `CoreError`; public API surfaces and function signatures
683/// use `LintError` and [`LintResult`].
684pub type LintError = CoreError;
685
686/// Outcome of validating a single file.
687///
688/// Returned by [`crate::validate_file`] and [`crate::validate_file_with_registry`].
689/// The `Ok` path of `LintResult<ValidationOutcome>` covers all per-file
690/// situations - successful validation, I/O errors, and skipped files - while
691/// the `Err` path is reserved for config-level errors (e.g. invalid exclude
692/// patterns, too many files).
693///
694/// Use [`into_diagnostics()`](ValidationOutcome::into_diagnostics) for a
695/// quick migration path from the previous `Vec<Diagnostic>` return type.
696#[derive(Debug)]
697#[non_exhaustive]
698pub enum ValidationOutcome {
699    /// Validation ran successfully. The contained diagnostics may be empty
700    /// (no issues found) or non-empty (issues found).
701    Success(Vec<Diagnostic>),
702
703    /// The file could not be read due to an I/O or filesystem error.
704    #[cfg(feature = "filesystem")]
705    IoError(FileError),
706
707    /// The file type is unknown and no validation was performed.
708    Skipped,
709}
710
711impl ValidationOutcome {
712    /// Returns `true` if validation ran (regardless of whether diagnostics were found).
713    pub fn is_success(&self) -> bool {
714        matches!(self, ValidationOutcome::Success(_))
715    }
716
717    /// Returns `true` if the file could not be read.
718    #[cfg(feature = "filesystem")]
719    pub fn is_io_error(&self) -> bool {
720        matches!(self, ValidationOutcome::IoError(_))
721    }
722
723    /// Returns `true` if the file was skipped (unknown file type).
724    pub fn is_skipped(&self) -> bool {
725        matches!(self, ValidationOutcome::Skipped)
726    }
727
728    /// Borrow the diagnostics from a successful validation.
729    ///
730    /// Returns an empty slice for `IoError` and `Skipped` variants.
731    pub fn diagnostics(&self) -> &[Diagnostic] {
732        match self {
733            ValidationOutcome::Success(diags) => diags,
734            #[cfg(feature = "filesystem")]
735            ValidationOutcome::IoError(_) => &[],
736            ValidationOutcome::Skipped => &[],
737        }
738    }
739
740    /// Consume the outcome and return the diagnostics.
741    ///
742    /// - `Success`: returns the contained diagnostics.
743    /// - `IoError`: returns a single `file::read` diagnostic describing the error.
744    /// - `Skipped`: returns an empty `Vec`.
745    pub fn into_diagnostics(self) -> Vec<Diagnostic> {
746        match self {
747            ValidationOutcome::Success(diags) => diags,
748            #[cfg(feature = "filesystem")]
749            ValidationOutcome::IoError(file_error) => {
750                let error_msg = file_error.to_string();
751                let path = match file_error {
752                    FileError::Read { path, .. }
753                    | FileError::Write { path, .. }
754                    | FileError::Symlink { path }
755                    | FileError::TooBig { path, .. }
756                    | FileError::NotRegular { path } => path,
757                };
758                vec![
759                    Diagnostic::error(
760                        path,
761                        0,
762                        0,
763                        "file::read",
764                        t!("rules.file_read_error", error = error_msg),
765                    )
766                    .with_suggestion(t!("rules.file_read_error_suggestion")),
767                ]
768            }
769            ValidationOutcome::Skipped => vec![],
770        }
771    }
772
773    /// If this is an `IoError`, return a reference to the underlying [`FileError`].
774    #[cfg(feature = "filesystem")]
775    pub fn io_error(&self) -> Option<&FileError> {
776        match self {
777            ValidationOutcome::IoError(e) => Some(e),
778            _ => None,
779        }
780    }
781}
782
783#[cfg(test)]
784mod tests {
785    use super::*;
786
787    // ===== Auto-populate metadata tests =====
788
789    #[test]
790    fn test_error_auto_populates_metadata_for_known_rule() {
791        let diag = Diagnostic::error(PathBuf::from("test.md"), 1, 1, "AS-001", "Test");
792        assert!(
793            diag.metadata.is_some(),
794            "Metadata should be auto-populated for known rule AS-001"
795        );
796        let meta = diag.metadata.unwrap();
797        assert_eq!(meta.category, "agent-skills");
798        assert_eq!(meta.severity, "HIGH");
799        assert!(
800            meta.applies_to_tool.is_none(),
801            "AS-001 is generic, should have no tool"
802        );
803    }
804
805    #[test]
806    fn test_warning_auto_populates_metadata() {
807        let diag = Diagnostic::warning(PathBuf::from("test.md"), 1, 1, "CC-HK-001", "Test");
808        assert!(diag.metadata.is_some());
809        let meta = diag.metadata.unwrap();
810        assert_eq!(meta.applies_to_tool, Some("claude-code".to_string()));
811    }
812
813    #[test]
814    fn test_info_auto_populates_metadata() {
815        let diag = Diagnostic::info(PathBuf::from("test.md"), 1, 1, "AS-001", "Test");
816        assert!(diag.metadata.is_some());
817    }
818
819    #[test]
820    fn test_unknown_rule_has_no_metadata() {
821        let diag = Diagnostic::error(PathBuf::from("test.md"), 1, 1, "UNKNOWN-999", "Test");
822        assert!(
823            diag.metadata.is_none(),
824            "Unknown rules should not have metadata"
825        );
826    }
827
828    #[test]
829    fn test_lookup_rule_metadata_empty_string() {
830        let meta = lookup_rule_metadata("");
831        assert!(meta.is_none(), "Empty string should return None");
832    }
833
834    #[test]
835    fn test_lookup_rule_metadata_special_characters() {
836        let meta = lookup_rule_metadata("@#$%^&*()");
837        assert!(
838            meta.is_none(),
839            "Rule ID with special characters should return None"
840        );
841    }
842
843    // ===== Builder method tests =====
844
845    #[test]
846    fn test_with_metadata_builder() {
847        let meta = RuleMetadata {
848            category: "custom".to_string(),
849            severity: "LOW".to_string(),
850            applies_to_tool: Some("my-tool".to_string()),
851        };
852        let diag = Diagnostic::error(PathBuf::from("test.md"), 1, 1, "UNKNOWN-999", "Test")
853            .with_metadata(meta.clone());
854        assert_eq!(diag.metadata, Some(meta));
855    }
856
857    #[test]
858    fn test_with_metadata_overrides_auto_populated() {
859        let diag = Diagnostic::error(PathBuf::from("test.md"), 1, 1, "AS-001", "Test");
860        assert!(diag.metadata.is_some());
861
862        let custom_meta = RuleMetadata {
863            category: "custom".to_string(),
864            severity: "LOW".to_string(),
865            applies_to_tool: None,
866        };
867        let diag = diag.with_metadata(custom_meta.clone());
868        assert_eq!(diag.metadata, Some(custom_meta));
869    }
870
871    // ===== Serde roundtrip tests =====
872
873    #[test]
874    fn test_rule_metadata_serde_roundtrip() {
875        let meta = RuleMetadata {
876            category: "agent-skills".to_string(),
877            severity: "HIGH".to_string(),
878            applies_to_tool: Some("claude-code".to_string()),
879        };
880        let json = serde_json::to_string(&meta).unwrap();
881        let deserialized: RuleMetadata = serde_json::from_str(&json).unwrap();
882        assert_eq!(meta, deserialized);
883    }
884
885    #[test]
886    fn test_rule_metadata_serde_none_tool_omitted() {
887        let meta = RuleMetadata {
888            category: "agent-skills".to_string(),
889            severity: "HIGH".to_string(),
890            applies_to_tool: None,
891        };
892        let json = serde_json::to_string(&meta).unwrap();
893        assert!(
894            !json.contains("applies_to_tool"),
895            "None tool should be omitted via skip_serializing_if"
896        );
897    }
898
899    #[test]
900    fn test_diagnostic_serde_roundtrip_with_metadata() {
901        let diag = Diagnostic::error(PathBuf::from("test.md"), 10, 5, "AS-001", "Test error");
902        let json = serde_json::to_string(&diag).unwrap();
903        let deserialized: Diagnostic = serde_json::from_str(&json).unwrap();
904        assert_eq!(deserialized.metadata, diag.metadata);
905        assert_eq!(deserialized.rule, "AS-001");
906    }
907
908    #[test]
909    fn test_diagnostic_serde_roundtrip_without_metadata() {
910        let diag = Diagnostic {
911            level: DiagnosticLevel::Error,
912            message: "Test".to_string(),
913            file: PathBuf::from("test.md"),
914            line: 1,
915            column: 1,
916            rule: "UNKNOWN".to_string(),
917            suggestion: None,
918            fixes: Vec::new(),
919            assumption: None,
920            metadata: None,
921        };
922        let json = serde_json::to_string(&diag).unwrap();
923        assert!(
924            !json.contains("metadata"),
925            "None metadata should be omitted"
926        );
927        let deserialized: Diagnostic = serde_json::from_str(&json).unwrap();
928        assert!(deserialized.metadata.is_none());
929    }
930
931    #[test]
932    fn test_diagnostic_deserialize_without_metadata_field() {
933        // Simulate old JSON that doesn't have the metadata field at all
934        let json = r#"{
935            "level": "Error",
936            "message": "Test",
937            "file": "test.md",
938            "line": 1,
939            "column": 1,
940            "rule": "AS-001",
941            "fixes": []
942        }"#;
943        let diag: Diagnostic = serde_json::from_str(json).unwrap();
944        assert!(
945            diag.metadata.is_none(),
946            "Missing metadata field should deserialize as None"
947        );
948    }
949
950    #[test]
951    fn test_diagnostic_manual_metadata_serde_roundtrip() {
952        let manual_metadata = RuleMetadata {
953            category: "custom-category".to_string(),
954            severity: "MEDIUM".to_string(),
955            applies_to_tool: Some("custom-tool".to_string()),
956        };
957
958        let diag = Diagnostic::error(
959            PathBuf::from("test.md"),
960            5,
961            10,
962            "CUSTOM-001",
963            "Custom error",
964        )
965        .with_metadata(manual_metadata.clone());
966
967        // Serialize to JSON
968        let json = serde_json::to_string(&diag).unwrap();
969
970        // Deserialize back
971        let deserialized: Diagnostic = serde_json::from_str(&json).unwrap();
972
973        // Verify metadata is preserved
974        assert_eq!(deserialized.metadata, Some(manual_metadata));
975        assert_eq!(deserialized.rule, "CUSTOM-001");
976        assert_eq!(deserialized.message, "Custom error");
977    }
978
979    // ===== Fix::is_insertion() tests =====
980
981    #[test]
982    fn test_fix_is_insertion_true_when_start_equals_end() {
983        let fix = Fix::insert(10, "inserted text", "insert something", true);
984        assert!(fix.is_insertion());
985    }
986
987    #[test]
988    fn test_fix_is_insertion_false_when_replacement_empty() {
989        // start == end but replacement is empty -> not an insertion
990        let fix = Fix {
991            start_byte: 5,
992            end_byte: 5,
993            replacement: String::new(),
994            description: "no-op".to_string(),
995            safe: true,
996            confidence: Some(1.0),
997            group: None,
998            depends_on: None,
999        };
1000        assert!(!fix.is_insertion());
1001    }
1002
1003    #[test]
1004    fn test_fix_is_insertion_false_when_range_differs() {
1005        let fix = Fix::replace(0, 10, "replacement", "replace", true);
1006        assert!(!fix.is_insertion());
1007    }
1008
1009    #[test]
1010    fn test_fix_is_insertion_at_zero() {
1011        let fix = Fix::insert(0, "prepend", "prepend text", true);
1012        assert!(fix.is_insertion());
1013    }
1014
1015    // ===== Fix::is_deletion() tests =====
1016
1017    #[test]
1018    fn test_fix_is_deletion_true_when_replacement_empty() {
1019        let fix = Fix::delete(5, 15, "remove text", true);
1020        assert!(fix.is_deletion());
1021    }
1022
1023    #[test]
1024    fn test_fix_is_deletion_false_when_replacement_nonempty() {
1025        let fix = Fix::replace(5, 15, "new text", "replace", true);
1026        assert!(!fix.is_deletion());
1027    }
1028
1029    #[test]
1030    fn test_fix_is_deletion_false_when_start_equals_end() {
1031        // Empty range with empty replacement -> not a deletion
1032        let fix = Fix {
1033            start_byte: 5,
1034            end_byte: 5,
1035            replacement: String::new(),
1036            description: "no-op".to_string(),
1037            safe: true,
1038            confidence: Some(1.0),
1039            group: None,
1040            depends_on: None,
1041        };
1042        assert!(!fix.is_deletion());
1043    }
1044
1045    #[test]
1046    fn test_fix_is_deletion_single_byte() {
1047        let fix = Fix::delete(10, 11, "delete one byte", false);
1048        assert!(fix.is_deletion());
1049    }
1050
1051    // ===== Fix constructors =====
1052
1053    #[test]
1054    fn test_fix_replace_fields() {
1055        let fix = Fix::replace(2, 8, "new", "replace old", false);
1056        assert_eq!(fix.start_byte, 2);
1057        assert_eq!(fix.end_byte, 8);
1058        assert_eq!(fix.replacement, "new");
1059        assert_eq!(fix.description, "replace old");
1060        assert!(!fix.safe);
1061        assert_eq!(fix.confidence_tier(), FixConfidenceTier::Medium);
1062        assert!(!fix.is_insertion());
1063        assert!(!fix.is_deletion());
1064    }
1065
1066    #[test]
1067    fn test_fix_insert_fields() {
1068        let fix = Fix::insert(42, "text", "insert", true);
1069        assert_eq!(fix.start_byte, 42);
1070        assert_eq!(fix.end_byte, 42);
1071        assert_eq!(fix.replacement, "text");
1072        assert!(fix.safe);
1073        assert_eq!(fix.confidence_tier(), FixConfidenceTier::High);
1074    }
1075
1076    #[test]
1077    fn test_fix_delete_fields() {
1078        let fix = Fix::delete(0, 100, "remove block", true);
1079        assert_eq!(fix.start_byte, 0);
1080        assert_eq!(fix.end_byte, 100);
1081        assert!(fix.replacement.is_empty());
1082        assert!(fix.safe);
1083        assert_eq!(fix.confidence_tier(), FixConfidenceTier::High);
1084    }
1085
1086    #[test]
1087    fn test_fix_explicit_confidence_fields() {
1088        let fix = Fix::replace_with_confidence(0, 4, "NAME", "normalize", 0.42)
1089            .with_group("name-normalization")
1090            .with_dependency("fix-prefix");
1091
1092        assert_eq!(fix.confidence_score(), 0.42);
1093        assert_eq!(fix.confidence_tier(), FixConfidenceTier::Low);
1094        assert!(!fix.is_safe());
1095        assert_eq!(fix.group.as_deref(), Some("name-normalization"));
1096        assert_eq!(fix.depends_on.as_deref(), Some("fix-prefix"));
1097    }
1098
1099    #[test]
1100    fn test_fix_with_confidence_updates_safe_compat_flag() {
1101        let fix = Fix::replace(0, 4, "NAME", "normalize", true).with_confidence(0.80);
1102        assert!(!fix.safe);
1103        assert_eq!(fix.confidence_tier(), FixConfidenceTier::Medium);
1104    }
1105
1106    // ===== Diagnostic builder methods =====
1107
1108    #[test]
1109    fn test_diagnostic_with_suggestion() {
1110        let diag = Diagnostic::warning(PathBuf::from("test.md"), 1, 0, "AS-001", "test message")
1111            .with_suggestion("try this instead");
1112
1113        assert_eq!(diag.suggestion, Some("try this instead".to_string()));
1114        assert_eq!(diag.level, DiagnosticLevel::Warning);
1115        assert_eq!(diag.message, "test message");
1116    }
1117
1118    #[test]
1119    fn test_diagnostic_with_fix() {
1120        let fix = Fix::insert(0, "added", "add prefix", true);
1121        let diag = Diagnostic::error(PathBuf::from("a.md"), 5, 3, "CC-AG-001", "missing prefix")
1122            .with_fix(fix);
1123
1124        assert!(diag.has_fixes());
1125        assert!(diag.has_safe_fixes());
1126        assert_eq!(diag.fixes.len(), 1);
1127        assert_eq!(diag.fixes[0].replacement, "added");
1128    }
1129
1130    #[test]
1131    fn test_diagnostic_with_fixes_multiple() {
1132        let fixes = vec![
1133            Fix::insert(0, "a", "fix a", true),
1134            Fix::delete(10, 20, "fix b", false),
1135        ];
1136        let diag =
1137            Diagnostic::info(PathBuf::from("b.md"), 1, 0, "XML-001", "xml issue").with_fixes(fixes);
1138
1139        assert_eq!(diag.fixes.len(), 2);
1140        assert!(diag.has_fixes());
1141        // One safe, one unsafe
1142        assert!(diag.has_safe_fixes());
1143    }
1144
1145    #[test]
1146    fn test_diagnostic_with_assumption() {
1147        let diag = Diagnostic::warning(PathBuf::from("c.md"), 2, 0, "CC-HK-001", "hook issue")
1148            .with_assumption("Assuming Claude Code >= 1.0.0");
1149
1150        assert_eq!(
1151            diag.assumption,
1152            Some("Assuming Claude Code >= 1.0.0".to_string())
1153        );
1154    }
1155
1156    #[test]
1157    fn test_diagnostic_builder_chaining() {
1158        let diag = Diagnostic::error(PathBuf::from("d.md"), 10, 5, "MCP-001", "mcp error")
1159            .with_suggestion("fix it")
1160            .with_fix(Fix::replace(0, 5, "fixed", "auto fix", true))
1161            .with_assumption("Assuming MCP protocol 2025-11-25");
1162
1163        assert_eq!(diag.suggestion, Some("fix it".to_string()));
1164        assert_eq!(diag.fixes.len(), 1);
1165        assert!(diag.assumption.is_some());
1166        assert_eq!(diag.level, DiagnosticLevel::Error);
1167        assert_eq!(diag.rule, "MCP-001");
1168    }
1169
1170    #[test]
1171    fn test_diagnostic_no_fixes_by_default() {
1172        let diag = Diagnostic::warning(PathBuf::from("e.md"), 1, 0, "AS-005", "something wrong");
1173
1174        assert!(!diag.has_fixes());
1175        assert!(!diag.has_safe_fixes());
1176        assert!(diag.fixes.is_empty());
1177        assert!(diag.suggestion.is_none());
1178        assert!(diag.assumption.is_none());
1179    }
1180
1181    #[test]
1182    fn test_diagnostic_has_safe_fixes_false_when_all_unsafe() {
1183        let fixes = vec![
1184            Fix::delete(0, 5, "remove a", false),
1185            Fix::delete(10, 15, "remove b", false),
1186        ];
1187        let diag = Diagnostic::error(PathBuf::from("f.md"), 1, 0, "CC-AG-002", "agent error")
1188            .with_fixes(fixes);
1189
1190        assert!(diag.has_fixes());
1191        assert!(!diag.has_safe_fixes());
1192    }
1193
1194    // ===== Diagnostic level constructors =====
1195
1196    #[test]
1197    fn test_diagnostic_error_level() {
1198        let diag = Diagnostic::error(PathBuf::from("x.md"), 1, 0, "R-001", "err");
1199        assert_eq!(diag.level, DiagnosticLevel::Error);
1200    }
1201
1202    #[test]
1203    fn test_diagnostic_warning_level() {
1204        let diag = Diagnostic::warning(PathBuf::from("x.md"), 1, 0, "R-002", "warn");
1205        assert_eq!(diag.level, DiagnosticLevel::Warning);
1206    }
1207
1208    #[test]
1209    fn test_diagnostic_info_level() {
1210        let diag = Diagnostic::info(PathBuf::from("x.md"), 1, 0, "R-003", "info");
1211        assert_eq!(diag.level, DiagnosticLevel::Info);
1212    }
1213
1214    // ===== Serialization roundtrip =====
1215
1216    #[test]
1217    fn test_diagnostic_serialization_roundtrip() {
1218        let original = Diagnostic::error(
1219            PathBuf::from("project/CLAUDE.md"),
1220            42,
1221            7,
1222            "CC-AG-003",
1223            "Agent configuration issue",
1224        )
1225        .with_suggestion("Add the required field")
1226        .with_fix(Fix::insert(100, "new_field: true\n", "add field", true))
1227        .with_fix(Fix::delete(200, 250, "remove deprecated", false))
1228        .with_assumption("Assuming Claude Code >= 1.0.0");
1229
1230        let json = serde_json::to_string(&original).expect("serialization should succeed");
1231        let deserialized: Diagnostic =
1232            serde_json::from_str(&json).expect("deserialization should succeed");
1233
1234        assert_eq!(deserialized.level, original.level);
1235        assert_eq!(deserialized.message, original.message);
1236        assert_eq!(deserialized.file, original.file);
1237        assert_eq!(deserialized.line, original.line);
1238        assert_eq!(deserialized.column, original.column);
1239        assert_eq!(deserialized.rule, original.rule);
1240        assert_eq!(deserialized.suggestion, original.suggestion);
1241        assert_eq!(deserialized.assumption, original.assumption);
1242        assert_eq!(deserialized.fixes.len(), 2);
1243        assert_eq!(deserialized.fixes[0].replacement, "new_field: true\n");
1244        assert!(deserialized.fixes[0].safe);
1245        assert!(deserialized.fixes[1].replacement.is_empty());
1246        assert!(!deserialized.fixes[1].safe);
1247    }
1248
1249    #[test]
1250    fn test_fix_serialization_roundtrip() {
1251        let original = Fix::replace(10, 20, "replaced", "test fix", true);
1252        let json = serde_json::to_string(&original).expect("serialization should succeed");
1253        let deserialized: Fix =
1254            serde_json::from_str(&json).expect("deserialization should succeed");
1255
1256        assert_eq!(deserialized.start_byte, original.start_byte);
1257        assert_eq!(deserialized.end_byte, original.end_byte);
1258        assert_eq!(deserialized.replacement, original.replacement);
1259        assert_eq!(deserialized.description, original.description);
1260        assert_eq!(deserialized.safe, original.safe);
1261    }
1262
1263    #[test]
1264    fn test_diagnostic_without_optional_fields_roundtrip() {
1265        let original =
1266            Diagnostic::info(PathBuf::from("simple.md"), 1, 0, "AS-001", "simple message");
1267
1268        let json = serde_json::to_string(&original).expect("serialization should succeed");
1269        let deserialized: Diagnostic =
1270            serde_json::from_str(&json).expect("deserialization should succeed");
1271
1272        assert_eq!(deserialized.suggestion, None);
1273        assert_eq!(deserialized.assumption, None);
1274        assert!(deserialized.fixes.is_empty());
1275    }
1276
1277    // ===== DiagnosticLevel ordering =====
1278
1279    #[test]
1280    fn test_diagnostic_level_ordering() {
1281        assert!(DiagnosticLevel::Error < DiagnosticLevel::Warning);
1282        assert!(DiagnosticLevel::Warning < DiagnosticLevel::Info);
1283        assert!(DiagnosticLevel::Error < DiagnosticLevel::Info);
1284    }
1285
1286    // Fix debug_assert! reversed-range tests
1287
1288    #[cfg(debug_assertions)]
1289    mod fix_debug_assert_tests {
1290        use super::*;
1291        use std::panic;
1292
1293        #[test]
1294        fn test_fix_replace_reversed_range_panics() {
1295            assert!(panic::catch_unwind(|| Fix::replace(10, 5, "x", "bad", true)).is_err());
1296        }
1297
1298        #[test]
1299        fn test_fix_replace_with_confidence_reversed_range_panics() {
1300            assert!(
1301                panic::catch_unwind(|| Fix::replace_with_confidence(10, 5, "x", "bad", 0.9))
1302                    .is_err()
1303            );
1304        }
1305
1306        #[test]
1307        fn test_fix_delete_reversed_range_panics() {
1308            assert!(panic::catch_unwind(|| Fix::delete(20, 10, "bad", true)).is_err());
1309        }
1310
1311        #[test]
1312        fn test_fix_delete_with_confidence_reversed_range_panics() {
1313            assert!(
1314                panic::catch_unwind(|| Fix::delete_with_confidence(20, 10, "bad", 0.9)).is_err()
1315            );
1316        }
1317
1318        #[test]
1319        fn test_fix_replace_equal_start_end_ok() {
1320            // start == end is a valid zero-width replacement
1321            let fix = Fix::replace(5, 5, "x", "ok", true);
1322            assert_eq!(fix.start_byte, 5);
1323            assert_eq!(fix.end_byte, 5);
1324        }
1325    }
1326
1327    // Fix _checked constructor tests
1328
1329    mod fix_checked_tests {
1330        use super::*;
1331
1332        // "hel\u{00e9}lo" = 7 bytes: h(0) e(1) l(2) e-acute(3,4) l(5) o(6)
1333        // Byte 4 is mid-codepoint (inside the 2-byte e-acute)
1334        const CONTENT_2BYTE: &str = "hel\u{00e9}lo";
1335
1336        #[test]
1337        fn test_fix_replace_checked_valid_boundaries() {
1338            let fix = Fix::replace_checked(CONTENT_2BYTE, 0, 5, "x", "ok", true);
1339            assert_eq!(fix.start_byte, 0);
1340            assert_eq!(fix.end_byte, 5);
1341        }
1342
1343        #[test]
1344        fn test_fix_insert_checked_valid_boundary() {
1345            // byte 3 is start of e-acute, which is a valid char boundary
1346            let fix = Fix::insert_checked(CONTENT_2BYTE, 3, "x", "ok", true);
1347            assert_eq!(fix.start_byte, 3);
1348        }
1349
1350        #[test]
1351        fn test_fix_checked_at_content_end() {
1352            let fix = Fix::insert_checked(CONTENT_2BYTE, CONTENT_2BYTE.len(), "x", "ok", true);
1353            assert_eq!(fix.start_byte, CONTENT_2BYTE.len());
1354        }
1355
1356        #[test]
1357        fn test_fix_replace_with_confidence_checked_valid() {
1358            let fix = Fix::replace_with_confidence_checked(CONTENT_2BYTE, 0, 3, "x", "ok", 0.9);
1359            assert_eq!(fix.start_byte, 0);
1360            assert_eq!(fix.end_byte, 3);
1361            assert!((fix.confidence_score() - 0.9).abs() < 1e-6);
1362        }
1363
1364        #[test]
1365        fn test_fix_delete_checked_valid() {
1366            let fix = Fix::delete_checked(CONTENT_2BYTE, 0, 3, "ok", true);
1367            assert_eq!(fix.start_byte, 0);
1368            assert_eq!(fix.end_byte, 3);
1369        }
1370
1371        #[test]
1372        fn test_fix_insert_with_confidence_checked_valid() {
1373            // byte 3 is start of e-acute, a valid char boundary
1374            let fix = Fix::insert_with_confidence_checked(CONTENT_2BYTE, 3, "x", "ok", 0.9);
1375            assert_eq!(fix.start_byte, 3);
1376            assert_eq!(fix.end_byte, 3);
1377            assert!((fix.confidence_score() - 0.9).abs() < 1e-6);
1378        }
1379
1380        #[test]
1381        fn test_fix_delete_with_confidence_checked_valid() {
1382            let fix = Fix::delete_with_confidence_checked(CONTENT_2BYTE, 0, 3, "ok", 0.9);
1383            assert_eq!(fix.start_byte, 0);
1384            assert_eq!(fix.end_byte, 3);
1385            assert!((fix.confidence_score() - 0.9).abs() < 1e-6);
1386        }
1387
1388        // 4-byte emoji: "a\u{1f600}b" = 6 bytes: a(0) grinning-face(1,2,3,4) b(5)
1389        // Valid range: 1..5 covers whole emoji. Invalid: 1..3 (mid-emoji).
1390        const CONTENT_4BYTE: &str = "a\u{1f600}b";
1391
1392        #[test]
1393        fn test_fix_replace_checked_four_byte_valid() {
1394            // 1..5 covers the full emoji - valid char boundaries
1395            let fix = Fix::replace_checked(CONTENT_4BYTE, 1, 5, "x", "ok", true);
1396            assert_eq!(fix.start_byte, 1);
1397            assert_eq!(fix.end_byte, 5);
1398        }
1399
1400        #[test]
1401        fn test_fix_replace_checked_zero_width_at_valid_boundary() {
1402            // start == end at a valid char boundary is permitted (zero-width replacement)
1403            let fix = Fix::replace_checked(CONTENT_2BYTE, 3, 3, "x", "ok", true);
1404            assert_eq!(fix.start_byte, 3);
1405            assert_eq!(fix.end_byte, 3);
1406        }
1407
1408        #[test]
1409        fn test_fix_replace_checked_ascii_content() {
1410            // ASCII content: all byte positions are valid char boundaries
1411            let content = "hello";
1412            let fix = Fix::replace_checked(content, 1, 3, "i", "ok", true);
1413            assert_eq!(fix.start_byte, 1);
1414            assert_eq!(fix.end_byte, 3);
1415        }
1416
1417        // These tests exercise debug_assert! paths and only compile when debug assertions are enabled.
1418        #[cfg(debug_assertions)]
1419        mod fix_checked_panic_tests {
1420            use super::*;
1421            use std::panic;
1422
1423            #[test]
1424            fn test_fix_replace_checked_mid_codepoint_start_panics() {
1425                // byte 4 is inside the 2-byte e-acute (bytes 3-4)
1426                assert!(
1427                    panic::catch_unwind(|| {
1428                        Fix::replace_checked(CONTENT_2BYTE, 4, 5, "x", "bad", true)
1429                    })
1430                    .is_err()
1431                );
1432            }
1433
1434            #[test]
1435            fn test_fix_replace_checked_mid_codepoint_end_panics() {
1436                assert!(
1437                    panic::catch_unwind(|| {
1438                        Fix::replace_checked(CONTENT_2BYTE, 0, 4, "x", "bad", true)
1439                    })
1440                    .is_err()
1441                );
1442            }
1443
1444            #[test]
1445            fn test_fix_insert_checked_mid_codepoint_panics() {
1446                assert!(
1447                    panic::catch_unwind(|| {
1448                        Fix::insert_checked(CONTENT_2BYTE, 4, "x", "bad", true)
1449                    })
1450                    .is_err()
1451                );
1452            }
1453
1454            #[test]
1455            fn test_fix_delete_checked_mid_codepoint_panics() {
1456                assert!(
1457                    panic::catch_unwind(|| {
1458                        Fix::delete_checked(CONTENT_2BYTE, 3, 4, "bad", true)
1459                    })
1460                    .is_err()
1461                );
1462            }
1463
1464            #[test]
1465            fn test_fix_replace_with_confidence_checked_mid_codepoint_panics() {
1466                assert!(
1467                    panic::catch_unwind(|| {
1468                        Fix::replace_with_confidence_checked(CONTENT_2BYTE, 4, 5, "x", "bad", 0.9)
1469                    })
1470                    .is_err()
1471                );
1472            }
1473
1474            #[test]
1475            fn test_fix_insert_with_confidence_checked_mid_codepoint_panics() {
1476                assert!(
1477                    panic::catch_unwind(|| {
1478                        Fix::insert_with_confidence_checked(CONTENT_2BYTE, 4, "x", "bad", 0.9)
1479                    })
1480                    .is_err()
1481                );
1482            }
1483
1484            #[test]
1485            fn test_fix_delete_with_confidence_checked_mid_codepoint_panics() {
1486                assert!(
1487                    panic::catch_unwind(|| {
1488                        Fix::delete_with_confidence_checked(CONTENT_2BYTE, 3, 4, "bad", 0.9)
1489                    })
1490                    .is_err()
1491                );
1492            }
1493
1494            #[test]
1495            fn test_fix_delete_checked_mid_codepoint_start_panics() {
1496                // start=4 is the continuation byte of the 2-byte e-acute; only end was covered before
1497                assert!(
1498                    panic::catch_unwind(|| Fix::delete_checked(CONTENT_2BYTE, 4, 5, "bad", true))
1499                        .is_err()
1500                );
1501            }
1502
1503            #[test]
1504            fn test_fix_delete_with_confidence_checked_mid_codepoint_start_panics() {
1505                // start=4 is the continuation byte of the 2-byte e-acute
1506                assert!(
1507                    panic::catch_unwind(|| Fix::delete_with_confidence_checked(
1508                        CONTENT_2BYTE,
1509                        4,
1510                        5,
1511                        "bad",
1512                        0.9
1513                    ))
1514                    .is_err()
1515                );
1516            }
1517
1518            #[test]
1519            fn test_fix_replace_with_confidence_checked_mid_codepoint_end_panics() {
1520                // end byte 4 is inside the 2-byte e-acute (bytes 3-4)
1521                assert!(
1522                    panic::catch_unwind(|| Fix::replace_with_confidence_checked(
1523                        CONTENT_2BYTE,
1524                        0,
1525                        4,
1526                        "x",
1527                        "bad",
1528                        0.9
1529                    ))
1530                    .is_err()
1531                );
1532            }
1533
1534            #[test]
1535            fn test_fix_insert_with_confidence_checked_out_of_bounds_panics() {
1536                assert!(
1537                    panic::catch_unwind(|| Fix::insert_with_confidence_checked(
1538                        CONTENT_2BYTE,
1539                        CONTENT_2BYTE.len() + 1,
1540                        "x",
1541                        "bad",
1542                        0.9
1543                    ))
1544                    .is_err()
1545                );
1546            }
1547
1548            #[test]
1549            fn test_fix_replace_checked_reversed_range_panics() {
1550                // The _checked variants also contain their own start <= end assertion
1551                assert!(
1552                    panic::catch_unwind(|| Fix::replace_checked(
1553                        CONTENT_2BYTE,
1554                        5,
1555                        3,
1556                        "x",
1557                        "bad",
1558                        true
1559                    ))
1560                    .is_err()
1561                );
1562            }
1563
1564            #[test]
1565            fn test_fix_delete_checked_reversed_range_panics() {
1566                assert!(
1567                    panic::catch_unwind(|| Fix::delete_checked(CONTENT_2BYTE, 5, 3, "bad", true))
1568                        .is_err()
1569                );
1570            }
1571
1572            #[test]
1573            fn test_fix_replace_with_confidence_checked_reversed_range_panics() {
1574                assert!(
1575                    panic::catch_unwind(|| Fix::replace_with_confidence_checked(
1576                        CONTENT_2BYTE,
1577                        5,
1578                        3,
1579                        "x",
1580                        "bad",
1581                        0.9
1582                    ))
1583                    .is_err()
1584                );
1585            }
1586
1587            #[test]
1588            fn test_fix_delete_with_confidence_checked_reversed_range_panics() {
1589                assert!(
1590                    panic::catch_unwind(|| Fix::delete_with_confidence_checked(
1591                        CONTENT_2BYTE,
1592                        5,
1593                        3,
1594                        "bad",
1595                        0.9
1596                    ))
1597                    .is_err()
1598                );
1599            }
1600
1601            #[test]
1602            fn test_fix_checked_out_of_bounds_panics() {
1603                assert!(
1604                    panic::catch_unwind(|| {
1605                        Fix::insert_checked(
1606                            CONTENT_2BYTE,
1607                            CONTENT_2BYTE.len() + 1,
1608                            "x",
1609                            "bad",
1610                            true,
1611                        )
1612                    })
1613                    .is_err()
1614                );
1615            }
1616
1617            #[test]
1618            fn test_fix_replace_checked_four_byte_mid_emoji_panics() {
1619                // end byte 3 is mid-codepoint (third byte of the 4-byte emoji); start byte 1 is a valid char boundary
1620                assert!(
1621                    panic::catch_unwind(|| {
1622                        Fix::replace_checked(CONTENT_4BYTE, 1, 3, "x", "bad", true)
1623                    })
1624                    .is_err()
1625                );
1626            }
1627
1628            #[test]
1629            fn test_fix_replace_checked_end_out_of_bounds_panics() {
1630                assert!(
1631                    panic::catch_unwind(|| Fix::replace_checked(
1632                        CONTENT_2BYTE,
1633                        0,
1634                        CONTENT_2BYTE.len() + 1,
1635                        "x",
1636                        "bad",
1637                        true
1638                    ))
1639                    .is_err()
1640                );
1641            }
1642
1643            #[test]
1644            fn test_fix_delete_checked_end_out_of_bounds_panics() {
1645                assert!(
1646                    panic::catch_unwind(|| Fix::delete_checked(
1647                        CONTENT_2BYTE,
1648                        0,
1649                        CONTENT_2BYTE.len() + 1,
1650                        "bad",
1651                        true
1652                    ))
1653                    .is_err()
1654                );
1655            }
1656        }
1657    }
1658
1659    // ===== ValidationOutcome tests =====
1660
1661    #[test]
1662    fn test_validation_outcome_success_empty() {
1663        let outcome = ValidationOutcome::Success(vec![]);
1664        assert!(outcome.is_success());
1665        assert!(!outcome.is_skipped());
1666        assert!(outcome.diagnostics().is_empty());
1667        assert!(outcome.into_diagnostics().is_empty());
1668    }
1669
1670    #[test]
1671    fn test_validation_outcome_success_with_diagnostics() {
1672        let diag = Diagnostic::warning(PathBuf::from("test.md"), 1, 0, "AS-001", "test");
1673        let outcome = ValidationOutcome::Success(vec![diag]);
1674        assert!(outcome.is_success());
1675        assert_eq!(outcome.diagnostics().len(), 1);
1676        assert_eq!(outcome.diagnostics()[0].rule, "AS-001");
1677        let diags = outcome.into_diagnostics();
1678        assert_eq!(diags.len(), 1);
1679        assert_eq!(diags[0].rule, "AS-001");
1680    }
1681
1682    #[test]
1683    fn test_validation_outcome_skipped() {
1684        let outcome = ValidationOutcome::Skipped;
1685        assert!(outcome.is_skipped());
1686        assert!(!outcome.is_success());
1687        assert!(outcome.diagnostics().is_empty());
1688        assert!(outcome.into_diagnostics().is_empty());
1689    }
1690
1691    #[cfg(feature = "filesystem")]
1692    #[test]
1693    fn test_validation_outcome_io_error() {
1694        let file_error = FileError::Read {
1695            path: PathBuf::from("/tmp/missing.md"),
1696            source: std::io::Error::new(std::io::ErrorKind::NotFound, "not found"),
1697        };
1698        let outcome = ValidationOutcome::IoError(file_error);
1699        assert!(outcome.is_io_error());
1700        assert!(!outcome.is_success());
1701        assert!(!outcome.is_skipped());
1702        // diagnostics() returns empty slice for IoError
1703        assert!(outcome.diagnostics().is_empty());
1704    }
1705
1706    #[cfg(feature = "filesystem")]
1707    #[test]
1708    fn test_validation_outcome_io_error_ref() {
1709        let file_error = FileError::Read {
1710            path: PathBuf::from("/tmp/missing.md"),
1711            source: std::io::Error::new(std::io::ErrorKind::NotFound, "not found"),
1712        };
1713        let outcome = ValidationOutcome::IoError(file_error);
1714        let err = outcome.io_error().expect("should be Some for IoError");
1715        match err {
1716            FileError::Read { path, .. } => {
1717                assert_eq!(path, &PathBuf::from("/tmp/missing.md"));
1718            }
1719            _ => panic!("expected FileError::Read"),
1720        }
1721    }
1722
1723    #[cfg(feature = "filesystem")]
1724    #[test]
1725    fn test_validation_outcome_io_error_into_diagnostics() {
1726        let file_error = FileError::Read {
1727            path: PathBuf::from("/tmp/missing.md"),
1728            source: std::io::Error::new(std::io::ErrorKind::NotFound, "not found"),
1729        };
1730        let outcome = ValidationOutcome::IoError(file_error);
1731        let diags = outcome.into_diagnostics();
1732        assert_eq!(diags.len(), 1);
1733        assert_eq!(diags[0].rule, "file::read");
1734        assert_eq!(diags[0].file, PathBuf::from("/tmp/missing.md"));
1735        assert_eq!(diags[0].level, DiagnosticLevel::Error);
1736        // Message uses i18n ("rules.file_read_error"); verify it is non-empty and
1737        // the suggestion is also populated.
1738        assert!(!diags[0].message.is_empty());
1739        assert!(diags[0].suggestion.is_some());
1740    }
1741
1742    #[cfg(feature = "filesystem")]
1743    #[test]
1744    fn test_validation_outcome_io_error_symlink() {
1745        let file_error = FileError::Symlink {
1746            path: PathBuf::from("/tmp/link.md"),
1747        };
1748        let outcome = ValidationOutcome::IoError(file_error);
1749        let diags = outcome.into_diagnostics();
1750        assert_eq!(diags.len(), 1);
1751        assert_eq!(diags[0].rule, "file::read");
1752        assert_eq!(diags[0].level, DiagnosticLevel::Error);
1753        assert!(diags[0].suggestion.is_some());
1754    }
1755
1756    #[cfg(feature = "filesystem")]
1757    #[test]
1758    fn test_validation_outcome_io_error_too_big() {
1759        let file_error = FileError::TooBig {
1760            path: PathBuf::from("/tmp/huge.md"),
1761            size: 5_000_000,
1762            limit: 1_048_576,
1763        };
1764        let outcome = ValidationOutcome::IoError(file_error);
1765        let diags = outcome.into_diagnostics();
1766        assert_eq!(diags.len(), 1);
1767        assert_eq!(diags[0].rule, "file::read");
1768        assert_eq!(diags[0].level, DiagnosticLevel::Error);
1769        assert!(diags[0].suggestion.is_some());
1770    }
1771
1772    #[test]
1773    fn test_validation_outcome_success_io_error_ref_is_none() {
1774        let outcome = ValidationOutcome::Success(vec![]);
1775        #[cfg(feature = "filesystem")]
1776        assert!(outcome.io_error().is_none());
1777        let _ = outcome;
1778    }
1779
1780    #[test]
1781    fn test_validation_outcome_skipped_io_error_ref_is_none() {
1782        let outcome = ValidationOutcome::Skipped;
1783        #[cfg(feature = "filesystem")]
1784        assert!(outcome.io_error().is_none());
1785        let _ = outcome;
1786    }
1787
1788    // ===== CoreError::path() tests =====
1789
1790    #[test]
1791    fn test_core_error_path_root_not_found() {
1792        let path = PathBuf::from("/some/nonexistent/path");
1793        let err = CoreError::Validation(ValidationError::RootNotFound { path: path.clone() });
1794        assert_eq!(err.path(), Some(&path));
1795    }
1796}