Skip to main content

agnix_core/
diagnostics.rs

1//! Diagnostic types and error reporting
2
3use serde::{Deserialize, Serialize};
4use std::path::PathBuf;
5use thiserror::Error;
6
7pub type LintResult<T> = Result<T, LintError>;
8
9/// An automatic fix for a diagnostic
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct Fix {
12    /// Byte offset start (inclusive)
13    pub start_byte: usize,
14    /// Byte offset end (exclusive)
15    pub end_byte: usize,
16    /// Text to insert/replace with
17    pub replacement: String,
18    /// Human-readable description of what this fix does
19    pub description: String,
20    /// Whether this fix is safe (HIGH certainty, >95%)
21    pub safe: bool,
22}
23
24impl Fix {
25    /// Create a replacement fix
26    pub fn replace(
27        start: usize,
28        end: usize,
29        replacement: impl Into<String>,
30        description: impl Into<String>,
31        safe: bool,
32    ) -> Self {
33        Self {
34            start_byte: start,
35            end_byte: end,
36            replacement: replacement.into(),
37            description: description.into(),
38            safe,
39        }
40    }
41
42    /// Create an insertion fix (start == end)
43    pub fn insert(
44        position: usize,
45        text: impl Into<String>,
46        description: impl Into<String>,
47        safe: bool,
48    ) -> Self {
49        Self {
50            start_byte: position,
51            end_byte: position,
52            replacement: text.into(),
53            description: description.into(),
54            safe,
55        }
56    }
57
58    /// Create a deletion fix (replacement is empty)
59    pub fn delete(start: usize, end: usize, description: impl Into<String>, safe: bool) -> Self {
60        Self {
61            start_byte: start,
62            end_byte: end,
63            replacement: String::new(),
64            description: description.into(),
65            safe,
66        }
67    }
68
69    /// Check if this is an insertion (start == end)
70    pub fn is_insertion(&self) -> bool {
71        self.start_byte == self.end_byte && !self.replacement.is_empty()
72    }
73
74    /// Check if this is a deletion (empty replacement)
75    pub fn is_deletion(&self) -> bool {
76        self.replacement.is_empty() && self.start_byte < self.end_byte
77    }
78}
79
80/// A diagnostic message from the linter
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct Diagnostic {
83    pub level: DiagnosticLevel,
84    pub message: String,
85    pub file: PathBuf,
86    pub line: usize,
87    pub column: usize,
88    pub rule: String,
89    pub suggestion: Option<String>,
90    /// Automatic fixes for this diagnostic
91    #[serde(default)]
92    pub fixes: Vec<Fix>,
93    /// Assumption note for version-aware validation
94    ///
95    /// When tool/spec versions are not pinned, validators may use default
96    /// assumptions. This field documents those assumptions to help users
97    /// understand what behavior is expected and how to get version-specific
98    /// validation.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub assumption: Option<String>,
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
104pub enum DiagnosticLevel {
105    Error,
106    Warning,
107    Info,
108}
109
110impl Diagnostic {
111    pub fn error(
112        file: PathBuf,
113        line: usize,
114        column: usize,
115        rule: &str,
116        message: impl Into<String>,
117    ) -> Self {
118        Self {
119            level: DiagnosticLevel::Error,
120            message: message.into(),
121            file,
122            line,
123            column,
124            rule: rule.to_string(),
125            suggestion: None,
126            fixes: Vec::new(),
127            assumption: None,
128        }
129    }
130
131    pub fn warning(
132        file: PathBuf,
133        line: usize,
134        column: usize,
135        rule: &str,
136        message: impl Into<String>,
137    ) -> Self {
138        Self {
139            level: DiagnosticLevel::Warning,
140            message: message.into(),
141            file,
142            line,
143            column,
144            rule: rule.to_string(),
145            suggestion: None,
146            fixes: Vec::new(),
147            assumption: None,
148        }
149    }
150
151    pub fn info(
152        file: PathBuf,
153        line: usize,
154        column: usize,
155        rule: &str,
156        message: impl Into<String>,
157    ) -> Self {
158        Self {
159            level: DiagnosticLevel::Info,
160            message: message.into(),
161            file,
162            line,
163            column,
164            rule: rule.to_string(),
165            suggestion: None,
166            fixes: Vec::new(),
167            assumption: None,
168        }
169    }
170
171    pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
172        self.suggestion = Some(suggestion.into());
173        self
174    }
175
176    /// Add an assumption note for version-aware validation
177    ///
178    /// Used when tool/spec versions are not pinned to document what
179    /// default behavior the validator is assuming.
180    pub fn with_assumption(mut self, assumption: impl Into<String>) -> Self {
181        self.assumption = Some(assumption.into());
182        self
183    }
184
185    /// Add an automatic fix to this diagnostic
186    pub fn with_fix(mut self, fix: Fix) -> Self {
187        self.fixes.push(fix);
188        self
189    }
190
191    /// Add multiple automatic fixes to this diagnostic
192    pub fn with_fixes(mut self, fixes: impl IntoIterator<Item = Fix>) -> Self {
193        self.fixes.extend(fixes);
194        self
195    }
196
197    /// Check if this diagnostic has any fixes available
198    pub fn has_fixes(&self) -> bool {
199        !self.fixes.is_empty()
200    }
201
202    /// Check if this diagnostic has any safe fixes available
203    pub fn has_safe_fixes(&self) -> bool {
204        self.fixes.iter().any(|f| f.safe)
205    }
206}
207
208/// Linter errors
209#[derive(Error, Debug)]
210pub enum LintError {
211    #[error("Failed to read file: {path}")]
212    FileRead {
213        path: PathBuf,
214        #[source]
215        source: std::io::Error,
216    },
217
218    #[error("Failed to write file: {path}")]
219    FileWrite {
220        path: PathBuf,
221        #[source]
222        source: std::io::Error,
223    },
224
225    #[error("Refusing to read symlink: {path}")]
226    FileSymlink { path: PathBuf },
227
228    #[error("File too large: {path} ({size} bytes, limit {limit} bytes)")]
229    FileTooBig {
230        path: PathBuf,
231        size: u64,
232        limit: u64,
233    },
234
235    #[error("Not a regular file: {path}")]
236    FileNotRegular { path: PathBuf },
237
238    #[error("Invalid exclude pattern: {pattern} ({message})")]
239    InvalidExcludePattern { pattern: String, message: String },
240
241    #[error("Too many files to validate: {count} files found, limit is {limit}")]
242    TooManyFiles { count: usize, limit: usize },
243
244    #[error(transparent)]
245    Other(anyhow::Error),
246}