1use std::fmt;
8
9#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
12pub struct PluginName(String);
13
14#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum InvalidName {
17 Empty,
19 TooLong {
21 length: usize,
23 },
24 ForbiddenCharacter {
26 character: char,
28 at: usize,
30 },
31 EdgeNotAlphanumeric,
33 DoubledSeparator {
35 separator: char,
37 },
38}
39
40impl fmt::Display for InvalidName {
41 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42 match self {
43 Self::Empty => f.write_str("plugin name is empty"),
44 Self::TooLong { length } => {
45 write!(f, "plugin name is {length} characters long; the limit is 64")
46 }
47 Self::ForbiddenCharacter { character, at } => write!(
48 f,
49 "character {character:?} at position {at} is outside `a-z`, `0-9`, `-`, `.`"
50 ),
51 Self::EdgeNotAlphanumeric => {
52 f.write_str("plugin name must start and end with a lowercase letter or digit")
53 }
54 Self::DoubledSeparator { separator } => {
55 write!(f, "plugin name must not repeat {separator:?}")
56 }
57 }
58 }
59}
60
61impl std::error::Error for InvalidName {}
62
63impl PluginName {
64 pub fn parse(candidate: &str) -> Result<Self, InvalidName> {
66 if candidate.is_empty() {
67 return Err(InvalidName::Empty);
68 }
69 let length = candidate.chars().count();
70 if length > 64 {
71 return Err(InvalidName::TooLong { length });
72 }
73 for (at, character) in candidate.char_indices() {
74 if !matches!(character, 'a'..='z' | '0'..='9' | '-' | '.') {
75 return Err(InvalidName::ForbiddenCharacter { character, at });
76 }
77 }
78 let edges_ok = candidate.chars().next().zip(candidate.chars().next_back()).is_some_and(
79 |(first, last)| first.is_ascii_alphanumeric() && last.is_ascii_alphanumeric(),
80 );
81 if !edges_ok {
82 return Err(InvalidName::EdgeNotAlphanumeric);
83 }
84 if candidate.contains("--") {
85 return Err(InvalidName::DoubledSeparator { separator: '-' });
86 }
87 if candidate.contains("..") {
88 return Err(InvalidName::DoubledSeparator { separator: '.' });
89 }
90 Ok(Self(candidate.to_owned()))
91 }
92
93 pub fn as_str(&self) -> &str {
95 &self.0
96 }
97}
98
99impl fmt::Display for PluginName {
100 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
101 f.write_str(&self.0)
102 }
103}
104
105impl AsRef<str> for PluginName {
106 fn as_ref(&self) -> &str {
107 &self.0
108 }
109}
110
111impl std::str::FromStr for PluginName {
112 type Err = InvalidName;
113
114 fn from_str(s: &str) -> Result<Self, Self::Err> {
115 Self::parse(s)
116 }
117}
118
119impl TryFrom<String> for PluginName {
120 type Error = InvalidName;
121
122 fn try_from(value: String) -> Result<Self, Self::Error> {
123 Self::parse(&value)
124 }
125}
126
127impl PartialEq<str> for PluginName {
128 fn eq(&self, other: &str) -> bool {
129 self.0 == other
130 }
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136
137 #[test]
138 fn spec_examples() {
139 for ok in ["my-plugin", "acme.tools", "lint3r", "a"] {
141 assert!(PluginName::parse(ok).is_ok(), "{ok} should be valid");
142 }
143 assert_eq!(
145 PluginName::parse("My-Plugin"),
146 Err(InvalidName::ForbiddenCharacter { character: 'M', at: 0 })
147 );
148 assert_eq!(PluginName::parse("-start"), Err(InvalidName::EdgeNotAlphanumeric));
149 assert_eq!(
150 PluginName::parse("has--double"),
151 Err(InvalidName::DoubledSeparator { separator: '-' })
152 );
153 assert_eq!(
154 PluginName::parse("too.many..dots"),
155 Err(InvalidName::DoubledSeparator { separator: '.' })
156 );
157 assert_eq!(PluginName::parse(""), Err(InvalidName::Empty));
158 }
159
160 #[test]
161 fn length_limits() {
162 assert!(PluginName::parse(&"a".repeat(64)).is_ok());
163 assert_eq!(PluginName::parse(&"a".repeat(65)), Err(InvalidName::TooLong { length: 65 }));
164 }
165}