influxdb3_plugin_schemas/
identity.rs1use crate::SchemaError;
4use std::fmt;
5use std::str::FromStr;
6
7#[derive(Debug, Clone, PartialEq, Eq, Hash)]
13pub struct PluginName(String);
14
15impl PluginName {
16 const WINDOWS_RESERVED: &'static [&'static str] = &[
21 "con", "prn", "aux", "nul", "com0", "com1", "com2", "com3", "com4", "com5", "com6", "com7",
22 "com8", "com9", "lpt0", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8",
23 "lpt9",
24 ];
25
26 pub fn as_str(&self) -> &str {
27 &self.0
28 }
29
30 pub fn canonical(&self) -> String {
39 canonical_name(&self.0)
40 }
41
42 pub fn into_inner(self) -> String {
43 self.0
44 }
45
46 fn validate(name: &str) -> Result<(), SchemaError> {
47 let bytes = name.as_bytes();
48 if bytes.is_empty() || bytes.len() > 64 {
49 return Err(SchemaError::InvalidPluginName {
50 name: name.to_owned(),
51 });
52 }
53 let first = bytes[0];
54 let is_alpha = |b: u8| b.is_ascii_uppercase() || b.is_ascii_lowercase();
55 let is_alnum = |b: u8| b.is_ascii_digit() || is_alpha(b);
56 if !is_alpha(first) {
57 return Err(SchemaError::InvalidPluginName {
58 name: name.to_owned(),
59 });
60 }
61 for &b in &bytes[1..] {
62 if !(is_alnum(b) || b == b'-' || b == b'_') {
63 return Err(SchemaError::InvalidPluginName {
64 name: name.to_owned(),
65 });
66 }
67 }
68 let lower = name.to_ascii_lowercase();
69 if Self::WINDOWS_RESERVED.iter().any(|&r| r == lower) {
70 return Err(SchemaError::ReservedPluginName {
71 name: name.to_owned(),
72 });
73 }
74 Ok(())
75 }
76}
77
78pub(crate) fn canonical_name(raw: &str) -> String {
84 raw.to_ascii_lowercase().replace('-', "_")
85}
86
87impl FromStr for PluginName {
88 type Err = SchemaError;
89
90 fn from_str(s: &str) -> Result<Self, Self::Err> {
91 Self::validate(s)?;
92 Ok(Self(s.to_owned()))
93 }
94}
95
96impl fmt::Display for PluginName {
97 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98 f.write_str(&self.0)
99 }
100}
101
102impl<'de> serde::Deserialize<'de> for PluginName {
103 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
104 where
105 D: serde::Deserializer<'de>,
106 {
107 let raw = String::deserialize(deserializer)?;
108 Self::from_str(&raw).map_err(serde::de::Error::custom)
109 }
110}
111
112impl serde::Serialize for PluginName {
113 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
114 where
115 S: serde::Serializer,
116 {
117 serializer.serialize_str(&self.0)
118 }
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, Hash)]
129pub enum PluginId {
130 Registry {
131 index_url: url::Url,
132 name: PluginName,
133 version: semver::Version,
134 },
135 Local {
136 path: std::path::PathBuf,
137 name: PluginName,
138 version: semver::Version,
139 },
140}
141
142impl PluginId {
143 pub fn registry(index_url: url::Url, name: PluginName, version: semver::Version) -> Self {
144 Self::Registry {
145 index_url,
146 name,
147 version,
148 }
149 }
150
151 pub fn local(path: std::path::PathBuf, name: PluginName, version: semver::Version) -> Self {
152 Self::Local {
153 path,
154 name,
155 version,
156 }
157 }
158
159 pub fn name(&self) -> &PluginName {
160 match self {
161 Self::Registry { name, .. } | Self::Local { name, .. } => name,
162 }
163 }
164
165 pub fn version(&self) -> &semver::Version {
166 match self {
167 Self::Registry { version, .. } | Self::Local { version, .. } => version,
168 }
169 }
170}
171
172impl fmt::Display for PluginId {
173 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174 match self {
175 Self::Registry {
176 index_url,
177 name,
178 version,
179 } => write!(f, "{name}@{version} ({index_url})"),
180 Self::Local {
181 path,
182 name,
183 version,
184 } => write!(f, "{name}@{version} (local: {})", path.display()),
185 }
186 }
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192 use assert_matches::assert_matches;
193 use rstest::rstest;
194
195 #[rstest]
196 #[case("a")]
197 #[case("aa")]
198 #[case("plugin")]
199 #[case("my-plugin")]
200 #[case("a1b2c3")]
201 #[case("a-really-long-but-still-valid-name-that-is-under-64-chars")]
202 #[case("Z")]
203 #[case("MyPlugin")]
204 #[case("MYPLUGIN")]
205 #[case("Test-1_v2")]
206 #[case("Foo")]
207 #[case("foo_bar")]
208 fn valid_names_accepted(#[case] input: &str) {
209 let name = PluginName::from_str(input).expect("should accept valid name");
210 assert_eq!(name.as_str(), input);
211 }
212
213 #[rstest]
214 #[case("")] #[case("-foo")] #[case("foo bar")] #[case("123")] #[case("7plugin")] #[case("café")] #[case("foo.bar")] fn invalid_names_rejected(#[case] input: &str) {
222 let err = PluginName::from_str(input).expect_err("should reject");
223 assert_matches!(err, SchemaError::InvalidPluginName { .. });
224 }
225
226 #[test]
227 fn plugin_name_length_boundaries() {
228 assert!(PluginName::from_str("a").is_ok());
229 assert!(PluginName::from_str(&"a".repeat(64)).is_ok());
230 assert!(matches!(
231 PluginName::from_str(&"a".repeat(65)),
232 Err(SchemaError::InvalidPluginName { .. })
233 ));
234 assert!(matches!(
235 PluginName::from_str(""),
236 Err(SchemaError::InvalidPluginName { .. })
237 ));
238 }
239
240 #[rstest]
241 #[case("con")]
242 #[case("prn")]
243 #[case("aux")]
244 #[case("nul")]
245 #[case("com0")]
246 #[case("com9")]
247 #[case("lpt0")]
248 #[case("lpt9")]
249 #[case("CON")]
250 #[case("Com1")]
251 fn reserved_names_rejected(#[case] input: &str) {
252 let err = PluginName::from_str(input).expect_err("should reject reserved name");
253 assert!(
254 matches!(err, SchemaError::ReservedPluginName { ref name } if name == input),
255 "expected ReservedPluginName with preserved input spelling, got: {err:?}"
256 );
257 }
258
259 #[rstest]
260 #[case("console")]
261 #[case("com10")]
262 #[case("conin")]
263 #[case("com")]
264 fn near_reserved_names_accepted(#[case] input: &str) {
265 assert!(PluginName::from_str(input).is_ok());
266 }
267
268 #[test]
269 fn plugin_name_display_matches_as_str() {
270 let name = PluginName::from_str("downsampler").unwrap();
271 assert_eq!(format!("{name}"), "downsampler");
272 }
273
274 #[test]
275 fn plugin_name_round_trips_through_serde_json() {
276 let name = PluginName::from_str("my-plugin").unwrap();
277 let json = serde_json::to_string(&name).unwrap();
278 assert_eq!(json, "\"my-plugin\"");
279 let back: PluginName = serde_json::from_str(&json).unwrap();
280 assert_eq!(back, name);
281 }
282
283 #[test]
284 fn plugin_name_deserialize_rejects_invalid() {
285 let result: Result<PluginName, _> = serde_json::from_str("\"Bad Name\"");
286 let err = result.expect_err("should reject invalid name");
287 assert!(
292 err.to_string().contains("plugin name"),
293 "expected error mentioning plugin name, got: {err}"
294 );
295 }
296
297 #[rstest]
298 #[case("a", "a")]
299 #[case("MyPlugin", "myplugin")]
300 #[case("foo-bar", "foo_bar")]
301 #[case("foo_bar", "foo_bar")]
302 #[case("Foo-Bar_Baz", "foo_bar_baz")]
303 #[case("Test-1_v2", "test_1_v2")]
304 fn canonical_form_matches_table(#[case] input: &str, #[case] expected: &str) {
305 let name = PluginName::from_str(input).expect("valid input");
306 assert_eq!(name.canonical(), expected);
307 assert_eq!(name.as_str(), input);
309 }
310}
311
312#[cfg(test)]
313mod plugin_id_tests {
314 use super::*;
315 use pretty_assertions::assert_eq;
316 use semver::Version;
317 use std::path::PathBuf;
318 use url::Url;
319
320 #[test]
321 fn registry_variant_constructs_from_parts() {
322 let id = PluginId::registry(
323 Url::parse("https://plugins.example.com/index.json").unwrap(),
324 PluginName::from_str("downsampler").unwrap(),
325 Version::new(1, 2, 0),
326 );
327 match &id {
328 PluginId::Registry { name, version, .. } => {
329 assert_eq!(name.as_str(), "downsampler");
330 assert_eq!(*version, Version::new(1, 2, 0));
331 }
332 PluginId::Local { .. } => panic!("expected Registry variant"),
333 }
334 assert_eq!(id.name().as_str(), "downsampler");
335 assert_eq!(*id.version(), Version::new(1, 2, 0));
336 }
337
338 #[test]
339 fn local_variant_constructs_from_parts() {
340 let id = PluginId::local(
341 PathBuf::from("/srv/plugins/my-plugin"),
342 PluginName::from_str("my-plugin").unwrap(),
343 Version::new(0, 3, 1),
344 );
345 match &id {
346 PluginId::Local {
347 path,
348 name,
349 version,
350 } => {
351 assert_eq!(*path, PathBuf::from("/srv/plugins/my-plugin"));
352 assert_eq!(name.as_str(), "my-plugin");
353 assert_eq!(*version, Version::new(0, 3, 1));
354 }
355 PluginId::Registry { .. } => panic!("expected Local variant"),
356 }
357 assert_eq!(id.name().as_str(), "my-plugin");
358 assert_eq!(*id.version(), Version::new(0, 3, 1));
359 }
360
361 #[test]
362 fn display_shape_pinned() {
363 let registry = PluginId::registry(
364 Url::parse("https://r.example/index.json").unwrap(),
365 PluginName::from_str("downsampler").unwrap(),
366 Version::new(1, 2, 0),
367 );
368 let local = PluginId::local(
369 PathBuf::from("/srv/plugins/my-plugin"),
370 PluginName::from_str("my-plugin").unwrap(),
371 Version::new(0, 3, 1),
372 );
373 insta::assert_yaml_snapshot!(
374 "plugin_id_display",
375 vec![registry.to_string(), local.to_string()]
376 );
377 }
378}