1use std::borrow::Cow;
4use std::fmt;
5
6use serde::{Deserialize, Serialize};
7
8#[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#[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 #[must_use]
39 pub const fn from_static(id: &'static str) -> Self {
40 Self(Cow::Borrowed(id))
41 }
42
43 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 #[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#[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 Info,
83 Warning,
85 Error,
87}
88
89#[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,
97 Manifest,
99 Policy,
101 Catalog,
103 Js,
105}
106
107#[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 None,
115 Safe,
117 Unsafe,
119 Sometimes,
121}
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
125#[serde(rename_all = "kebab-case")]
126#[non_exhaustive]
127pub struct RuleMeta {
128 pub id: &'static str,
130 pub category: Category,
132 pub default: Option<Severity>,
134 pub fixable: Fixability,
136 pub summary: &'static str,
138}
139
140impl RuleMeta {
141 #[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}