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(file: PathBuf, line: usize, column: usize, rule: &str, message: String) -> Self {
112        Self {
113            level: DiagnosticLevel::Error,
114            message,
115            file,
116            line,
117            column,
118            rule: rule.to_string(),
119            suggestion: None,
120            fixes: Vec::new(),
121            assumption: None,
122        }
123    }
124
125    pub fn warning(file: PathBuf, line: usize, column: usize, rule: &str, message: String) -> Self {
126        Self {
127            level: DiagnosticLevel::Warning,
128            message,
129            file,
130            line,
131            column,
132            rule: rule.to_string(),
133            suggestion: None,
134            fixes: Vec::new(),
135            assumption: None,
136        }
137    }
138
139    pub fn info(file: PathBuf, line: usize, column: usize, rule: &str, message: String) -> Self {
140        Self {
141            level: DiagnosticLevel::Info,
142            message,
143            file,
144            line,
145            column,
146            rule: rule.to_string(),
147            suggestion: None,
148            fixes: Vec::new(),
149            assumption: None,
150        }
151    }
152
153    pub fn with_suggestion(mut self, suggestion: String) -> Self {
154        self.suggestion = Some(suggestion);
155        self
156    }
157
158    /// Add an assumption note for version-aware validation
159    ///
160    /// Used when tool/spec versions are not pinned to document what
161    /// default behavior the validator is assuming.
162    pub fn with_assumption(mut self, assumption: impl Into<String>) -> Self {
163        self.assumption = Some(assumption.into());
164        self
165    }
166
167    /// Add an automatic fix to this diagnostic
168    pub fn with_fix(mut self, fix: Fix) -> Self {
169        self.fixes.push(fix);
170        self
171    }
172
173    /// Add multiple automatic fixes to this diagnostic
174    pub fn with_fixes(mut self, fixes: impl IntoIterator<Item = Fix>) -> Self {
175        self.fixes.extend(fixes);
176        self
177    }
178
179    /// Check if this diagnostic has any fixes available
180    pub fn has_fixes(&self) -> bool {
181        !self.fixes.is_empty()
182    }
183
184    /// Check if this diagnostic has any safe fixes available
185    pub fn has_safe_fixes(&self) -> bool {
186        self.fixes.iter().any(|f| f.safe)
187    }
188}
189
190/// Linter errors
191#[derive(Error, Debug)]
192pub enum LintError {
193    #[error("Failed to read file: {path}")]
194    FileRead {
195        path: PathBuf,
196        #[source]
197        source: std::io::Error,
198    },
199
200    #[error("Failed to write file: {path}")]
201    FileWrite {
202        path: PathBuf,
203        #[source]
204        source: std::io::Error,
205    },
206
207    #[error("Refusing to read symlink: {path}")]
208    FileSymlink { path: PathBuf },
209
210    #[error("File too large: {path} ({size} bytes, limit {limit} bytes)")]
211    FileTooBig {
212        path: PathBuf,
213        size: u64,
214        limit: u64,
215    },
216
217    #[error("Not a regular file: {path}")]
218    FileNotRegular { path: PathBuf },
219
220    #[error(transparent)]
221    Other(anyhow::Error),
222}