Skip to main content

mit_lint/model/
lints.rs

1use std::{
2    collections::{BTreeMap, BTreeSet},
3    convert::TryFrom,
4    sync::LazyLock,
5};
6
7use miette::Diagnostic;
8use thiserror::Error;
9
10use crate::model::{Lint, lint};
11
12/// A collection of lints
13#[derive(Debug, Eq, PartialEq, Clone)]
14pub struct Lints {
15    lints: BTreeSet<Lint>,
16}
17
18/// All the available lints
19static AVAILABLE: LazyLock<Lints> = LazyLock::new(|| {
20    let set = Lint::all_lints().collect();
21    Lints::new(set)
22});
23
24impl Lints {
25    /// Create a new collection of lints
26    ///
27    /// # Examples
28    ///
29    /// ```rust
30    /// use std::collections::BTreeSet;
31    ///
32    /// use mit_lint::Lints;
33    /// Lints::new(BTreeSet::new());
34    /// ```
35    #[must_use]
36    pub const fn new(lints: BTreeSet<Lint>) -> Self {
37        Self { lints }
38    }
39
40    /// Get the available lints
41    ///
42    /// # Examples
43    ///
44    /// ```rust
45    /// use mit_lint::{Lint, Lints};
46    ///
47    /// let lints = Lints::available().clone();
48    /// assert!(lints.into_iter().count() > 0);
49    /// ```
50    #[must_use]
51    pub fn available() -> &'static Self {
52        &AVAILABLE
53    }
54
55    /// Get all the names of these lints
56    ///
57    /// # Examples
58    ///
59    /// ```rust
60    /// use mit_lint::{Lint, Lints};
61    ///
62    /// let names = Lints::available().clone().names();
63    /// assert!(names.contains(&Lint::SubjectNotSeparateFromBody.name()));
64    /// ```
65    #[must_use]
66    pub fn names(self) -> Vec<&'static str> {
67        self.lints.iter().map(|lint| lint.name()).collect()
68    }
69
70    /// Get all the config keys of these lints
71    ///
72    /// # Examples
73    ///
74    /// ```rust
75    /// use mit_lint::{Lint, Lints};
76    ///
77    /// let names = Lints::available().clone().config_keys();
78    /// assert!(names.contains(&Lint::SubjectNotSeparateFromBody.config_key()));
79    /// ```
80    #[must_use]
81    pub fn config_keys(self) -> Vec<String> {
82        self.lints.iter().map(|lint| lint.config_key()).collect()
83    }
84
85    /// Create the union of two lints
86    ///
87    /// # Examples
88    ///
89    /// ```rust
90    /// use mit_lint::{Lint, Lints};
91    ///
92    /// let to_add = Lints::new(vec![Lint::NotEmojiLog].into_iter().collect());
93    /// let actual = Lints::available().clone().merge(&to_add).names();
94    /// assert!(actual.contains(&Lint::NotEmojiLog.name()));
95    /// ```
96    #[must_use]
97    pub fn merge(&self, other: &Self) -> Self {
98        Self::new(self.lints.union(&other.lints).copied().collect())
99    }
100
101    /// Get the lints that are in self, but not in other
102    ///
103    /// # Examples
104    ///
105    /// ```rust
106    /// use mit_lint::{Lint, Lints};
107    ///
108    /// let to_remove = Lints::new(vec![Lint::SubjectNotSeparateFromBody].into_iter().collect());
109    /// let actual = Lints::available().clone().subtract(&to_remove).names();
110    /// assert!(!actual.contains(&Lint::SubjectNotSeparateFromBody.name()));
111    /// ```
112    #[must_use]
113    pub fn subtract(&self, other: &Self) -> Self {
114        Self::new(self.lints.difference(&other.lints).copied().collect())
115    }
116}
117
118impl IntoIterator for Lints {
119    type Item = Lint;
120    type IntoIter = std::collections::btree_set::IntoIter<Lint>;
121
122    fn into_iter(self) -> Self::IntoIter {
123        self.lints.into_iter()
124    }
125}
126
127impl TryFrom<Lints> for String {
128    type Error = Error;
129
130    fn try_from(lints: Lints) -> Result<Self, Self::Error> {
131        let enabled: Vec<_> = lints.into();
132
133        let config: BTreeMap<Self, bool> = Lint::all_lints()
134            .map(|x| (x, enabled.contains(&x)))
135            .fold(BTreeMap::new(), |mut acc, (lint, state)| {
136                acc.insert(lint.to_string(), state);
137                acc
138            });
139
140        let mut inner: BTreeMap<Self, BTreeMap<Self, bool>> = BTreeMap::new();
141        inner.insert("lint".into(), config);
142        let mut output: BTreeMap<Self, BTreeMap<Self, BTreeMap<Self, bool>>> = BTreeMap::new();
143        output.insert("mit".into(), inner);
144
145        Ok(toml::to_string(&output)?)
146    }
147}
148
149impl From<Vec<Lint>> for Lints {
150    fn from(lints: Vec<Lint>) -> Self {
151        Self::new(lints.into_iter().collect())
152    }
153}
154
155impl From<Lints> for Vec<Lint> {
156    fn from(lints: Lints) -> Self {
157        lints.into_iter().collect()
158    }
159}
160
161impl TryFrom<Vec<&str>> for Lints {
162    type Error = Error;
163
164    fn try_from(value: Vec<&str>) -> Result<Self, Self::Error> {
165        let len = value.len();
166        let lints = value
167            .into_iter()
168            .try_fold(
169                Vec::with_capacity(len),
170                |mut lints: Vec<Lint>, item_name| -> Result<Vec<Lint>, Error> {
171                    let lint = Lint::try_from(item_name)?;
172                    lints.push(lint);
173                    Ok(lints)
174                },
175            )?
176            .into_iter();
177
178        Ok(Self::new(lints.collect()))
179    }
180}
181
182/// General lint related errors
183#[derive(Error, Debug, Diagnostic)]
184pub enum Error {
185    /// Lint name unknown
186    #[error(transparent)]
187    #[diagnostic(transparent)]
188    LintNameUnknown(#[from] lint::Error),
189    /// Failed to parse lint config file
190    #[error("Failed to parse lint config file: {0}")]
191    #[diagnostic(
192        code(mit_lint::model::lints::error::toml_parse),
193        url(docsrs),
194        help("is it valid toml?")
195    )]
196    TomlParse(#[from] toml::de::Error),
197    /// Failed to convert config to toml
198    #[error("Failed to convert config to toml: {0}")]
199    #[diagnostic(code(mit_lint::model::lints::error::toml_serialize), url(docsrs))]
200    TomlSerialize(#[from] toml::ser::Error),
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    use std::{
208        borrow::Borrow,
209        collections::{BTreeMap, BTreeSet},
210        convert::{TryFrom, TryInto},
211    };
212
213    use quickcheck::TestResult;
214
215    use crate::model::{
216        Lint,
217        lint::Lint::{
218            BodyWiderThan72Characters, DuplicatedTrailers, JiraIssueKeyMissing,
219            PivotalTrackerIdMissing, SubjectLongerThan72Characters, SubjectNotSeparateFromBody,
220        },
221    };
222
223    #[allow(clippy::needless_pass_by_value)]
224    #[quickcheck]
225    fn it_returns_an_error_if_one_of_the_names_is_wrong(lints: Vec<String>) -> TestResult {
226        if lints.is_empty() {
227            return TestResult::discard();
228        }
229
230        let actual: Result<Lints, Error> = lints
231            .iter()
232            .map(Borrow::borrow)
233            .collect::<Vec<&str>>()
234            .try_into();
235
236        TestResult::from_bool(actual.is_err())
237    }
238
239    #[allow(clippy::needless_pass_by_value)]
240    #[quickcheck]
241    fn no_lint_segfaults(lint: Lint, commit: String) -> TestResult {
242        let _ = lint.lint(&commit.into());
243
244        TestResult::passed()
245    }
246
247    #[test]
248    fn example_it_returns_an_error_if_one_of_the_names_is_wrong() {
249        let lints = vec![
250            "pivotal-tracker-id-missing",
251            "broken",
252            "jira-issue-key-missing",
253        ];
254        let actual: Result<Lints, Error> = lints.try_into();
255
256        actual.unwrap_err();
257    }
258
259    #[test]
260    fn test_try_from_preserves_all_lints_from_names() {
261        // Regression test: previously used O(n²) [vec, vec].concat() allocation.
262        // This test verifies that all lint names round-trip correctly through
263        // TryFrom<Vec<&str>>, which would fail if the replacement had a bug.
264        let all_names: Vec<&str> = Lint::all_lints().map(Lint::name).collect();
265        let result: Lints = all_names
266            .try_into()
267            .expect("All lint names should be valid");
268        let expected = Lints::new(Lint::all_lints().collect());
269        assert_eq!(
270            result, expected,
271            "All lints should round-trip through name parsing"
272        );
273    }
274
275    #[test]
276    fn test_try_from_preserves_duplicate_names_as_unique() {
277        // Duplicate names should result in a single lint entry (BTreeSet dedup)
278        let names = vec![
279            "duplicated-trailers",
280            "duplicated-trailers",
281            "jira-issue-key-missing",
282        ];
283        let result: Lints = names.try_into().expect("Valid lint names should parse");
284        let mut expected_set = BTreeSet::new();
285        expected_set.insert(Lint::DuplicatedTrailers);
286        expected_set.insert(Lint::JiraIssueKeyMissing);
287        assert_eq!(result, Lints::new(expected_set));
288    }
289
290    #[test]
291    fn test_try_from_empty_vec_produces_empty_lints() {
292        let names: Vec<&str> = vec![];
293        let result: Lints = names
294            .try_into()
295            .expect("Empty vec should produce empty Lints");
296        assert!(result.into_iter().next().is_none());
297    }
298
299    #[quickcheck]
300    fn it_can_construct_itself_from_names(lints: Vec<Lint>) -> bool {
301        let lint_names: Vec<&str> = lints.clone().into_iter().map(Lint::name).collect();
302
303        let expected_lints = lints.into_iter().collect::<BTreeSet<Lint>>();
304        let expected = Lints::new(expected_lints);
305
306        let actual: Lints = lint_names.try_into().expect("Lints to have been parsed");
307
308        expected == actual
309    }
310
311    #[test]
312    fn example_it_can_construct_itself_from_names() {
313        let lints = vec!["pivotal-tracker-id-missing", "jira-issue-key-missing"];
314
315        let mut expected_lints = BTreeSet::new();
316        expected_lints.insert(PivotalTrackerIdMissing);
317        expected_lints.insert(JiraIssueKeyMissing);
318
319        let expected = Lints::new(expected_lints);
320        let actual: Lints = lints.try_into().expect("Lints to have been parsed");
321
322        assert_eq!(expected, actual);
323    }
324
325    #[quickcheck]
326    fn it_can_give_me_an_into_iterator(lint_vec: Vec<Lint>) -> bool {
327        let lints = lint_vec.into_iter().collect::<BTreeSet<_>>();
328        let input = Lints::new(lints.clone());
329
330        let expected = lints.into_iter().collect::<Vec<_>>();
331        let actual = input.into_iter().collect::<Vec<_>>();
332
333        expected == actual
334    }
335
336    #[test]
337    fn example_it_can_give_me_an_into_iterator() {
338        let mut lints = BTreeSet::new();
339        lints.insert(PivotalTrackerIdMissing);
340        lints.insert(JiraIssueKeyMissing);
341        let input = Lints::new(lints);
342
343        let expected = vec![PivotalTrackerIdMissing, JiraIssueKeyMissing];
344        let actual = input.into_iter().collect::<Vec<_>>();
345
346        assert_eq!(expected, actual);
347    }
348
349    #[quickcheck]
350    fn it_can_convert_into_a_vec(lint_vec: Vec<Lint>) -> bool {
351        let lints = lint_vec.into_iter().collect::<BTreeSet<_>>();
352        let input = Lints::new(lints.clone());
353
354        let expected = lints.into_iter().collect::<Vec<_>>();
355        let actual: Vec<_> = input.into();
356
357        expected == actual
358    }
359
360    #[test]
361    fn example_it_can_convert_into_a_vec() {
362        let mut lints = BTreeSet::new();
363        lints.insert(PivotalTrackerIdMissing);
364        lints.insert(JiraIssueKeyMissing);
365        let input = Lints::new(lints);
366
367        let expected = vec![PivotalTrackerIdMissing, JiraIssueKeyMissing];
368        let actual: Vec<Lint> = input.into();
369
370        assert_eq!(expected, actual);
371    }
372
373    #[quickcheck]
374    fn it_can_give_me_the_names(lints: BTreeSet<Lint>) -> bool {
375        let lint_names: Vec<&str> = lints.clone().into_iter().map(Lint::name).collect();
376        let actual = Lints::from(lints.into_iter().collect::<Vec<Lint>>()).names();
377
378        lint_names == actual
379    }
380
381    #[test]
382    fn example_it_can_give_me_the_names() {
383        let mut lints = BTreeSet::new();
384        lints.insert(PivotalTrackerIdMissing);
385        lints.insert(JiraIssueKeyMissing);
386        let input = Lints::new(lints);
387
388        let expected = vec![PivotalTrackerIdMissing.name(), JiraIssueKeyMissing.name()];
389        let actual = input.names();
390
391        assert_eq!(expected, actual);
392    }
393
394    #[quickcheck]
395    fn it_can_give_me_the_config_keys(lints: BTreeSet<Lint>) -> bool {
396        let lint_names: Vec<String> = lints.clone().into_iter().map(Lint::config_key).collect();
397        let actual = Lints::from(lints.into_iter().collect::<Vec<Lint>>()).config_keys();
398
399        lint_names == actual
400    }
401
402    #[test]
403    fn example_it_can_give_me_the_config_keys() {
404        let mut lints = BTreeSet::new();
405        lints.insert(PivotalTrackerIdMissing);
406        lints.insert(JiraIssueKeyMissing);
407        let input = Lints::new(lints);
408
409        let expected = vec![
410            PivotalTrackerIdMissing.config_key(),
411            JiraIssueKeyMissing.config_key(),
412        ];
413        let actual = input.config_keys();
414
415        assert_eq!(expected, actual);
416    }
417
418    #[test]
419    fn can_get_all() {
420        let actual = Lints::available();
421        let lints = Lint::all_lints().collect();
422        let expected = &Lints::new(lints);
423
424        assert_eq!(
425            expected, actual,
426            "Expected all the lints to be {expected:?}, instead got {actual:?}"
427        );
428    }
429
430    #[test]
431    fn example_can_get_all() {
432        let actual = Lints::available();
433        let lints = Lint::all_lints().collect();
434        let expected = &Lints::new(lints);
435
436        assert_eq!(
437            expected, actual,
438            "Expected all the lints to be {expected:?}, instead got {actual:?}"
439        );
440    }
441
442    #[allow(clippy::needless_pass_by_value)]
443    #[quickcheck]
444    fn get_toml(expected: BTreeMap<Lint, bool>) -> bool {
445        let toml = String::try_from(Lints::new(
446            expected
447                .iter()
448                .filter(|(_, enabled)| **enabled)
449                .map(|(lint, _)| *lint)
450                .collect(),
451        ))
452        .expect("To be able to convert lints to toml");
453        let full: BTreeMap<String, BTreeMap<String, BTreeMap<String, bool>>> =
454            toml::from_str(toml.as_str()).unwrap();
455        let actual: BTreeMap<Lint, bool> = full
456            .get("mit")
457            .and_then(|x| x.get("lint"))
458            .expect("To have successfully removed the wrapping keys")
459            .iter()
460            .map(|(lint, enabled)| (Lint::try_from(lint.as_str()).unwrap(), *enabled))
461            .collect();
462
463        actual.iter().all(|(actual_key, actual_enabled)| {
464            expected
465                .get(actual_key)
466                .map_or(!*actual_enabled, |expected_enabled| {
467                    expected_enabled == actual_enabled
468                })
469        })
470    }
471
472    #[test]
473    fn example_get_toml() {
474        let mut lints_on = BTreeSet::new();
475        lints_on.insert(DuplicatedTrailers);
476        lints_on.insert(SubjectNotSeparateFromBody);
477        lints_on.insert(SubjectLongerThan72Characters);
478        lints_on.insert(BodyWiderThan72Characters);
479        lints_on.insert(PivotalTrackerIdMissing);
480        let actual = String::try_from(Lints::new(lints_on)).expect("Failed to serialise");
481        let expected = "[mit.lint]
482body-wider-than-72-characters = true
483duplicated-trailers = true
484github-id-missing = false
485jira-issue-key-missing = false
486not-conventional-commit = false
487not-emoji-log = false
488pivotal-tracker-id-missing = true
489subject-line-ends-with-period = false
490subject-line-not-capitalized = false
491subject-longer-than-72-characters = true
492subject-not-separated-from-body = true
493";
494
495        assert_eq!(
496            expected, actual,
497            "Expected the list of lint identifiers to be {expected:?}, instead got {actual:?}"
498        );
499    }
500
501    #[allow(clippy::needless_pass_by_value)]
502    #[quickcheck]
503    fn two_sets_of_lints_can_be_merged(
504        set_a_lints: BTreeSet<Lint>,
505        set_b_lints: BTreeSet<Lint>,
506    ) -> bool {
507        let set_a = Lints::new(set_a_lints.clone());
508        let set_b = Lints::new(set_b_lints.clone());
509
510        let actual = set_a.merge(&set_b);
511
512        let expected = Lints::new(set_a_lints.union(&set_b_lints).copied().collect());
513
514        expected == actual
515    }
516
517    #[test]
518    fn example_two_sets_of_lints_can_be_merged() {
519        let mut set_a_lints = BTreeSet::new();
520        set_a_lints.insert(PivotalTrackerIdMissing);
521
522        let mut set_b_lints = BTreeSet::new();
523        set_b_lints.insert(DuplicatedTrailers);
524
525        let set_a = Lints::new(set_a_lints);
526        let set_b = Lints::new(set_b_lints);
527
528        let actual = set_a.merge(&set_b);
529
530        let mut lints = BTreeSet::new();
531        lints.insert(DuplicatedTrailers);
532        lints.insert(PivotalTrackerIdMissing);
533        let expected = Lints::new(lints);
534
535        assert_eq!(
536            expected, actual,
537            "Expected the list of lint identifiers to be {expected:?}, instead got {actual:?}"
538        );
539    }
540
541    #[allow(clippy::needless_pass_by_value)]
542    #[quickcheck]
543    fn we_can_subtract_lints_from_the_lint_list(
544        set_a_lints: BTreeSet<Lint>,
545        set_b_lints: BTreeSet<Lint>,
546    ) -> bool {
547        let total = Lints::new(set_a_lints.union(&set_b_lints).copied().collect());
548        let set_a = Lints::new(set_a_lints.difference(&set_b_lints).copied().collect());
549        let expected = Lints::new(set_b_lints);
550
551        let actual = total.subtract(&set_a);
552
553        expected == actual
554    }
555
556    #[test]
557    fn example_we_can_subtract_lints_from_the_lint_list() {
558        let mut set_a_lints = BTreeSet::new();
559        set_a_lints.insert(JiraIssueKeyMissing);
560        set_a_lints.insert(PivotalTrackerIdMissing);
561
562        let mut set_b_lints = BTreeSet::new();
563        set_b_lints.insert(DuplicatedTrailers);
564        set_b_lints.insert(PivotalTrackerIdMissing);
565
566        let set_a = Lints::new(set_a_lints);
567        let set_b = Lints::new(set_b_lints);
568
569        let actual = set_a.subtract(&set_b);
570
571        let mut lints = BTreeSet::new();
572        lints.insert(JiraIssueKeyMissing);
573        let expected = Lints::new(lints);
574
575        assert_eq!(
576            expected, actual,
577            "Expected the list of lint identifiers to be {expected:?}, instead got {actual:?}"
578        );
579    }
580
581    #[test]
582    fn example_when_merging_overlapping_does_not_lead_to_duplication() {
583        let mut set_a_lints = BTreeSet::new();
584        set_a_lints.insert(PivotalTrackerIdMissing);
585
586        let mut set_b_lints = BTreeSet::new();
587        set_b_lints.insert(DuplicatedTrailers);
588        set_b_lints.insert(PivotalTrackerIdMissing);
589
590        let set_a = Lints::new(set_a_lints);
591        let set_b = Lints::new(set_b_lints);
592
593        let actual = set_a.merge(&set_b);
594
595        let mut lints = BTreeSet::new();
596        lints.insert(DuplicatedTrailers);
597        lints.insert(PivotalTrackerIdMissing);
598        let expected = Lints::new(lints);
599
600        assert_eq!(
601            expected, actual,
602            "Expected the list of lint identifiers to be {expected:?}, instead got {actual:?}"
603        );
604    }
605}