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