1use std::fmt;
12use std::str::FromStr;
13
14use serde::{Deserialize, Deserializer, Serialize, Serializer};
15use thiserror::Error;
16
17#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
29pub struct Namespace(String);
30
31impl Namespace {
32 pub const LANEKEEP: &'static str = "lanekeep";
34 pub const LOCAL: &'static str = "local";
36
37 #[must_use]
39 pub fn as_str(&self) -> &str {
40 &self.0
41 }
42
43 #[must_use]
45 pub fn is_built_in(&self) -> bool {
46 self.0 == Self::LANEKEEP || self.0 == Self::LOCAL
47 }
48
49 #[must_use]
51 pub fn is_lanekeep(&self) -> bool {
52 self.0 == Self::LANEKEEP
53 }
54
55 #[must_use]
57 pub const fn built_ins() -> &'static [&'static str] {
58 &[Self::LANEKEEP, Self::LOCAL]
59 }
60}
61
62impl fmt::Display for Namespace {
63 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64 f.write_str(&self.0)
65 }
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Error)]
73pub enum ParseRuleIdError {
74 #[error(
76 "rule ID `{0}` has no namespace: write `lanekeep/{0}` for a built-in rule or \
77 `local/{0}` for one defined in this project"
78 )]
79 MissingNamespace(String),
80
81 #[error("rule ID `{0}` contains more than one `/`")]
83 TooManySeparators(String),
84
85 #[error("invalid rule namespace `{name}` in `{id}`: {reason}")]
87 InvalidNamespace {
88 name: String,
90 id: String,
92 reason: &'static str,
94 },
95
96 #[error("rule ID `{0}` has an empty name")]
98 EmptyName(String),
99
100 #[error("invalid rule name `{name}` in `{id}`: {reason}")]
102 InvalidName {
103 name: String,
105 id: String,
107 reason: &'static str,
109 },
110}
111
112#[derive(Debug, Clone, PartialEq, Eq, Hash)]
116pub struct RuleId {
117 namespace: Namespace,
118 name: String,
119}
120
121impl RuleId {
122 #[must_use]
124 pub const fn namespace(&self) -> &Namespace {
125 &self.namespace
126 }
127
128 #[must_use]
130 pub fn name(&self) -> &str {
131 &self.name
132 }
133
134 #[must_use]
136 pub fn is_built_in(&self) -> bool {
137 self.namespace.is_lanekeep()
138 }
139
140 pub fn new(namespace: Namespace, name: &str) -> Result<Self, ParseRuleIdError> {
146 let id = format!("{}/{name}", namespace.as_str());
147 validate_name(name, &id)?;
148 Ok(Self {
149 namespace,
150 name: name.to_owned(),
151 })
152 }
153
154 pub fn namespace_from_str(namespace: &str) -> Result<Namespace, ParseRuleIdError> {
160 let id = format!("{namespace}/x");
161 validate_name(namespace, &id).map_err(|e| match e {
162 ParseRuleIdError::InvalidName { name, id, reason } => {
163 ParseRuleIdError::InvalidNamespace { name, id, reason }
164 }
165 other => other,
166 })?;
167 Ok(Namespace(namespace.to_owned()))
168 }
169}
170
171fn validate_name(name: &str, id: &str) -> Result<(), ParseRuleIdError> {
179 if name.is_empty() {
180 return Err(ParseRuleIdError::EmptyName(id.to_owned()));
181 }
182
183 let invalid = |reason: &'static str| {
184 Err(ParseRuleIdError::InvalidName {
185 name: name.to_owned(),
186 id: id.to_owned(),
187 reason,
188 })
189 };
190
191 if !name.is_ascii() {
192 return invalid("only ASCII letters, digits and hyphens are allowed");
193 }
194 if name.chars().any(|c| c.is_ascii_uppercase()) {
195 return invalid("must be lowercase");
196 }
197 if !name
198 .chars()
199 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
200 {
201 return invalid("only lowercase letters, digits and hyphens are allowed");
202 }
203 if name.starts_with('-') || name.ends_with('-') {
204 return invalid("must not start or end with a hyphen");
205 }
206 if name.contains("--") {
207 return invalid("must not contain consecutive hyphens");
208 }
209
210 Ok(())
211}
212
213impl FromStr for RuleId {
214 type Err = ParseRuleIdError;
215
216 fn from_str(s: &str) -> Result<Self, Self::Err> {
217 let mut parts = s.split('/');
218 let (Some(namespace), Some(name)) = (parts.next(), parts.next()) else {
219 return Err(ParseRuleIdError::MissingNamespace(s.to_owned()));
220 };
221 if parts.next().is_some() {
222 return Err(ParseRuleIdError::TooManySeparators(s.to_owned()));
223 }
224
225 if namespace.is_empty() {
229 return Err(ParseRuleIdError::MissingNamespace(s.to_owned()));
230 }
231 validate_name(namespace, s).map_err(|e| match e {
232 ParseRuleIdError::InvalidName { name, id, reason } => {
233 ParseRuleIdError::InvalidNamespace { name, id, reason }
234 }
235 other => other,
236 })?;
237
238 validate_name(name, s)?;
239 Ok(Self {
240 namespace: Namespace(namespace.to_owned()),
241 name: name.to_owned(),
242 })
243 }
244}
245
246impl fmt::Display for RuleId {
247 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
248 write!(f, "{}/{}", self.namespace.as_str(), self.name)
249 }
250}
251
252impl Ord for RuleId {
261 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
262 self.namespace
263 .as_str()
264 .cmp(other.namespace.as_str())
265 .then_with(|| self.name.cmp(&other.name))
266 }
267}
268
269impl PartialOrd for RuleId {
270 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
271 Some(self.cmp(other))
272 }
273}
274
275impl Serialize for RuleId {
276 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
277 serializer.collect_str(self)
278 }
279}
280
281impl<'de> Deserialize<'de> for RuleId {
282 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
283 let raw = String::deserialize(deserializer)?;
284 raw.parse().map_err(serde::de::Error::custom)
285 }
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291
292 fn local() -> Namespace {
293 RuleId::namespace_from_str("local").expect("valid")
294 }
295
296 fn lanekeep_ns() -> Namespace {
297 RuleId::namespace_from_str("lanekeep").expect("valid")
298 }
299
300 fn parse(s: &str) -> Result<RuleId, ParseRuleIdError> {
301 s.parse()
302 }
303
304 #[test]
305 fn parses_a_built_in_id() {
306 let id = parse("lanekeep/no-default-export").expect("valid");
307 assert!(id.namespace().is_lanekeep());
308 assert_eq!(id.name(), "no-default-export");
309 assert!(id.is_built_in());
310 }
311
312 #[test]
313 fn parses_a_project_id() {
314 let id = parse("local/no-numeric-sizes").expect("valid");
315 assert_eq!(id.namespace().as_str(), "local");
316 assert_eq!(id.name(), "no-numeric-sizes");
317 assert!(!id.is_built_in());
318 }
319
320 #[test]
321 fn accepts_digits_in_names() {
322 assert!(parse("local/no-utf8-bom").is_ok());
323 assert!(parse("local/rule2").is_ok());
324 }
325
326 #[test]
327 fn round_trips_through_display() {
328 for raw in ["lanekeep/no-default-export", "local/a", "local/x-1-y"] {
329 let id = parse(raw).expect("valid");
330 assert_eq!(id.to_string(), raw);
331 assert_eq!(parse(&id.to_string()).expect("valid"), id);
332 }
333 }
334
335 #[test]
336 fn rejects_a_bare_name() {
337 let err = parse("no-default-export").expect_err("must be namespaced");
338 assert!(matches!(err, ParseRuleIdError::MissingNamespace(_)));
339 let msg = err.to_string();
342 assert!(msg.contains("lanekeep/no-default-export"), "{msg}");
343 assert!(msg.contains("local/no-default-export"), "{msg}");
344 }
345
346 #[test]
350 fn accepts_a_project_namespace() {
351 let id = parse("pera/no-numeric-sizes").expect("a team may use its own namespace");
352 assert_eq!(id.namespace().as_str(), "pera");
353 assert!(!id.is_built_in());
354 }
355
356 #[test]
357 fn rejects_a_malformed_namespace() {
358 for bad in ["Pera/no-x", "pera_wallet/no-x", "-pera/no-x", "/no-x"] {
359 assert!(
360 parse(bad).is_err(),
361 "`{bad}` is not shaped like a namespace and should be refused"
362 );
363 }
364 }
365
366 #[test]
367 fn rejects_extra_separators() {
368 let err = parse("local/nested/rule").expect_err("one separator only");
369 assert!(matches!(err, ParseRuleIdError::TooManySeparators(_)));
370 }
371
372 #[test]
373 fn rejects_empty_parts() {
374 assert!(matches!(
375 parse("local/"),
376 Err(ParseRuleIdError::EmptyName(_))
377 ));
378 assert!(matches!(
379 parse("/rule"),
380 Err(ParseRuleIdError::MissingNamespace(_))
381 ));
382 assert!(matches!(
383 parse(""),
384 Err(ParseRuleIdError::MissingNamespace(_))
385 ));
386 assert!(matches!(
387 parse("/"),
388 Err(ParseRuleIdError::MissingNamespace(_))
389 ));
390 }
391
392 #[test]
393 fn rejects_names_that_are_not_kebab_case() {
394 for bad in [
397 "No-Default-Export",
398 "no_default_export",
399 "no default export",
400 "-leading",
401 "trailing-",
402 "double--hyphen",
403 "no.default.export",
404 "café",
405 "rule!",
406 ] {
407 let raw = format!("local/{bad}");
408 assert!(parse(&raw).is_err(), "should have rejected {raw}");
409 }
410 }
411
412 #[test]
413 fn constructor_validates_the_same_way_as_parsing() {
414 assert!(RuleId::new(local(), "ok-name").is_ok());
415 assert!(RuleId::new(local(), "Bad_Name").is_err());
416 assert!(RuleId::new(local(), "").is_err());
417
418 let built = RuleId::new(lanekeep_ns(), "no-default-export").expect("valid");
419 let parsed = parse("lanekeep/no-default-export").expect("valid");
420 assert_eq!(built, parsed);
421 }
422
423 #[test]
424 fn orders_by_rendered_string() {
425 let mut ids: Vec<RuleId> = ["local/b", "lanekeep/z", "local/a", "lanekeep/a"]
426 .iter()
427 .map(|s| parse(s).expect("valid"))
428 .collect();
429 ids.sort();
430
431 let rendered: Vec<String> = ids.iter().map(ToString::to_string).collect();
432 assert_eq!(rendered, ["lanekeep/a", "lanekeep/z", "local/a", "local/b"]);
433 }
434
435 #[test]
436 fn ordering_matches_string_ordering_exactly() {
437 let ids: Vec<RuleId> = [
441 "lanekeep/a",
442 "lanekeep/no-default-export",
443 "local/a",
444 "local/zzz",
445 "lanekeep/zzz",
446 ]
447 .iter()
448 .map(|s| parse(s).expect("valid"))
449 .collect();
450
451 for a in &ids {
452 for b in &ids {
453 assert_eq!(
454 a.cmp(b),
455 a.to_string().cmp(&b.to_string()),
456 "ordering disagreed for {a} vs {b}"
457 );
458 }
459 }
460 }
461
462 #[test]
463 fn serializes_as_a_plain_string() {
464 let id = parse("lanekeep/no-default-export").expect("valid");
465 let json = serde_json::to_string(&id).expect("serializes");
466 assert_eq!(json, "\"lanekeep/no-default-export\"");
467
468 let back: RuleId = serde_json::from_str(&json).expect("deserializes");
469 assert_eq!(back, id);
470 }
471
472 #[test]
473 fn deserializing_rejects_an_invalid_id() {
474 let err = serde_json::from_str::<RuleId>("\"nonsense\"").expect_err("invalid");
477 assert!(err.to_string().contains("nonsense"), "{err}");
478 }
479}