use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Namespace(String);
impl Namespace {
pub const LANEKEEP: &'static str = "lanekeep";
pub const LOCAL: &'static str = "local";
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn is_built_in(&self) -> bool {
self.0 == Self::LANEKEEP || self.0 == Self::LOCAL
}
#[must_use]
pub fn is_lanekeep(&self) -> bool {
self.0 == Self::LANEKEEP
}
#[must_use]
pub const fn built_ins() -> &'static [&'static str] {
&[Self::LANEKEEP, Self::LOCAL]
}
}
impl fmt::Display for Namespace {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[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("invalid rule namespace `{name}` in `{id}`: {reason}")]
InvalidNamespace {
name: String,
id: String,
reason: &'static str,
},
#[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 fn is_built_in(&self) -> bool {
self.namespace.is_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(),
})
}
pub fn namespace_from_str(namespace: &str) -> Result<Namespace, ParseRuleIdError> {
let id = format!("{namespace}/x");
validate_name(namespace, &id).map_err(|e| match e {
ParseRuleIdError::InvalidName { name, id, reason } => {
ParseRuleIdError::InvalidNamespace { name, id, reason }
}
other => other,
})?;
Ok(Namespace(namespace.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()));
}
if namespace.is_empty() {
return Err(ParseRuleIdError::MissingNamespace(s.to_owned()));
}
validate_name(namespace, s).map_err(|e| match e {
ParseRuleIdError::InvalidName { name, id, reason } => {
ParseRuleIdError::InvalidNamespace { name, id, reason }
}
other => other,
})?;
validate_name(name, s)?;
Ok(Self {
namespace: Namespace(namespace.to_owned()),
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 local() -> Namespace {
RuleId::namespace_from_str("local").expect("valid")
}
fn lanekeep_ns() -> Namespace {
RuleId::namespace_from_str("lanekeep").expect("valid")
}
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!(id.namespace().is_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().as_str(), "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 accepts_a_project_namespace() {
let id = parse("pera/no-numeric-sizes").expect("a team may use its own namespace");
assert_eq!(id.namespace().as_str(), "pera");
assert!(!id.is_built_in());
}
#[test]
fn rejects_a_malformed_namespace() {
for bad in ["Pera/no-x", "pera_wallet/no-x", "-pera/no-x", "/no-x"] {
assert!(
parse(bad).is_err(),
"`{bad}` is not shaped like a namespace and should be refused"
);
}
}
#[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::MissingNamespace(_))
));
assert!(matches!(
parse(""),
Err(ParseRuleIdError::MissingNamespace(_))
));
assert!(matches!(
parse("/"),
Err(ParseRuleIdError::MissingNamespace(_))
));
}
#[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(local(), "ok-name").is_ok());
assert!(RuleId::new(local(), "Bad_Name").is_err());
assert!(RuleId::new(local(), "").is_err());
let built = RuleId::new(lanekeep_ns(), "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}");
}
}