1use std::fmt;
12use std::str::FromStr;
13
14use serde::{Deserialize, Deserializer, Serialize, Serializer};
15use thiserror::Error;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
24pub enum Namespace {
25 Lanekeep,
27 Local,
29}
30
31impl Namespace {
32 #[must_use]
34 pub const fn as_str(self) -> &'static str {
35 match self {
36 Self::Lanekeep => "lanekeep",
37 Self::Local => "local",
38 }
39 }
40
41 #[must_use]
43 pub const fn all() -> &'static [Self] {
44 &[Self::Lanekeep, Self::Local]
45 }
46}
47
48impl fmt::Display for Namespace {
49 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50 f.write_str(self.as_str())
51 }
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Error)]
59pub enum ParseRuleIdError {
60 #[error(
62 "rule ID `{0}` has no namespace: write `lanekeep/{0}` for a built-in rule or \
63 `local/{0}` for one defined in this project"
64 )]
65 MissingNamespace(String),
66
67 #[error("rule ID `{0}` contains more than one `/`")]
69 TooManySeparators(String),
70
71 #[error("unknown rule namespace `{namespace}` in `{id}`: expected one of {expected}")]
73 UnknownNamespace {
74 namespace: String,
76 id: String,
78 expected: String,
80 },
81
82 #[error("rule ID `{0}` has an empty name")]
84 EmptyName(String),
85
86 #[error("invalid rule name `{name}` in `{id}`: {reason}")]
88 InvalidName {
89 name: String,
91 id: String,
93 reason: &'static str,
95 },
96}
97
98#[derive(Debug, Clone, PartialEq, Eq, Hash)]
102pub struct RuleId {
103 namespace: Namespace,
104 name: String,
105}
106
107impl RuleId {
108 #[must_use]
110 pub const fn namespace(&self) -> Namespace {
111 self.namespace
112 }
113
114 #[must_use]
116 pub fn name(&self) -> &str {
117 &self.name
118 }
119
120 #[must_use]
122 pub const fn is_built_in(&self) -> bool {
123 matches!(self.namespace, Namespace::Lanekeep)
124 }
125
126 pub fn new(namespace: Namespace, name: &str) -> Result<Self, ParseRuleIdError> {
132 let id = format!("{}/{name}", namespace.as_str());
133 validate_name(name, &id)?;
134 Ok(Self {
135 namespace,
136 name: name.to_owned(),
137 })
138 }
139}
140
141fn validate_name(name: &str, id: &str) -> Result<(), ParseRuleIdError> {
149 if name.is_empty() {
150 return Err(ParseRuleIdError::EmptyName(id.to_owned()));
151 }
152
153 let invalid = |reason: &'static str| {
154 Err(ParseRuleIdError::InvalidName {
155 name: name.to_owned(),
156 id: id.to_owned(),
157 reason,
158 })
159 };
160
161 if !name.is_ascii() {
162 return invalid("only ASCII letters, digits and hyphens are allowed");
163 }
164 if name.chars().any(|c| c.is_ascii_uppercase()) {
165 return invalid("must be lowercase");
166 }
167 if !name
168 .chars()
169 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
170 {
171 return invalid("only lowercase letters, digits and hyphens are allowed");
172 }
173 if name.starts_with('-') || name.ends_with('-') {
174 return invalid("must not start or end with a hyphen");
175 }
176 if name.contains("--") {
177 return invalid("must not contain consecutive hyphens");
178 }
179
180 Ok(())
181}
182
183impl FromStr for RuleId {
184 type Err = ParseRuleIdError;
185
186 fn from_str(s: &str) -> Result<Self, Self::Err> {
187 let mut parts = s.split('/');
188 let (Some(namespace), Some(name)) = (parts.next(), parts.next()) else {
189 return Err(ParseRuleIdError::MissingNamespace(s.to_owned()));
190 };
191 if parts.next().is_some() {
192 return Err(ParseRuleIdError::TooManySeparators(s.to_owned()));
193 }
194
195 let namespace = match namespace {
196 "lanekeep" => Namespace::Lanekeep,
197 "local" => Namespace::Local,
198 other => {
199 let expected = Namespace::all()
200 .iter()
201 .map(|n| format!("`{}`", n.as_str()))
202 .collect::<Vec<_>>()
203 .join(", ");
204 return Err(ParseRuleIdError::UnknownNamespace {
205 namespace: other.to_owned(),
206 id: s.to_owned(),
207 expected,
208 });
209 }
210 };
211
212 validate_name(name, s)?;
213 Ok(Self {
214 namespace,
215 name: name.to_owned(),
216 })
217 }
218}
219
220impl fmt::Display for RuleId {
221 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
222 write!(f, "{}/{}", self.namespace.as_str(), self.name)
223 }
224}
225
226impl Ord for RuleId {
235 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
236 self.namespace
237 .as_str()
238 .cmp(other.namespace.as_str())
239 .then_with(|| self.name.cmp(&other.name))
240 }
241}
242
243impl PartialOrd for RuleId {
244 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
245 Some(self.cmp(other))
246 }
247}
248
249impl Serialize for RuleId {
250 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
251 serializer.collect_str(self)
252 }
253}
254
255impl<'de> Deserialize<'de> for RuleId {
256 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
257 let raw = String::deserialize(deserializer)?;
258 raw.parse().map_err(serde::de::Error::custom)
259 }
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265
266 fn parse(s: &str) -> Result<RuleId, ParseRuleIdError> {
267 s.parse()
268 }
269
270 #[test]
271 fn parses_a_built_in_id() {
272 let id = parse("lanekeep/no-default-export").expect("valid");
273 assert_eq!(id.namespace(), Namespace::Lanekeep);
274 assert_eq!(id.name(), "no-default-export");
275 assert!(id.is_built_in());
276 }
277
278 #[test]
279 fn parses_a_project_id() {
280 let id = parse("local/no-numeric-sizes").expect("valid");
281 assert_eq!(id.namespace(), Namespace::Local);
282 assert_eq!(id.name(), "no-numeric-sizes");
283 assert!(!id.is_built_in());
284 }
285
286 #[test]
287 fn accepts_digits_in_names() {
288 assert!(parse("local/no-utf8-bom").is_ok());
289 assert!(parse("local/rule2").is_ok());
290 }
291
292 #[test]
293 fn round_trips_through_display() {
294 for raw in ["lanekeep/no-default-export", "local/a", "local/x-1-y"] {
295 let id = parse(raw).expect("valid");
296 assert_eq!(id.to_string(), raw);
297 assert_eq!(parse(&id.to_string()).expect("valid"), id);
298 }
299 }
300
301 #[test]
302 fn rejects_a_bare_name() {
303 let err = parse("no-default-export").expect_err("must be namespaced");
304 assert!(matches!(err, ParseRuleIdError::MissingNamespace(_)));
305 let msg = err.to_string();
308 assert!(msg.contains("lanekeep/no-default-export"), "{msg}");
309 assert!(msg.contains("local/no-default-export"), "{msg}");
310 }
311
312 #[test]
313 fn rejects_an_unknown_namespace() {
314 let err = parse("lanekep/no-default-export").expect_err("typo in namespace");
315 match err {
316 ParseRuleIdError::UnknownNamespace {
317 namespace,
318 expected,
319 ..
320 } => {
321 assert_eq!(namespace, "lanekep");
322 assert!(expected.contains("lanekeep"), "{expected}");
323 assert!(expected.contains("local"), "{expected}");
324 }
325 other => panic!("wrong error: {other:?}"),
326 }
327 }
328
329 #[test]
330 fn rejects_extra_separators() {
331 let err = parse("local/nested/rule").expect_err("one separator only");
332 assert!(matches!(err, ParseRuleIdError::TooManySeparators(_)));
333 }
334
335 #[test]
336 fn rejects_empty_parts() {
337 assert!(matches!(
338 parse("local/"),
339 Err(ParseRuleIdError::EmptyName(_))
340 ));
341 assert!(matches!(
342 parse("/rule"),
343 Err(ParseRuleIdError::UnknownNamespace { .. })
344 ));
345 assert!(matches!(
346 parse(""),
347 Err(ParseRuleIdError::MissingNamespace(_))
348 ));
349 assert!(matches!(
350 parse("/"),
351 Err(ParseRuleIdError::UnknownNamespace { .. })
352 ));
353 }
354
355 #[test]
356 fn rejects_names_that_are_not_kebab_case() {
357 for bad in [
360 "No-Default-Export",
361 "no_default_export",
362 "no default export",
363 "-leading",
364 "trailing-",
365 "double--hyphen",
366 "no.default.export",
367 "café",
368 "rule!",
369 ] {
370 let raw = format!("local/{bad}");
371 assert!(parse(&raw).is_err(), "should have rejected {raw}");
372 }
373 }
374
375 #[test]
376 fn constructor_validates_the_same_way_as_parsing() {
377 assert!(RuleId::new(Namespace::Local, "ok-name").is_ok());
378 assert!(RuleId::new(Namespace::Local, "Bad_Name").is_err());
379 assert!(RuleId::new(Namespace::Local, "").is_err());
380
381 let built = RuleId::new(Namespace::Lanekeep, "no-default-export").expect("valid");
382 let parsed = parse("lanekeep/no-default-export").expect("valid");
383 assert_eq!(built, parsed);
384 }
385
386 #[test]
387 fn orders_by_rendered_string() {
388 let mut ids: Vec<RuleId> = ["local/b", "lanekeep/z", "local/a", "lanekeep/a"]
389 .iter()
390 .map(|s| parse(s).expect("valid"))
391 .collect();
392 ids.sort();
393
394 let rendered: Vec<String> = ids.iter().map(ToString::to_string).collect();
395 assert_eq!(rendered, ["lanekeep/a", "lanekeep/z", "local/a", "local/b"]);
396 }
397
398 #[test]
399 fn ordering_matches_string_ordering_exactly() {
400 let ids: Vec<RuleId> = [
404 "lanekeep/a",
405 "lanekeep/no-default-export",
406 "local/a",
407 "local/zzz",
408 "lanekeep/zzz",
409 ]
410 .iter()
411 .map(|s| parse(s).expect("valid"))
412 .collect();
413
414 for a in &ids {
415 for b in &ids {
416 assert_eq!(
417 a.cmp(b),
418 a.to_string().cmp(&b.to_string()),
419 "ordering disagreed for {a} vs {b}"
420 );
421 }
422 }
423 }
424
425 #[test]
426 fn serializes_as_a_plain_string() {
427 let id = parse("lanekeep/no-default-export").expect("valid");
428 let json = serde_json::to_string(&id).expect("serializes");
429 assert_eq!(json, "\"lanekeep/no-default-export\"");
430
431 let back: RuleId = serde_json::from_str(&json).expect("deserializes");
432 assert_eq!(back, id);
433 }
434
435 #[test]
436 fn deserializing_rejects_an_invalid_id() {
437 let err = serde_json::from_str::<RuleId>("\"nonsense\"").expect_err("invalid");
440 assert!(err.to_string().contains("nonsense"), "{err}");
441 }
442}