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