Skip to main content

lanekeep_core/
card.rs

1//! The rule card.
2//!
3//! `message`, `remediation` and `examples` are mandatory fields on every rule. They are
4//! not documentation. They are the payload `lanekeep explain` prints, the agent reporter
5//! emits, and context injection feeds to a model so it learns the rule *before* generating
6//! rather than after.
7//!
8//! A rule whose card says only "this is not allowed" tells an agent nothing it can act on,
9//! and the whole premise of the tool is that the feedback loop closes.
10
11use serde::{Deserialize, Serialize};
12
13/// A matched pair showing the rule's point better than prose does.
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15pub struct Examples {
16    /// Code the rule reports.
17    pub bad: String,
18    /// The corresponding code it does not.
19    pub good: String,
20}
21
22/// Everything a reader — human or model — needs to act on a violation.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct RuleCard {
25    /// One line saying what is wrong. Not what to do about it.
26    pub message: String,
27    /// What to do instead. Specific enough to act on without reading the rule source.
28    pub remediation: String,
29    /// A bad/good pair.
30    pub examples: Examples,
31}
32
33/// Why a card is not usable.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum CardProblem {
36    /// `message` is empty or whitespace.
37    EmptyMessage,
38    /// `remediation` is empty or whitespace.
39    EmptyRemediation,
40    /// An example is empty or whitespace.
41    EmptyExample,
42    /// The bad and good examples are the same, so the pair demonstrates nothing.
43    IdenticalExamples,
44}
45
46impl RuleCard {
47    /// Check that the card can actually do its job.
48    ///
49    /// This is deliberately more than a null check. A card is the tool's output, and an
50    /// empty remediation produces a violation a reader cannot act on — which is
51    /// indistinguishable, from their side, from lanekeep being wrong.
52    ///
53    /// # Errors
54    ///
55    /// Returns every problem found, so a rule author fixes them in one pass rather than
56    /// one per run.
57    pub fn validate(&self) -> Result<(), Vec<CardProblem>> {
58        let mut problems = Vec::new();
59
60        if self.message.trim().is_empty() {
61            problems.push(CardProblem::EmptyMessage);
62        }
63        if self.remediation.trim().is_empty() {
64            problems.push(CardProblem::EmptyRemediation);
65        }
66        if self.examples.bad.trim().is_empty() || self.examples.good.trim().is_empty() {
67            problems.push(CardProblem::EmptyExample);
68        } else if self.examples.bad.trim() == self.examples.good.trim() {
69            problems.push(CardProblem::IdenticalExamples);
70        }
71
72        if problems.is_empty() {
73            Ok(())
74        } else {
75            Err(problems)
76        }
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    fn card(message: &str, remediation: &str, bad: &str, good: &str) -> RuleCard {
85        RuleCard {
86            message: message.to_owned(),
87            remediation: remediation.to_owned(),
88            examples: Examples {
89                bad: bad.to_owned(),
90                good: good.to_owned(),
91            },
92        }
93    }
94
95    fn valid() -> RuleCard {
96        card(
97            "Literal numeric size inside makeStyles",
98            "Use theme.spacing.* instead",
99            "padding: 12",
100            "padding: theme.spacing.md",
101        )
102    }
103
104    #[test]
105    fn accepts_a_complete_card() {
106        assert_eq!(valid().validate(), Ok(()));
107    }
108
109    #[test]
110    fn rejects_an_empty_message() {
111        let mut c = valid();
112        c.message = String::new();
113        assert_eq!(c.validate(), Err(vec![CardProblem::EmptyMessage]));
114    }
115
116    #[test]
117    fn rejects_an_empty_remediation() {
118        let mut c = valid();
119        c.remediation = String::new();
120        assert_eq!(c.validate(), Err(vec![CardProblem::EmptyRemediation]));
121    }
122
123    #[test]
124    fn treats_whitespace_as_empty() {
125        // A card with `remediation: "   "` passes a null check and fails a reader.
126        let c = card("  ", "\t\n", " ", "  ");
127        let problems = c.validate().expect_err("should reject");
128        assert!(problems.contains(&CardProblem::EmptyMessage));
129        assert!(problems.contains(&CardProblem::EmptyRemediation));
130        assert!(problems.contains(&CardProblem::EmptyExample));
131    }
132
133    #[test]
134    fn rejects_examples_that_demonstrate_nothing() {
135        let c = card("msg", "fix", "padding: 12", "padding: 12");
136        assert_eq!(c.validate(), Err(vec![CardProblem::IdenticalExamples]));
137    }
138
139    #[test]
140    fn compares_examples_ignoring_surrounding_whitespace() {
141        // Copy-paste into a YAML block or a template literal picks up indentation, and
142        // "identical apart from leading spaces" is still a pair that shows nothing.
143        let c = card("msg", "fix", "  padding: 12  ", "padding: 12");
144        assert_eq!(c.validate(), Err(vec![CardProblem::IdenticalExamples]));
145    }
146
147    #[test]
148    fn reports_every_problem_at_once() {
149        // One problem per run would make fixing a bad card an N-round-trip exercise.
150        let c = card("", "", "", "");
151        let problems = c.validate().expect_err("should reject");
152        assert_eq!(problems.len(), 3);
153    }
154
155    #[test]
156    fn empty_examples_are_reported_instead_of_identical() {
157        // Both conditions hold when the examples are two empty strings. Reporting
158        // "identical" there would be technically true and useless.
159        let c = card("msg", "fix", "", "");
160        assert_eq!(c.validate(), Err(vec![CardProblem::EmptyExample]));
161    }
162
163    #[test]
164    fn round_trips_through_json() {
165        let c = valid();
166        let json = serde_json::to_string(&c).expect("serializes");
167        let back: RuleCard = serde_json::from_str(&json).expect("deserializes");
168        assert_eq!(back, c);
169    }
170}