Skip to main content

fallow_types/
suppress.rs

1//! Inline suppression comment types and issue kind definitions.
2
3/// Issue kind for suppression matching.
4///
5/// # Examples
6///
7/// ```
8/// use fallow_types::suppress::IssueKind;
9///
10/// let kind = IssueKind::parse("unused-export");
11/// assert_eq!(kind, Some(IssueKind::UnusedExport));
12///
13/// // Round-trip through discriminant
14/// let d = IssueKind::UnusedFile.to_discriminant();
15/// assert_eq!(IssueKind::from_discriminant(d), Some(IssueKind::UnusedFile));
16///
17/// // Unknown strings return None
18/// assert_eq!(IssueKind::parse("not-a-kind"), None);
19/// ```
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum IssueKind {
22    /// An unused file.
23    UnusedFile,
24    /// An unused export.
25    UnusedExport,
26    /// An unused type export.
27    UnusedType,
28    /// An unused dependency.
29    UnusedDependency,
30    /// An unused dev dependency.
31    UnusedDevDependency,
32    /// An unused enum member.
33    UnusedEnumMember,
34    /// An unused class member.
35    UnusedClassMember,
36    /// An unresolved import.
37    UnresolvedImport,
38    /// An unlisted dependency.
39    UnlistedDependency,
40    /// A duplicate export name across modules.
41    DuplicateExport,
42    /// Code duplication.
43    CodeDuplication,
44    /// A circular dependency chain.
45    CircularDependency,
46    /// A production dependency only imported via type-only imports.
47    TypeOnlyDependency,
48    /// A production dependency only imported by test files.
49    TestOnlyDependency,
50    /// An import that crosses an architecture boundary.
51    BoundaryViolation,
52    /// A runtime file or export with no test dependency path.
53    CoverageGaps,
54    /// A detected feature flag pattern.
55    FeatureFlag,
56    /// A function exceeding complexity thresholds (health command).
57    Complexity,
58}
59
60impl IssueKind {
61    /// Parse an issue kind from the string tokens used in CLI output and suppression comments.
62    #[must_use]
63    pub fn parse(s: &str) -> Option<Self> {
64        match s {
65            "unused-file" => Some(Self::UnusedFile),
66            "unused-export" => Some(Self::UnusedExport),
67            "unused-type" => Some(Self::UnusedType),
68            "unused-dependency" => Some(Self::UnusedDependency),
69            "unused-dev-dependency" => Some(Self::UnusedDevDependency),
70            "unused-enum-member" => Some(Self::UnusedEnumMember),
71            "unused-class-member" => Some(Self::UnusedClassMember),
72            "unresolved-import" => Some(Self::UnresolvedImport),
73            "unlisted-dependency" => Some(Self::UnlistedDependency),
74            "duplicate-export" => Some(Self::DuplicateExport),
75            "code-duplication" => Some(Self::CodeDuplication),
76            "circular-dependency" => Some(Self::CircularDependency),
77            "type-only-dependency" => Some(Self::TypeOnlyDependency),
78            "test-only-dependency" => Some(Self::TestOnlyDependency),
79            "boundary-violation" => Some(Self::BoundaryViolation),
80            "coverage-gaps" => Some(Self::CoverageGaps),
81            "feature-flag" => Some(Self::FeatureFlag),
82            "complexity" => Some(Self::Complexity),
83            _ => None,
84        }
85    }
86
87    /// Convert to a u8 discriminant for compact cache storage.
88    #[must_use]
89    pub const fn to_discriminant(self) -> u8 {
90        match self {
91            Self::UnusedFile => 1,
92            Self::UnusedExport => 2,
93            Self::UnusedType => 3,
94            Self::UnusedDependency => 4,
95            Self::UnusedDevDependency => 5,
96            Self::UnusedEnumMember => 6,
97            Self::UnusedClassMember => 7,
98            Self::UnresolvedImport => 8,
99            Self::UnlistedDependency => 9,
100            Self::DuplicateExport => 10,
101            Self::CodeDuplication => 11,
102            Self::CircularDependency => 12,
103            Self::TypeOnlyDependency => 13,
104            Self::TestOnlyDependency => 14,
105            Self::BoundaryViolation => 15,
106            Self::CoverageGaps => 16,
107            Self::FeatureFlag => 17,
108            Self::Complexity => 18,
109        }
110    }
111
112    /// Reconstruct from a cache discriminant.
113    #[must_use]
114    pub const fn from_discriminant(d: u8) -> Option<Self> {
115        match d {
116            1 => Some(Self::UnusedFile),
117            2 => Some(Self::UnusedExport),
118            3 => Some(Self::UnusedType),
119            4 => Some(Self::UnusedDependency),
120            5 => Some(Self::UnusedDevDependency),
121            6 => Some(Self::UnusedEnumMember),
122            7 => Some(Self::UnusedClassMember),
123            8 => Some(Self::UnresolvedImport),
124            9 => Some(Self::UnlistedDependency),
125            10 => Some(Self::DuplicateExport),
126            11 => Some(Self::CodeDuplication),
127            12 => Some(Self::CircularDependency),
128            13 => Some(Self::TypeOnlyDependency),
129            14 => Some(Self::TestOnlyDependency),
130            15 => Some(Self::BoundaryViolation),
131            16 => Some(Self::CoverageGaps),
132            17 => Some(Self::FeatureFlag),
133            18 => Some(Self::Complexity),
134            _ => None,
135        }
136    }
137}
138
139/// A suppression directive parsed from a source comment.
140///
141/// # Examples
142///
143/// ```
144/// use fallow_types::suppress::{Suppression, IssueKind};
145///
146/// // File-wide suppression (line 0, no specific kind)
147/// let file_wide = Suppression { line: 0, kind: None };
148/// assert_eq!(file_wide.line, 0);
149///
150/// // Line-specific suppression for unused exports
151/// let line_suppress = Suppression {
152///     line: 42,
153///     kind: Some(IssueKind::UnusedExport),
154/// };
155/// assert_eq!(line_suppress.kind, Some(IssueKind::UnusedExport));
156/// ```
157#[derive(Debug, Clone)]
158pub struct Suppression {
159    /// 1-based line this suppression applies to. 0 = file-wide suppression.
160    pub line: u32,
161    /// None = suppress all issue kinds on this line.
162    pub kind: Option<IssueKind>,
163}
164
165// Size assertions to prevent memory regressions.
166// `Suppression` is stored in a Vec per file; `IssueKind` appears in every suppression.
167const _: () = assert!(std::mem::size_of::<Suppression>() == 8);
168const _: () = assert!(std::mem::size_of::<IssueKind>() == 1);
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn issue_kind_from_str_all_variants() {
176        assert_eq!(IssueKind::parse("unused-file"), Some(IssueKind::UnusedFile));
177        assert_eq!(
178            IssueKind::parse("unused-export"),
179            Some(IssueKind::UnusedExport)
180        );
181        assert_eq!(IssueKind::parse("unused-type"), Some(IssueKind::UnusedType));
182        assert_eq!(
183            IssueKind::parse("unused-dependency"),
184            Some(IssueKind::UnusedDependency)
185        );
186        assert_eq!(
187            IssueKind::parse("unused-dev-dependency"),
188            Some(IssueKind::UnusedDevDependency)
189        );
190        assert_eq!(
191            IssueKind::parse("unused-enum-member"),
192            Some(IssueKind::UnusedEnumMember)
193        );
194        assert_eq!(
195            IssueKind::parse("unused-class-member"),
196            Some(IssueKind::UnusedClassMember)
197        );
198        assert_eq!(
199            IssueKind::parse("unresolved-import"),
200            Some(IssueKind::UnresolvedImport)
201        );
202        assert_eq!(
203            IssueKind::parse("unlisted-dependency"),
204            Some(IssueKind::UnlistedDependency)
205        );
206        assert_eq!(
207            IssueKind::parse("duplicate-export"),
208            Some(IssueKind::DuplicateExport)
209        );
210        assert_eq!(
211            IssueKind::parse("code-duplication"),
212            Some(IssueKind::CodeDuplication)
213        );
214        assert_eq!(
215            IssueKind::parse("circular-dependency"),
216            Some(IssueKind::CircularDependency)
217        );
218        assert_eq!(
219            IssueKind::parse("type-only-dependency"),
220            Some(IssueKind::TypeOnlyDependency)
221        );
222        assert_eq!(
223            IssueKind::parse("test-only-dependency"),
224            Some(IssueKind::TestOnlyDependency)
225        );
226        assert_eq!(
227            IssueKind::parse("boundary-violation"),
228            Some(IssueKind::BoundaryViolation)
229        );
230        assert_eq!(
231            IssueKind::parse("coverage-gaps"),
232            Some(IssueKind::CoverageGaps)
233        );
234        assert_eq!(
235            IssueKind::parse("feature-flag"),
236            Some(IssueKind::FeatureFlag)
237        );
238        assert_eq!(IssueKind::parse("complexity"), Some(IssueKind::Complexity));
239    }
240
241    #[test]
242    fn issue_kind_from_str_unknown() {
243        assert_eq!(IssueKind::parse("foo"), None);
244        assert_eq!(IssueKind::parse(""), None);
245    }
246
247    #[test]
248    fn issue_kind_from_str_near_misses() {
249        // Case sensitivity — these should NOT match
250        assert_eq!(IssueKind::parse("Unused-File"), None);
251        assert_eq!(IssueKind::parse("UNUSED-EXPORT"), None);
252        // Typos / near-misses
253        assert_eq!(IssueKind::parse("unused_file"), None);
254        assert_eq!(IssueKind::parse("unused-files"), None);
255    }
256
257    #[test]
258    fn discriminant_out_of_range() {
259        assert_eq!(IssueKind::from_discriminant(0), None);
260        assert_eq!(IssueKind::from_discriminant(19), None);
261        assert_eq!(IssueKind::from_discriminant(u8::MAX), None);
262    }
263
264    #[test]
265    fn discriminant_roundtrip() {
266        for kind in [
267            IssueKind::UnusedFile,
268            IssueKind::UnusedExport,
269            IssueKind::UnusedType,
270            IssueKind::UnusedDependency,
271            IssueKind::UnusedDevDependency,
272            IssueKind::UnusedEnumMember,
273            IssueKind::UnusedClassMember,
274            IssueKind::UnresolvedImport,
275            IssueKind::UnlistedDependency,
276            IssueKind::DuplicateExport,
277            IssueKind::CodeDuplication,
278            IssueKind::CircularDependency,
279            IssueKind::TypeOnlyDependency,
280            IssueKind::TestOnlyDependency,
281            IssueKind::BoundaryViolation,
282            IssueKind::CoverageGaps,
283            IssueKind::FeatureFlag,
284            IssueKind::Complexity,
285        ] {
286            assert_eq!(
287                IssueKind::from_discriminant(kind.to_discriminant()),
288                Some(kind)
289            );
290        }
291        assert_eq!(IssueKind::from_discriminant(0), None);
292        assert_eq!(IssueKind::from_discriminant(19), None);
293    }
294
295    // ── Discriminant uniqueness ─────────────────────────────────
296
297    #[test]
298    fn discriminant_values_are_unique() {
299        let all_kinds = [
300            IssueKind::UnusedFile,
301            IssueKind::UnusedExport,
302            IssueKind::UnusedType,
303            IssueKind::UnusedDependency,
304            IssueKind::UnusedDevDependency,
305            IssueKind::UnusedEnumMember,
306            IssueKind::UnusedClassMember,
307            IssueKind::UnresolvedImport,
308            IssueKind::UnlistedDependency,
309            IssueKind::DuplicateExport,
310            IssueKind::CodeDuplication,
311            IssueKind::CircularDependency,
312            IssueKind::TypeOnlyDependency,
313            IssueKind::TestOnlyDependency,
314            IssueKind::BoundaryViolation,
315            IssueKind::CoverageGaps,
316            IssueKind::FeatureFlag,
317            IssueKind::Complexity,
318        ];
319        let discriminants: Vec<u8> = all_kinds.iter().map(|k| k.to_discriminant()).collect();
320        let mut sorted = discriminants.clone();
321        sorted.sort_unstable();
322        sorted.dedup();
323        assert_eq!(
324            discriminants.len(),
325            sorted.len(),
326            "discriminant values must be unique"
327        );
328    }
329
330    // ── Discriminant starts at 1 ────────────────────────────────
331
332    #[test]
333    fn discriminant_starts_at_one() {
334        assert_eq!(IssueKind::UnusedFile.to_discriminant(), 1);
335    }
336
337    // ── Suppression struct ──────────────────────────────────────
338
339    #[test]
340    fn suppression_line_zero_is_file_wide() {
341        let s = Suppression {
342            line: 0,
343            kind: None,
344        };
345        assert_eq!(s.line, 0);
346        assert!(s.kind.is_none());
347    }
348
349    #[test]
350    fn suppression_with_specific_kind_and_line() {
351        let s = Suppression {
352            line: 42,
353            kind: Some(IssueKind::UnusedExport),
354        };
355        assert_eq!(s.line, 42);
356        assert_eq!(s.kind, Some(IssueKind::UnusedExport));
357    }
358}