mdbook_lint_core/
violation.rs

1//! Violation types for mdbook-lint
2//!
3//! This module contains the core types for representing linting violations.
4
5/// A suggested fix for a violation
6#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
7pub struct Fix {
8    /// Description of what the fix does
9    pub description: String,
10    /// The replacement text (None means delete)
11    pub replacement: Option<String>,
12    /// Start position of the text to replace
13    pub start: Position,
14    /// End position of the text to replace  
15    pub end: Position,
16}
17
18/// Position in a document
19#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
20pub struct Position {
21    /// Line number (1-based)
22    pub line: usize,
23    /// Column number (1-based)
24    pub column: usize,
25}
26
27/// A violation found during linting
28#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
29pub struct Violation {
30    /// Rule identifier (e.g., "MD001")
31    pub rule_id: String,
32    /// Human-readable rule name (e.g., "heading-increment")
33    pub rule_name: String,
34    /// Description of the violation
35    pub message: String,
36    /// Line number (1-based)
37    pub line: usize,
38    /// Column number (1-based)
39    pub column: usize,
40    /// Severity level
41    pub severity: Severity,
42    /// Optional fix for this violation
43    pub fix: Option<Fix>,
44}
45
46/// Severity levels for violations
47#[derive(
48    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
49)]
50pub enum Severity {
51    /// Informational message
52    Info,
53    /// Warning that should be addressed
54    Warning,
55    /// Error that must be fixed
56    Error,
57}
58
59impl std::fmt::Display for Severity {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        match self {
62            Severity::Info => write!(f, "info"),
63            Severity::Warning => write!(f, "warning"),
64            Severity::Error => write!(f, "error"),
65        }
66    }
67}
68
69impl std::fmt::Display for Violation {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        write!(
72            f,
73            "{}:{}:{}: {}/{}: {}",
74            self.line, self.column, self.severity, self.rule_id, self.rule_name, self.message
75        )
76    }
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    #[test]
84    fn test_severity_display() {
85        assert_eq!(format!("{}", Severity::Info), "info");
86        assert_eq!(format!("{}", Severity::Warning), "warning");
87        assert_eq!(format!("{}", Severity::Error), "error");
88    }
89
90    #[test]
91    fn test_severity_ordering() {
92        assert!(Severity::Info < Severity::Warning);
93        assert!(Severity::Warning < Severity::Error);
94        assert!(Severity::Info < Severity::Error);
95    }
96
97    #[test]
98    fn test_violation_creation() {
99        let violation = Violation {
100            rule_id: "MD001".to_string(),
101            rule_name: "heading-increment".to_string(),
102            message: "Heading levels should only increment by one level at a time".to_string(),
103            line: 5,
104            column: 1,
105            severity: Severity::Warning,
106            fix: None,
107        };
108
109        assert_eq!(violation.rule_id, "MD001");
110        assert_eq!(violation.rule_name, "heading-increment");
111        assert_eq!(violation.line, 5);
112        assert_eq!(violation.column, 1);
113        assert_eq!(violation.severity, Severity::Warning);
114        assert_eq!(violation.fix, None);
115    }
116
117    #[test]
118    fn test_violation_display() {
119        let violation = Violation {
120            rule_id: "MD013".to_string(),
121            rule_name: "line-length".to_string(),
122            message: "Line too long".to_string(),
123            line: 10,
124            column: 81,
125            severity: Severity::Error,
126            fix: None,
127        };
128
129        let expected = "10:81:error: MD013/line-length: Line too long";
130        assert_eq!(format!("{violation}"), expected);
131    }
132
133    #[test]
134    fn test_violation_equality() {
135        let violation1 = Violation {
136            rule_id: "MD001".to_string(),
137            rule_name: "heading-increment".to_string(),
138            message: "Test message".to_string(),
139            line: 1,
140            column: 1,
141            severity: Severity::Warning,
142            fix: None,
143        };
144
145        let violation2 = Violation {
146            rule_id: "MD001".to_string(),
147            rule_name: "heading-increment".to_string(),
148            message: "Test message".to_string(),
149            line: 1,
150            column: 1,
151            severity: Severity::Warning,
152            fix: None,
153        };
154
155        let violation3 = Violation {
156            rule_id: "MD002".to_string(),
157            rule_name: "first-heading-h1".to_string(),
158            message: "Different message".to_string(),
159            line: 2,
160            column: 1,
161            severity: Severity::Error,
162            fix: None,
163        };
164
165        assert_eq!(violation1, violation2);
166        assert_ne!(violation1, violation3);
167    }
168
169    #[test]
170    fn test_violation_clone() {
171        let original = Violation {
172            rule_id: "MD040".to_string(),
173            rule_name: "fenced-code-language".to_string(),
174            message: "Fenced code blocks should have a language specified".to_string(),
175            line: 15,
176            column: 3,
177            severity: Severity::Info,
178            fix: None,
179        };
180
181        let cloned = original.clone();
182        assert_eq!(original, cloned);
183    }
184
185    #[test]
186    fn test_violation_debug() {
187        let violation = Violation {
188            rule_id: "MD025".to_string(),
189            rule_name: "single-h1".to_string(),
190            message: "Multiple top level headings in the same document".to_string(),
191            line: 20,
192            column: 1,
193            severity: Severity::Warning,
194            fix: None,
195        };
196
197        let debug_str = format!("{violation:?}");
198        assert!(debug_str.contains("MD025"));
199        assert!(debug_str.contains("single-h1"));
200        assert!(debug_str.contains("Multiple top level headings"));
201        assert!(debug_str.contains("line: 20"));
202        assert!(debug_str.contains("column: 1"));
203        assert!(debug_str.contains("Warning"));
204    }
205
206    #[test]
207    fn test_all_severity_variants() {
208        let severities = [Severity::Info, Severity::Warning, Severity::Error];
209
210        for severity in &severities {
211            let violation = Violation {
212                rule_id: "TEST".to_string(),
213                rule_name: "test-rule".to_string(),
214                message: "Test message".to_string(),
215                line: 1,
216                column: 1,
217                severity: *severity,
218                fix: None,
219            };
220
221            // Test that display format includes severity
222            let display_str = format!("{violation}");
223            assert!(display_str.contains(&format!("{severity}")));
224        }
225    }
226
227    #[test]
228    fn test_violation_with_fix() {
229        let fix = Fix {
230            description: "Replace tab with spaces".to_string(),
231            replacement: Some("    ".to_string()),
232            start: Position {
233                line: 5,
234                column: 10,
235            },
236            end: Position {
237                line: 5,
238                column: 11,
239            },
240        };
241
242        let violation = Violation {
243            rule_id: "MD010".to_string(),
244            rule_name: "no-hard-tabs".to_string(),
245            message: "Hard tab found".to_string(),
246            line: 5,
247            column: 10,
248            severity: Severity::Warning,
249            fix: Some(fix.clone()),
250        };
251
252        assert_eq!(violation.fix, Some(fix));
253        assert!(violation.fix.is_some());
254
255        let fix_ref = violation.fix.as_ref().unwrap();
256        assert_eq!(fix_ref.description, "Replace tab with spaces");
257        assert_eq!(fix_ref.replacement, Some("    ".to_string()));
258        assert_eq!(fix_ref.start.line, 5);
259        assert_eq!(fix_ref.start.column, 10);
260        assert_eq!(fix_ref.end.line, 5);
261        assert_eq!(fix_ref.end.column, 11);
262    }
263
264    #[test]
265    fn test_fix_delete_operation() {
266        let fix = Fix {
267            description: "Remove extra newlines".to_string(),
268            replacement: None, // None means delete
269            start: Position {
270                line: 10,
271                column: 1,
272            },
273            end: Position {
274                line: 12,
275                column: 1,
276            },
277        };
278
279        assert_eq!(fix.replacement, None);
280        assert_eq!(fix.description, "Remove extra newlines");
281    }
282}