use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use thiserror::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Namespace {
Lanekeep,
Local,
}
impl Namespace {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Lanekeep => "lanekeep",
Self::Local => "local",
}
}
#[must_use]
pub const fn all() -> &'static [Self] {
&[Self::Lanekeep, Self::Local]
}
}
impl fmt::Display for Namespace {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum ParseRuleIdError {
#[error(
"rule ID `{0}` has no namespace: write `lanekeep/{0}` for a built-in rule or \
`local/{0}` for one defined in this project"
)]
MissingNamespace(String),
#[error("rule ID `{0}` contains more than one `/`")]
TooManySeparators(String),
#[error("unknown rule namespace `{namespace}` in `{id}`: expected one of {expected}")]
UnknownNamespace {
namespace: String,
id: String,
expected: String,
},
#[error("rule ID `{0}` has an empty name")]
EmptyName(String),
#[error("invalid rule name `{name}` in `{id}`: {reason}")]
InvalidName {
name: String,
id: String,
reason: &'static str,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RuleId {
namespace: Namespace,
name: String,
}
impl RuleId {
#[must_use]
pub const fn namespace(&self) -> Namespace {
self.namespace
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub const fn is_built_in(&self) -> bool {
matches!(self.namespace, Namespace::Lanekeep)
}
pub fn new(namespace: Namespace, name: &str) -> Result<Self, ParseRuleIdError> {
let id = format!("{}/{name}", namespace.as_str());
validate_name(name, &id)?;
Ok(Self {
namespace,
name: name.to_owned(),
})
}
}
fn validate_name(name: &str, id: &str) -> Result<(), ParseRuleIdError> {
if name.is_empty() {
return Err(ParseRuleIdError::EmptyName(id.to_owned()));
}
let invalid = |reason: &'static str| {
Err(ParseRuleIdError::InvalidName {
name: name.to_owned(),
id: id.to_owned(),
reason,
})
};
if !name.is_ascii() {
return invalid("only ASCII letters, digits and hyphens are allowed");
}
if name.chars().any(|c| c.is_ascii_uppercase()) {
return invalid("must be lowercase");
}
if !name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return invalid("only lowercase letters, digits and hyphens are allowed");
}
if name.starts_with('-') || name.ends_with('-') {
return invalid("must not start or end with a hyphen");
}
if name.contains("--") {
return invalid("must not contain consecutive hyphens");
}
Ok(())
}
impl FromStr for RuleId {
type Err = ParseRuleIdError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut parts = s.split('/');
let (Some(namespace), Some(name)) = (parts.next(), parts.next()) else {
return Err(ParseRuleIdError::MissingNamespace(s.to_owned()));
};
if parts.next().is_some() {
return Err(ParseRuleIdError::TooManySeparators(s.to_owned()));
}
let namespace = match namespace {
"lanekeep" => Namespace::Lanekeep,
"local" => Namespace::Local,
other => {
let expected = Namespace::all()
.iter()
.map(|n| format!("`{}`", n.as_str()))
.collect::<Vec<_>>()
.join(", ");
return Err(ParseRuleIdError::UnknownNamespace {
namespace: other.to_owned(),
id: s.to_owned(),
expected,
});
}
};
validate_name(name, s)?;
Ok(Self {
namespace,
name: name.to_owned(),
})
}
}
impl fmt::Display for RuleId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}/{}", self.namespace.as_str(), self.name)
}
}
impl Ord for RuleId {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.namespace
.as_str()
.cmp(other.namespace.as_str())
.then_with(|| self.name.cmp(&other.name))
}
}
impl PartialOrd for RuleId {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Serialize for RuleId {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_str(self)
}
}
impl<'de> Deserialize<'de> for RuleId {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let raw = String::deserialize(deserializer)?;
raw.parse().map_err(serde::de::Error::custom)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(s: &str) -> Result<RuleId, ParseRuleIdError> {
s.parse()
}
#[test]
fn parses_a_built_in_id() {
let id = parse("lanekeep/no-default-export").expect("valid");
assert_eq!(id.namespace(), Namespace::Lanekeep);
assert_eq!(id.name(), "no-default-export");
assert!(id.is_built_in());
}
#[test]
fn parses_a_project_id() {
let id = parse("local/no-numeric-sizes").expect("valid");
assert_eq!(id.namespace(), Namespace::Local);
assert_eq!(id.name(), "no-numeric-sizes");
assert!(!id.is_built_in());
}
#[test]
fn accepts_digits_in_names() {
assert!(parse("local/no-utf8-bom").is_ok());
assert!(parse("local/rule2").is_ok());
}
#[test]
fn round_trips_through_display() {
for raw in ["lanekeep/no-default-export", "local/a", "local/x-1-y"] {
let id = parse(raw).expect("valid");
assert_eq!(id.to_string(), raw);
assert_eq!(parse(&id.to_string()).expect("valid"), id);
}
}
#[test]
fn rejects_a_bare_name() {
let err = parse("no-default-export").expect_err("must be namespaced");
assert!(matches!(err, ParseRuleIdError::MissingNamespace(_)));
let msg = err.to_string();
assert!(msg.contains("lanekeep/no-default-export"), "{msg}");
assert!(msg.contains("local/no-default-export"), "{msg}");
}
#[test]
fn rejects_an_unknown_namespace() {
let err = parse("lanekep/no-default-export").expect_err("typo in namespace");
match err {
ParseRuleIdError::UnknownNamespace {
namespace,
expected,
..
} => {
assert_eq!(namespace, "lanekep");
assert!(expected.contains("lanekeep"), "{expected}");
assert!(expected.contains("local"), "{expected}");
}
other => panic!("wrong error: {other:?}"),
}
}
#[test]
fn rejects_extra_separators() {
let err = parse("local/nested/rule").expect_err("one separator only");
assert!(matches!(err, ParseRuleIdError::TooManySeparators(_)));
}
#[test]
fn rejects_empty_parts() {
assert!(matches!(
parse("local/"),
Err(ParseRuleIdError::EmptyName(_))
));
assert!(matches!(
parse("/rule"),
Err(ParseRuleIdError::UnknownNamespace { .. })
));
assert!(matches!(
parse(""),
Err(ParseRuleIdError::MissingNamespace(_))
));
assert!(matches!(
parse("/"),
Err(ParseRuleIdError::UnknownNamespace { .. })
));
}
#[test]
fn rejects_names_that_are_not_kebab_case() {
for bad in [
"No-Default-Export",
"no_default_export",
"no default export",
"-leading",
"trailing-",
"double--hyphen",
"no.default.export",
"café",
"rule!",
] {
let raw = format!("local/{bad}");
assert!(parse(&raw).is_err(), "should have rejected {raw}");
}
}
#[test]
fn constructor_validates_the_same_way_as_parsing() {
assert!(RuleId::new(Namespace::Local, "ok-name").is_ok());
assert!(RuleId::new(Namespace::Local, "Bad_Name").is_err());
assert!(RuleId::new(Namespace::Local, "").is_err());
let built = RuleId::new(Namespace::Lanekeep, "no-default-export").expect("valid");
let parsed = parse("lanekeep/no-default-export").expect("valid");
assert_eq!(built, parsed);
}
#[test]
fn orders_by_rendered_string() {
let mut ids: Vec<RuleId> = ["local/b", "lanekeep/z", "local/a", "lanekeep/a"]
.iter()
.map(|s| parse(s).expect("valid"))
.collect();
ids.sort();
let rendered: Vec<String> = ids.iter().map(ToString::to_string).collect();
assert_eq!(rendered, ["lanekeep/a", "lanekeep/z", "local/a", "local/b"]);
}
#[test]
fn ordering_matches_string_ordering_exactly() {
let ids: Vec<RuleId> = [
"lanekeep/a",
"lanekeep/no-default-export",
"local/a",
"local/zzz",
"lanekeep/zzz",
]
.iter()
.map(|s| parse(s).expect("valid"))
.collect();
for a in &ids {
for b in &ids {
assert_eq!(
a.cmp(b),
a.to_string().cmp(&b.to_string()),
"ordering disagreed for {a} vs {b}"
);
}
}
}
#[test]
fn serializes_as_a_plain_string() {
let id = parse("lanekeep/no-default-export").expect("valid");
let json = serde_json::to_string(&id).expect("serializes");
assert_eq!(json, "\"lanekeep/no-default-export\"");
let back: RuleId = serde_json::from_str(&json).expect("deserializes");
assert_eq!(back, id);
}
#[test]
fn deserializing_rejects_an_invalid_id() {
let err = serde_json::from_str::<RuleId>("\"nonsense\"").expect_err("invalid");
assert!(err.to_string().contains("nonsense"), "{err}");
}
}