Skip to main content

skif_core/
rule.rs

1//! Rule identity and static rule metadata.
2
3use std::borrow::Cow;
4use std::fmt;
5
6use serde::{Deserialize, Serialize};
7
8/// Stable kebab-case identifier of a rule, e.g. `unused-dependency`.
9///
10/// Deserializing validates the identifier, exactly like [`RuleId::new`].
11#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize)]
12#[serde(try_from = "String")]
13#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
14#[cfg_attr(feature = "schema", schemars(with = "String"))]
15pub struct RuleId(Cow<'static, str>);
16
17impl Serialize for RuleId {
18    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
19        serializer.serialize_str(self.as_str())
20    }
21}
22
23impl TryFrom<String> for RuleId {
24    type Error = InvalidRuleId;
25
26    fn try_from(id: String) -> Result<Self, Self::Error> {
27        Self::new(id)
28    }
29}
30
31/// Error returned by [`RuleId::new`] when the identifier is not kebab-case.
32#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
33#[error("invalid rule id {0:?}: expected kebab-case `[a-z0-9]+(-[a-z0-9]+)*`")]
34pub struct InvalidRuleId(pub String);
35
36impl RuleId {
37    /// Wraps a static identifier without validation; intended for `const` rule tables.
38    #[must_use]
39    pub const fn from_static(id: &'static str) -> Self {
40        Self(Cow::Borrowed(id))
41    }
42
43    /// Creates a rule id, rejecting anything that is not `[a-z0-9]+(-[a-z0-9]+)*`.
44    ///
45    /// # Errors
46    ///
47    /// Returns [`InvalidRuleId`] when `id` is empty or contains characters or separators outside kebab-case.
48    pub fn new(id: impl Into<String>) -> Result<Self, InvalidRuleId> {
49        let id = id.into();
50        if is_kebab_case(&id) {
51            Ok(Self(Cow::Owned(id)))
52        } else {
53            Err(InvalidRuleId(id))
54        }
55    }
56
57    /// The identifier as a string slice.
58    #[must_use]
59    pub fn as_str(&self) -> &str {
60        &self.0
61    }
62}
63
64impl fmt::Display for RuleId {
65    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66        f.write_str(self.as_str())
67    }
68}
69
70fn is_kebab_case(id: &str) -> bool {
71    id.split('-')
72        .all(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()))
73}
74
75/// How serious a diagnostic is. Ordered `Info < Warning < Error`.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
77#[serde(rename_all = "kebab-case")]
78#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
79#[non_exhaustive]
80pub enum Severity {
81    /// Informational finding.
82    Info,
83    /// Should be fixed but does not fail the run by default.
84    Warning,
85    /// Fails the run.
86    Error,
87}
88
89/// Area of the workspace a rule inspects.
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
91#[serde(rename_all = "kebab-case")]
92#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
93#[non_exhaustive]
94pub enum Category {
95    /// Workspace layout and configuration.
96    Workspace,
97    /// Individual `package.json` manifests.
98    Manifest,
99    /// Cross-package policies such as version consistency.
100    Policy,
101    /// Catalog entries.
102    Catalog,
103    /// JavaScript and TypeScript sources.
104    Js,
105}
106
107/// Whether, and how safely, a rule can repair its findings.
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
109#[serde(rename_all = "kebab-case")]
110#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
111#[non_exhaustive]
112pub enum Fixability {
113    /// No fix is available.
114    None,
115    /// Fixes are always safe.
116    Safe,
117    /// Fixes may change behavior.
118    Unsafe,
119    /// Depends on the individual finding.
120    Sometimes,
121}
122
123/// Static description of a rule, used for documentation and listings.
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
125#[serde(rename_all = "kebab-case")]
126#[non_exhaustive]
127pub struct RuleMeta {
128    /// Rule identifier.
129    pub id: &'static str,
130    /// Area of the workspace the rule inspects.
131    pub category: Category,
132    /// Severity applied unless configured otherwise; `None` means the rule is off by default.
133    pub default: Option<Severity>,
134    /// Fix availability.
135    pub fixable: Fixability,
136    /// One-line description.
137    pub summary: &'static str,
138}
139
140impl RuleMeta {
141    /// Describes rule `id`.
142    #[must_use]
143    pub const fn new(
144        id: &'static str,
145        category: Category,
146        default: Option<Severity>,
147        fixable: Fixability,
148        summary: &'static str,
149    ) -> Self {
150        Self {
151            id,
152            category,
153            default,
154            fixable,
155            summary,
156        }
157    }
158}
159
160#[cfg(test)]
161#[allow(clippy::unwrap_used, clippy::expect_used)]
162mod tests {
163    use super::*;
164
165    #[test]
166    fn rule_id_validation_table() {
167        let cases = [
168            ("a", true),
169            ("unused-dependency", true),
170            ("a1", true),
171            ("1abc", true),
172            ("a-1-b", true),
173            ("", false),
174            ("-a", false),
175            ("a-", false),
176            ("a--b", false),
177            ("A", false),
178            ("aB", false),
179            ("a_b", false),
180            ("a b", false),
181            ("a.b", false),
182            ("é", false),
183        ];
184        for (id, valid) in cases {
185            assert_eq!(RuleId::new(id).is_ok(), valid, "id {id:?}");
186        }
187    }
188
189    #[test]
190    fn invalid_rule_id_reports_input() {
191        let err = RuleId::new("Bad_Id").unwrap_err();
192        assert_eq!(err, InvalidRuleId("Bad_Id".to_owned()));
193        assert!(err.to_string().contains("Bad_Id"));
194    }
195
196    #[test]
197    fn rule_id_accessors_agree() {
198        const ID: RuleId = RuleId::from_static("no-op");
199        assert_eq!(ID.as_str(), "no-op");
200        assert_eq!(ID.to_string(), "no-op");
201        assert_eq!(ID, RuleId::new("no-op").unwrap());
202    }
203
204    #[test]
205    fn rule_id_serde_is_transparent() {
206        let id = RuleId::from_static("unused-dependency");
207        assert_eq!(
208            serde_json::to_value(&id).unwrap(),
209            serde_json::json!("unused-dependency")
210        );
211        let back: RuleId = serde_json::from_str("\"unused-dependency\"").unwrap();
212        assert_eq!(back, id);
213    }
214
215    #[test]
216    fn rule_id_deserialization_validates() {
217        for bad in ["\"Bad_Id\"", "\"\"", "\"a--b\"", "\"-a\"", "\"é\""] {
218            let error = serde_json::from_str::<RuleId>(bad).unwrap_err();
219            assert!(error.to_string().contains("invalid rule id"), "{bad}: {error}");
220        }
221        assert!(serde_json::from_str::<RuleId>("3").is_err());
222        let ids: Vec<RuleId> = serde_json::from_str("[\"a\", \"b-1\"]").unwrap();
223        assert_eq!(ids, [RuleId::from_static("a"), RuleId::from_static("b-1")]);
224    }
225
226    #[test]
227    fn severity_orders_info_below_error() {
228        assert!(Severity::Info < Severity::Warning);
229        assert!(Severity::Warning < Severity::Error);
230        assert_eq!(
231            serde_json::to_value(Severity::Warning).unwrap(),
232            serde_json::json!("warning")
233        );
234    }
235
236    #[test]
237    fn rule_meta_serializes_kebab_case() {
238        let meta = RuleMeta {
239            id: "unused-dependency",
240            category: Category::Manifest,
241            default: Some(Severity::Error),
242            fixable: Fixability::Sometimes,
243            summary: "Unused dependency.",
244        };
245        assert_eq!(
246            serde_json::to_value(meta).unwrap(),
247            serde_json::json!({
248                "id": "unused-dependency",
249                "category": "manifest",
250                "default": "error",
251                "fixable": "sometimes",
252                "summary": "Unused dependency.",
253            })
254        );
255    }
256
257    #[test]
258    fn rule_meta_new_is_const_and_fills_every_field() {
259        const META: RuleMeta = RuleMeta::new("some-rule", Category::Manifest, None, Fixability::None, "Does a thing.");
260        assert_eq!(
261            (META.id, META.default, META.summary),
262            ("some-rule", None, "Does a thing.")
263        );
264    }
265}