syncable_cli/analyzer/dclint/
types.rs

1//! Core types for the dclint Docker Compose linter.
2//!
3//! These types follow the pattern established by hadolint-rs:
4//! - `Severity` - Rule violation severity levels
5//! - `RuleCode` - Rule identifiers (e.g., "DCL001")
6//! - `CheckFailure` - A single rule violation
7//! - `RuleCategory` - Category of the rule (style, security, etc.)
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`
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}
27
28impl Severity {
29    /// Parse a severity from a string (case-insensitive).
30    pub fn from_str(s: &str) -> Option<Self> {
31        match s.to_lowercase().as_str() {
32            "error" | "critical" | "major" => Some(Self::Error),
33            "warning" | "minor" => Some(Self::Warning),
34            "info" => Some(Self::Info),
35            "style" => Some(Self::Style),
36            _ => None,
37        }
38    }
39
40    /// Get the string representation.
41    pub fn as_str(&self) -> &'static str {
42        match self {
43            Self::Error => "error",
44            Self::Warning => "warning",
45            Self::Info => "info",
46            Self::Style => "style",
47        }
48    }
49}
50
51impl fmt::Display for Severity {
52    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53        write!(f, "{}", self.as_str())
54    }
55}
56
57impl Default for Severity {
58    fn default() -> Self {
59        Self::Warning
60    }
61}
62
63impl Ord for Severity {
64    fn cmp(&self, other: &Self) -> Ordering {
65        // Higher severity = lower numeric value for Ord
66        let self_val = match self {
67            Self::Error => 0,
68            Self::Warning => 1,
69            Self::Info => 2,
70            Self::Style => 3,
71        };
72        let other_val = match other {
73            Self::Error => 0,
74            Self::Warning => 1,
75            Self::Info => 2,
76            Self::Style => 3,
77        };
78        // Reverse so Error > Warning > Info > Style
79        other_val.cmp(&self_val)
80    }
81}
82
83impl PartialOrd for Severity {
84    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
85        Some(self.cmp(other))
86    }
87}
88
89/// Category of a lint rule.
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
91pub enum RuleCategory {
92    /// Style and formatting issues
93    Style,
94    /// Security-related issues
95    Security,
96    /// Best practice recommendations
97    BestPractice,
98    /// Performance-related issues
99    Performance,
100}
101
102impl RuleCategory {
103    /// Get the string representation.
104    pub fn as_str(&self) -> &'static str {
105        match self {
106            Self::Style => "style",
107            Self::Security => "security",
108            Self::BestPractice => "best-practice",
109            Self::Performance => "performance",
110        }
111    }
112}
113
114impl fmt::Display for RuleCategory {
115    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116        write!(f, "{}", self.as_str())
117    }
118}
119
120/// A rule code identifier (e.g., "DCL001").
121#[derive(Debug, Clone, PartialEq, Eq, Hash)]
122pub struct RuleCode(pub String);
123
124impl RuleCode {
125    /// Create a new rule code.
126    pub fn new(code: impl Into<String>) -> Self {
127        Self(code.into())
128    }
129
130    /// Get the code as a string slice.
131    pub fn as_str(&self) -> &str {
132        &self.0
133    }
134
135    /// Check if this is a DCL rule.
136    pub fn is_dcl_rule(&self) -> bool {
137        self.0.starts_with("DCL")
138    }
139
140    /// Get the numeric part of the rule code.
141    pub fn number(&self) -> Option<u32> {
142        if self.0.starts_with("DCL") {
143            self.0[3..].parse().ok()
144        } else {
145            None
146        }
147    }
148}
149
150impl fmt::Display for RuleCode {
151    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
152        write!(f, "{}", self.0)
153    }
154}
155
156impl From<&str> for RuleCode {
157    fn from(s: &str) -> Self {
158        Self::new(s)
159    }
160}
161
162impl From<String> for RuleCode {
163    fn from(s: String) -> Self {
164        Self(s)
165    }
166}
167
168/// A check failure (rule violation) found during linting.
169#[derive(Debug, Clone, PartialEq, Eq)]
170pub struct CheckFailure {
171    /// The rule code that was violated.
172    pub code: RuleCode,
173    /// The human-readable rule name (e.g., "no-build-and-image").
174    pub rule_name: String,
175    /// The severity of the violation.
176    pub severity: Severity,
177    /// The category of the rule.
178    pub category: RuleCategory,
179    /// A human-readable message describing the violation.
180    pub message: String,
181    /// The line number where the violation occurred (1-indexed).
182    pub line: u32,
183    /// The column number where the violation starts (1-indexed).
184    pub column: u32,
185    /// Optional end line number.
186    pub end_line: Option<u32>,
187    /// Optional end column number.
188    pub end_column: Option<u32>,
189    /// Whether this issue can be auto-fixed.
190    pub fixable: bool,
191    /// Additional context data for the violation.
192    pub data: std::collections::HashMap<String, String>,
193}
194
195impl CheckFailure {
196    /// Create a new check failure.
197    pub fn new(
198        code: impl Into<RuleCode>,
199        rule_name: impl Into<String>,
200        severity: Severity,
201        category: RuleCategory,
202        message: impl Into<String>,
203        line: u32,
204        column: u32,
205    ) -> Self {
206        Self {
207            code: code.into(),
208            rule_name: rule_name.into(),
209            severity,
210            category,
211            message: message.into(),
212            line,
213            column,
214            end_line: None,
215            end_column: None,
216            fixable: false,
217            data: std::collections::HashMap::new(),
218        }
219    }
220
221    /// Set the end position.
222    pub fn with_end(mut self, end_line: u32, end_column: u32) -> Self {
223        self.end_line = Some(end_line);
224        self.end_column = Some(end_column);
225        self
226    }
227
228    /// Mark as fixable.
229    pub fn with_fixable(mut self, fixable: bool) -> Self {
230        self.fixable = fixable;
231        self
232    }
233
234    /// Add context data.
235    pub fn with_data(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
236        self.data.insert(key.into(), value.into());
237        self
238    }
239}
240
241impl Ord for CheckFailure {
242    fn cmp(&self, other: &Self) -> Ordering {
243        // Sort by line number first, then column
244        match self.line.cmp(&other.line) {
245            Ordering::Equal => self.column.cmp(&other.column),
246            other => other,
247        }
248    }
249}
250
251impl PartialOrd for CheckFailure {
252    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
253        Some(self.cmp(other))
254    }
255}
256
257/// Rule metadata for documentation and display.
258#[derive(Debug, Clone)]
259pub struct RuleMeta {
260    /// Short description of the rule.
261    pub description: String,
262    /// URL to detailed documentation.
263    pub url: String,
264}
265
266impl RuleMeta {
267    pub fn new(description: impl Into<String>, url: impl Into<String>) -> Self {
268        Self {
269            description: description.into(),
270            url: url.into(),
271        }
272    }
273}
274
275/// Configuration level for a rule (matches TypeScript ConfigRuleLevel).
276#[derive(Debug, Clone, Copy, PartialEq, Eq)]
277pub enum ConfigLevel {
278    /// Rule is disabled
279    Off = 0,
280    /// Rule produces warnings
281    Warn = 1,
282    /// Rule produces errors
283    Error = 2,
284}
285
286impl ConfigLevel {
287    /// Convert from numeric value.
288    pub fn from_u8(value: u8) -> Option<Self> {
289        match value {
290            0 => Some(Self::Off),
291            1 => Some(Self::Warn),
292            2 => Some(Self::Error),
293            _ => None,
294        }
295    }
296
297    /// Convert to severity (for non-off levels).
298    pub fn to_severity(&self) -> Option<Severity> {
299        match self {
300            Self::Off => None,
301            Self::Warn => Some(Severity::Warning),
302            Self::Error => Some(Severity::Error),
303        }
304    }
305}
306
307impl Default for ConfigLevel {
308    fn default() -> Self {
309        Self::Error
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    #[test]
318    fn test_severity_ordering() {
319        assert!(Severity::Error > Severity::Warning);
320        assert!(Severity::Warning > Severity::Info);
321        assert!(Severity::Info > Severity::Style);
322    }
323
324    #[test]
325    fn test_severity_from_str() {
326        assert_eq!(Severity::from_str("error"), Some(Severity::Error));
327        assert_eq!(Severity::from_str("WARNING"), Some(Severity::Warning));
328        assert_eq!(Severity::from_str("Info"), Some(Severity::Info));
329        assert_eq!(Severity::from_str("style"), Some(Severity::Style));
330        assert_eq!(Severity::from_str("critical"), Some(Severity::Error));
331        assert_eq!(Severity::from_str("major"), Some(Severity::Error));
332        assert_eq!(Severity::from_str("minor"), Some(Severity::Warning));
333        assert_eq!(Severity::from_str("invalid"), None);
334    }
335
336    #[test]
337    fn test_rule_code() {
338        let code = RuleCode::new("DCL001");
339        assert!(code.is_dcl_rule());
340        assert_eq!(code.number(), Some(1));
341        assert_eq!(code.as_str(), "DCL001");
342
343        let invalid = RuleCode::new("OTHER");
344        assert!(!invalid.is_dcl_rule());
345        assert_eq!(invalid.number(), None);
346    }
347
348    #[test]
349    fn test_check_failure_ordering() {
350        let f1 = CheckFailure::new(
351            "DCL001",
352            "test",
353            Severity::Warning,
354            RuleCategory::Style,
355            "msg1",
356            5,
357            1,
358        );
359        let f2 = CheckFailure::new(
360            "DCL002",
361            "test",
362            Severity::Info,
363            RuleCategory::Style,
364            "msg2",
365            10,
366            1,
367        );
368        let f3 = CheckFailure::new(
369            "DCL003",
370            "test",
371            Severity::Error,
372            RuleCategory::Style,
373            "msg3",
374            3,
375            1,
376        );
377        let f4 = CheckFailure::new(
378            "DCL004",
379            "test",
380            Severity::Error,
381            RuleCategory::Style,
382            "msg4",
383            3,
384            5,
385        );
386
387        let mut failures = vec![f1.clone(), f2.clone(), f3.clone(), f4.clone()];
388        failures.sort();
389
390        assert_eq!(failures[0].line, 3);
391        assert_eq!(failures[0].column, 1);
392        assert_eq!(failures[1].line, 3);
393        assert_eq!(failures[1].column, 5);
394        assert_eq!(failures[2].line, 5);
395        assert_eq!(failures[3].line, 10);
396    }
397
398    #[test]
399    fn test_config_level() {
400        assert_eq!(ConfigLevel::from_u8(0), Some(ConfigLevel::Off));
401        assert_eq!(ConfigLevel::from_u8(1), Some(ConfigLevel::Warn));
402        assert_eq!(ConfigLevel::from_u8(2), Some(ConfigLevel::Error));
403        assert_eq!(ConfigLevel::from_u8(3), None);
404
405        assert_eq!(ConfigLevel::Off.to_severity(), None);
406        assert_eq!(ConfigLevel::Warn.to_severity(), Some(Severity::Warning));
407        assert_eq!(ConfigLevel::Error.to_severity(), Some(Severity::Error));
408    }
409
410    #[test]
411    fn test_rule_category() {
412        assert_eq!(RuleCategory::Style.as_str(), "style");
413        assert_eq!(RuleCategory::Security.as_str(), "security");
414        assert_eq!(RuleCategory::BestPractice.as_str(), "best-practice");
415        assert_eq!(RuleCategory::Performance.as_str(), "performance");
416    }
417}