use std::borrow::Cow;
use std::fmt;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize)]
#[serde(try_from = "String")]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schema", schemars(with = "String"))]
pub struct RuleId(Cow<'static, str>);
impl Serialize for RuleId {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.as_str())
}
}
impl TryFrom<String> for RuleId {
type Error = InvalidRuleId;
fn try_from(id: String) -> Result<Self, Self::Error> {
Self::new(id)
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("invalid rule id {0:?}: expected kebab-case `[a-z0-9]+(-[a-z0-9]+)*`")]
pub struct InvalidRuleId(pub String);
impl RuleId {
#[must_use]
pub const fn from_static(id: &'static str) -> Self {
Self(Cow::Borrowed(id))
}
pub fn new(id: impl Into<String>) -> Result<Self, InvalidRuleId> {
let id = id.into();
if is_kebab_case(&id) {
Ok(Self(Cow::Owned(id)))
} else {
Err(InvalidRuleId(id))
}
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for RuleId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
fn is_kebab_case(id: &str) -> bool {
id.split('-')
.all(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub enum Severity {
Info,
Warning,
Error,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub enum Category {
Workspace,
Manifest,
Policy,
Catalog,
Js,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub enum Fixability {
None,
Safe,
Unsafe,
Sometimes,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub struct RuleMeta {
pub id: &'static str,
pub category: Category,
pub default: Option<Severity>,
pub fixable: Fixability,
pub summary: &'static str,
}
impl RuleMeta {
#[must_use]
pub const fn new(
id: &'static str,
category: Category,
default: Option<Severity>,
fixable: Fixability,
summary: &'static str,
) -> Self {
Self {
id,
category,
default,
fixable,
summary,
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn rule_id_validation_table() {
let cases = [
("a", true),
("unused-dependency", true),
("a1", true),
("1abc", true),
("a-1-b", true),
("", false),
("-a", false),
("a-", false),
("a--b", false),
("A", false),
("aB", false),
("a_b", false),
("a b", false),
("a.b", false),
("é", false),
];
for (id, valid) in cases {
assert_eq!(RuleId::new(id).is_ok(), valid, "id {id:?}");
}
}
#[test]
fn invalid_rule_id_reports_input() {
let err = RuleId::new("Bad_Id").unwrap_err();
assert_eq!(err, InvalidRuleId("Bad_Id".to_owned()));
assert!(err.to_string().contains("Bad_Id"));
}
#[test]
fn rule_id_accessors_agree() {
const ID: RuleId = RuleId::from_static("no-op");
assert_eq!(ID.as_str(), "no-op");
assert_eq!(ID.to_string(), "no-op");
assert_eq!(ID, RuleId::new("no-op").unwrap());
}
#[test]
fn rule_id_serde_is_transparent() {
let id = RuleId::from_static("unused-dependency");
assert_eq!(
serde_json::to_value(&id).unwrap(),
serde_json::json!("unused-dependency")
);
let back: RuleId = serde_json::from_str("\"unused-dependency\"").unwrap();
assert_eq!(back, id);
}
#[test]
fn rule_id_deserialization_validates() {
for bad in ["\"Bad_Id\"", "\"\"", "\"a--b\"", "\"-a\"", "\"é\""] {
let error = serde_json::from_str::<RuleId>(bad).unwrap_err();
assert!(error.to_string().contains("invalid rule id"), "{bad}: {error}");
}
assert!(serde_json::from_str::<RuleId>("3").is_err());
let ids: Vec<RuleId> = serde_json::from_str("[\"a\", \"b-1\"]").unwrap();
assert_eq!(ids, [RuleId::from_static("a"), RuleId::from_static("b-1")]);
}
#[test]
fn severity_orders_info_below_error() {
assert!(Severity::Info < Severity::Warning);
assert!(Severity::Warning < Severity::Error);
assert_eq!(
serde_json::to_value(Severity::Warning).unwrap(),
serde_json::json!("warning")
);
}
#[test]
fn rule_meta_serializes_kebab_case() {
let meta = RuleMeta {
id: "unused-dependency",
category: Category::Manifest,
default: Some(Severity::Error),
fixable: Fixability::Sometimes,
summary: "Unused dependency.",
};
assert_eq!(
serde_json::to_value(meta).unwrap(),
serde_json::json!({
"id": "unused-dependency",
"category": "manifest",
"default": "error",
"fixable": "sometimes",
"summary": "Unused dependency.",
})
);
}
#[test]
fn rule_meta_new_is_const_and_fills_every_field() {
const META: RuleMeta = RuleMeta::new("some-rule", Category::Manifest, None, Fixability::None, "Does a thing.");
assert_eq!(
(META.id, META.default, META.summary),
("some-rule", None, "Does a thing.")
);
}
}