syncable_cli/analyzer/hadolint/
types.rs

1//! Core types for the hadolint-rs linter.
2//!
3//! These types match the Haskell hadolint implementation for compatibility:
4//! - `Severity` - Rule violation severity levels
5//! - `RuleCode` - Rule identifiers (e.g., "DL3008")
6//! - `CheckFailure` - A single rule violation
7//! - `State` - Stateful rule accumulator
8
9use std::cmp::Ordering;
10use std::fmt;
11
12/// Severity levels for rule violations.
13///
14/// Ordered from most severe to least severe:
15/// `Error > Warning > Info > Style > Ignore`
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
17pub enum Severity {
18    /// Critical issues that should always be fixed
19    Error,
20    /// Important issues that should usually be fixed
21    Warning,
22    /// Informational suggestions for improvement
23    #[default]
24    Info,
25    /// Style recommendations
26    Style,
27    /// Ignored (rule disabled)
28    Ignore,
29}
30
31impl Severity {
32    /// Parse a severity from a string (case-insensitive).
33    pub fn parse(s: &str) -> Option<Self> {
34        match s.to_lowercase().as_str() {
35            "error" => Some(Self::Error),
36            "warning" => Some(Self::Warning),
37            "info" => Some(Self::Info),
38            "style" => Some(Self::Style),
39            "ignore" | "none" => Some(Self::Ignore),
40            _ => None,
41        }
42    }
43
44    /// Get the string representation.
45    pub fn as_str(&self) -> &'static str {
46        match self {
47            Self::Error => "error",
48            Self::Warning => "warning",
49            Self::Info => "info",
50            Self::Style => "style",
51            Self::Ignore => "ignore",
52        }
53    }
54}
55
56impl fmt::Display for Severity {
57    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58        write!(f, "{}", self.as_str())
59    }
60}
61
62impl Ord for Severity {
63    fn cmp(&self, other: &Self) -> Ordering {
64        // Higher severity = lower numeric value for Ord
65        let self_val = match self {
66            Self::Error => 0,
67            Self::Warning => 1,
68            Self::Info => 2,
69            Self::Style => 3,
70            Self::Ignore => 4,
71        };
72        let other_val = match other {
73            Self::Error => 0,
74            Self::Warning => 1,
75            Self::Info => 2,
76            Self::Style => 3,
77            Self::Ignore => 4,
78        };
79        // Reverse so Error > Warning > Info > Style > Ignore
80        other_val.cmp(&self_val)
81    }
82}
83
84impl PartialOrd for Severity {
85    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
86        Some(self.cmp(other))
87    }
88}
89
90/// A rule code identifier (e.g., "DL3008", "SC2086").
91#[derive(Debug, Clone, PartialEq, Eq, Hash)]
92pub struct RuleCode(pub String);
93
94impl RuleCode {
95    /// Create a new rule code.
96    pub fn new(code: impl Into<String>) -> Self {
97        Self(code.into())
98    }
99
100    /// Get the code as a string slice.
101    pub fn as_str(&self) -> &str {
102        &self.0
103    }
104
105    /// Check if this is a Dockerfile rule (DL prefix).
106    pub fn is_dockerfile_rule(&self) -> bool {
107        self.0.starts_with("DL")
108    }
109
110    /// Check if this is a ShellCheck rule (SC prefix).
111    pub fn is_shellcheck_rule(&self) -> bool {
112        self.0.starts_with("SC")
113    }
114}
115
116impl fmt::Display for RuleCode {
117    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118        write!(f, "{}", self.0)
119    }
120}
121
122impl From<&str> for RuleCode {
123    fn from(s: &str) -> Self {
124        Self::new(s)
125    }
126}
127
128impl From<String> for RuleCode {
129    fn from(s: String) -> Self {
130        Self(s)
131    }
132}
133
134/// A check failure (rule violation) found during linting.
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct CheckFailure {
137    /// The rule code that was violated.
138    pub code: RuleCode,
139    /// The severity of the violation.
140    pub severity: Severity,
141    /// A human-readable message describing the violation.
142    pub message: String,
143    /// The line number where the violation occurred (1-indexed).
144    pub line: u32,
145    /// Optional column number (1-indexed).
146    pub column: Option<u32>,
147}
148
149impl CheckFailure {
150    /// Create a new check failure.
151    pub fn new(
152        code: impl Into<RuleCode>,
153        severity: Severity,
154        message: impl Into<String>,
155        line: u32,
156    ) -> Self {
157        Self {
158            code: code.into(),
159            severity,
160            message: message.into(),
161            line,
162            column: None,
163        }
164    }
165
166    /// Create a check failure with column information.
167    pub fn with_column(
168        code: impl Into<RuleCode>,
169        severity: Severity,
170        message: impl Into<String>,
171        line: u32,
172        column: u32,
173    ) -> Self {
174        Self {
175            code: code.into(),
176            severity,
177            message: message.into(),
178            line,
179            column: Some(column),
180        }
181    }
182}
183
184impl Ord for CheckFailure {
185    fn cmp(&self, other: &Self) -> Ordering {
186        // Sort by line number first
187        self.line.cmp(&other.line)
188    }
189}
190
191impl PartialOrd for CheckFailure {
192    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
193        Some(self.cmp(other))
194    }
195}
196
197/// State accumulator for stateful rules.
198///
199/// Used by `custom_rule` and `very_custom_rule` to track state across
200/// multiple instructions during the analysis pass.
201#[derive(Debug, Clone)]
202pub struct State<T> {
203    /// Accumulated failures found during analysis.
204    pub failures: Vec<CheckFailure>,
205    /// Custom state for the rule.
206    pub state: T,
207}
208
209impl<T: Default> Default for State<T> {
210    fn default() -> Self {
211        Self {
212            failures: Vec::new(),
213            state: T::default(),
214        }
215    }
216}
217
218impl<T> State<T> {
219    /// Create a new state with the given initial state.
220    pub fn new(state: T) -> Self {
221        Self {
222            failures: Vec::new(),
223            state,
224        }
225    }
226
227    /// Add a failure to the state.
228    pub fn add_failure(&mut self, failure: CheckFailure) {
229        self.failures.push(failure);
230    }
231
232    /// Modify the state with a function.
233    pub fn modify<F>(&mut self, f: F)
234    where
235        F: FnOnce(&mut T),
236    {
237        f(&mut self.state);
238    }
239
240    /// Replace the state entirely.
241    pub fn replace_state(&mut self, new_state: T) {
242        self.state = new_state;
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    #[test]
251    fn test_severity_ordering() {
252        assert!(Severity::Error > Severity::Warning);
253        assert!(Severity::Warning > Severity::Info);
254        assert!(Severity::Info > Severity::Style);
255        assert!(Severity::Style > Severity::Ignore);
256    }
257
258    #[test]
259    fn test_severity_from_str() {
260        assert_eq!(Severity::parse("error"), Some(Severity::Error));
261        assert_eq!(Severity::parse("WARNING"), Some(Severity::Warning));
262        assert_eq!(Severity::parse("Info"), Some(Severity::Info));
263        assert_eq!(Severity::parse("style"), Some(Severity::Style));
264        assert_eq!(Severity::parse("ignore"), Some(Severity::Ignore));
265        assert_eq!(Severity::parse("none"), Some(Severity::Ignore));
266        assert_eq!(Severity::parse("invalid"), None);
267    }
268
269    #[test]
270    fn test_rule_code() {
271        let dl_code = RuleCode::new("DL3008");
272        assert!(dl_code.is_dockerfile_rule());
273        assert!(!dl_code.is_shellcheck_rule());
274
275        let sc_code = RuleCode::new("SC2086");
276        assert!(!sc_code.is_dockerfile_rule());
277        assert!(sc_code.is_shellcheck_rule());
278    }
279
280    #[test]
281    fn test_check_failure_ordering() {
282        let f1 = CheckFailure::new("DL3008", Severity::Warning, "msg1", 5);
283        let f2 = CheckFailure::new("DL3009", Severity::Info, "msg2", 10);
284        let f3 = CheckFailure::new("DL3010", Severity::Error, "msg3", 3);
285
286        let mut failures = vec![f1.clone(), f2.clone(), f3.clone()];
287        failures.sort();
288
289        assert_eq!(failures[0].line, 3);
290        assert_eq!(failures[1].line, 5);
291        assert_eq!(failures[2].line, 10);
292    }
293
294    #[test]
295    fn test_state() {
296        let mut state: State<i32> = State::new(0);
297        assert_eq!(state.state, 0);
298        assert!(state.failures.is_empty());
299
300        state.modify(|s| *s += 10);
301        assert_eq!(state.state, 10);
302
303        state.add_failure(CheckFailure::new("DL3008", Severity::Warning, "test", 1));
304        assert_eq!(state.failures.len(), 1);
305    }
306}