1#[derive(Debug, thiserror::Error)]
11#[non_exhaustive]
12pub enum SchemaError {
13 #[error(
14 "plugin name {name:?} must match `[a-zA-Z][a-zA-Z0-9_-]*` \
15 (1-64 chars, ASCII alphanumerics / `-` / `_`, starting with a letter)"
16 )]
17 InvalidPluginName { name: String },
18
19 #[error(
20 "plugin name {name:?} matches a Windows reserved device name \
21 (case-insensitive); pick a different name"
22 )]
23 ReservedPluginName { name: String },
24
25 #[error("version {version:?} is not SemVer 2.0.0 compliant: {source}")]
26 InvalidVersion {
27 version: String,
28 #[source]
29 source: semver::Error,
30 },
31
32 #[error("description exceeds 200 characters (was {len})")]
33 DescriptionTooLong { len: usize },
34
35 #[error("description must not be empty")]
36 DescriptionEmpty,
37
38 #[error("description must be one line; got {len} chars across multiple lines")]
39 DescriptionMultiline { len: usize },
40
41 #[error("URL {url:?} must use http or https scheme (was {scheme:?})")]
42 InvalidUrlScheme { url: String, scheme: String },
43
44 #[error("URL {url:?} is malformed: {source}")]
45 InvalidUrl {
46 url: String,
47 #[source]
48 source: url::ParseError,
49 },
50
51 #[error(
52 "trigger {trigger:?} is not in the closed set \
53 {{process_writes, process_scheduled_call, process_request}}"
54 )]
55 UnknownTriggerType { trigger: String },
56
57 #[error("triggers array must not be empty")]
58 EmptyTriggers,
59
60 #[error("database_version {range:?} is not a valid SemVer range: {source}")]
61 InvalidDatabaseVersion {
62 range: String,
63 #[source]
64 source: semver::Error,
65 },
66
67 #[error("python requirement {requirement:?} is not PEP 508-parseable: {source}")]
74 InvalidPythonRequirement {
75 requirement: String,
76 #[source]
77 source: Box<pep508_rs::Pep508Error<pep508_rs::VerbatimUrl>>,
78 },
79
80 #[error(
81 "artifacts_url {url:?} uses unsupported scheme {scheme:?}; \
82 allowed: http, https, file"
83 )]
84 UnsupportedArtifactScheme { url: String, scheme: String },
85
86 #[error(
87 "index_url {url:?} uses unsupported scheme {scheme:?}; \
88 allowed: http, https, file"
89 )]
90 UnsupportedIndexUrlScheme { url: String, scheme: String },
91
92 #[error("plugin dependency version {range:?} is not a valid SemVer range: {source}")]
93 InvalidPluginDependencyVersion {
94 range: String,
95 #[source]
96 source: semver::Error,
97 },
98
99 #[error(
100 "duplicate plugin dependency ({index_url:?}, {name:?}); \
101 entries must be unique by (index_url, canonical name)"
102 )]
103 DuplicatePluginDependency { index_url: String, name: String },
104
105 #[error("hash {value:?} must be formatted as sha256:<64 lowercase hex chars>")]
106 InvalidHash { value: String },
107
108 #[error("published_at {value:?} must be formatted as YYYY-MM-DDTHH:MM:SSZ in UTC")]
109 InvalidPublishedAt { value: String },
110
111 #[error("duplicate plugin entry ({name:?}, {version:?}) in index")]
112 DuplicateIndexEntry { name: String, version: String },
113
114 #[error(
115 "canonical collision: plugin name {name:?} conflicts with existing \
116 entries sharing canonical form {canonical:?}: {existing:?}. \
117 Rename to one of the existing spellings or choose a distinct name."
118 )]
119 CanonicalCollision {
120 name: String,
121 canonical: String,
122 existing: Vec<(String, String)>,
123 },
124
125 #[error(
126 "manifest_schema_version {found:?} has unsupported major; \
127 this library supports major {supported}"
128 )]
129 UnsupportedManifestMajor { found: String, supported: u32 },
130
131 #[error(
132 "index_schema_version {found:?} has unsupported major; \
133 this library supports major {supported}"
134 )]
135 UnsupportedIndexMajor { found: String, supported: u32 },
136
137 #[error("schema version {value:?} must be formatted as <major>.<minor>")]
138 MalformedSchemaVersion { value: String },
139
140 #[error("TOML parse error: {source}")]
141 TomlParse {
142 #[source]
143 source: toml::de::Error,
144 },
145
146 #[error("JSON parse error: {source}")]
147 JsonParse {
148 #[source]
149 source: serde_json::Error,
150 },
151
152 #[error("JSON serialization error: {source}")]
153 JsonSerialize {
154 #[source]
155 source: serde_json::Error,
156 },
157}
158
159impl SchemaError {
160 pub fn variant_name(&self) -> &'static str {
166 match self {
167 Self::InvalidPluginName { .. } => "InvalidPluginName",
168 Self::ReservedPluginName { .. } => "ReservedPluginName",
169 Self::InvalidVersion { .. } => "InvalidVersion",
170 Self::DescriptionTooLong { .. } => "DescriptionTooLong",
171 Self::DescriptionEmpty => "DescriptionEmpty",
172 Self::DescriptionMultiline { .. } => "DescriptionMultiline",
173 Self::InvalidUrlScheme { .. } => "InvalidUrlScheme",
174 Self::InvalidUrl { .. } => "InvalidUrl",
175 Self::UnknownTriggerType { .. } => "UnknownTriggerType",
176 Self::EmptyTriggers => "EmptyTriggers",
177 Self::InvalidDatabaseVersion { .. } => "InvalidDatabaseVersion",
178 Self::InvalidPythonRequirement { .. } => "InvalidPythonRequirement",
179 Self::UnsupportedArtifactScheme { .. } => "UnsupportedArtifactScheme",
180 Self::UnsupportedIndexUrlScheme { .. } => "UnsupportedIndexUrlScheme",
181 Self::InvalidPluginDependencyVersion { .. } => "InvalidPluginDependencyVersion",
182 Self::DuplicatePluginDependency { .. } => "DuplicatePluginDependency",
183 Self::InvalidHash { .. } => "InvalidHash",
184 Self::InvalidPublishedAt { .. } => "InvalidPublishedAt",
185 Self::DuplicateIndexEntry { .. } => "DuplicateIndexEntry",
186 Self::CanonicalCollision { .. } => "CanonicalCollision",
187 Self::UnsupportedManifestMajor { .. } => "UnsupportedManifestMajor",
188 Self::UnsupportedIndexMajor { .. } => "UnsupportedIndexMajor",
189 Self::MalformedSchemaVersion { .. } => "MalformedSchemaVersion",
190 Self::TomlParse { .. } => "TomlParse",
191 Self::JsonParse { .. } => "JsonParse",
192 Self::JsonSerialize { .. } => "JsonSerialize",
193 }
194 }
195}
196
197use crate::FieldPath;
198
199#[derive(Debug)]
204pub struct ReportedError {
205 pub path: FieldPath,
206 pub error: SchemaError,
207}
208
209impl ReportedError {
210 pub fn new(path: FieldPath, error: SchemaError) -> Self {
211 Self { path, error }
212 }
213
214 pub fn at_root(error: SchemaError) -> Self {
217 Self::new(FieldPath::root(), error)
218 }
219}
220
221impl std::fmt::Display for ReportedError {
222 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
223 if self.path.as_str().is_empty() {
224 write!(f, "{}", self.error)
225 } else {
226 write!(f, "{}: {}", self.path, self.error)
227 }
228 }
229}
230
231impl std::error::Error for ReportedError {
232 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
233 Some(&self.error)
234 }
235}
236
237#[derive(Debug)]
243pub struct SchemaErrors(Vec<ReportedError>);
244
245impl SchemaErrors {
246 pub fn new(errors: Vec<ReportedError>) -> Self {
249 debug_assert!(
250 !errors.is_empty(),
251 "SchemaErrors must contain at least one error; use Ok(_) for the no-error case"
252 );
253 Self(errors)
254 }
255
256 pub fn single_at_root(error: SchemaError) -> Self {
259 Self(vec![ReportedError::at_root(error)])
260 }
261
262 pub fn errors(&self) -> &[ReportedError] {
263 &self.0
264 }
265
266 pub fn into_vec(self) -> Vec<ReportedError> {
267 self.0
268 }
269
270 pub fn len(&self) -> usize {
271 self.0.len()
272 }
273
274 pub fn is_empty(&self) -> bool {
275 self.0.is_empty()
276 }
277}
278
279impl std::fmt::Display for SchemaErrors {
280 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
281 match self.0.len() {
282 0 => f.write_str("(no errors)"),
283 1 => self.0[0].fmt(f),
284 n => {
285 writeln!(f, "{n} schema validation errors:")?;
286 for (i, err) in self.0.iter().enumerate() {
287 writeln!(f, " {}. {err}", i + 1)?;
288 }
289 Ok(())
290 }
291 }
292 }
293}
294
295impl std::error::Error for SchemaErrors {
296 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
297 self.0
299 .first()
300 .map(|r| &r.error as &(dyn std::error::Error + 'static))
301 }
302}
303
304impl From<SchemaErrors> for Vec<ReportedError> {
305 fn from(errors: SchemaErrors) -> Self {
306 errors.into_vec()
307 }
308}
309
310impl IntoIterator for SchemaErrors {
311 type Item = ReportedError;
312 type IntoIter = std::vec::IntoIter<ReportedError>;
313
314 fn into_iter(self) -> Self::IntoIter {
315 self.0.into_iter()
316 }
317}
318
319impl<'a> IntoIterator for &'a SchemaErrors {
320 type Item = &'a ReportedError;
321 type IntoIter = std::slice::Iter<'a, ReportedError>;
322
323 fn into_iter(self) -> Self::IntoIter {
324 self.0.iter()
325 }
326}
327
328#[derive(Debug, thiserror::Error)]
335#[non_exhaustive]
336pub enum IndexInsertError {
337 #[error("plugin ({name:?}, {version:?}) already exists in the target index")]
338 Duplicate {
339 name: String,
340 version: semver::Version,
341 existing_versions: Vec<semver::Version>,
342 },
343
344 #[error(
345 "canonical collision: plugin name {name:?} conflicts with existing \
346 entries sharing canonical form {canonical:?}: {existing:?}"
347 )]
348 CanonicalCollision {
349 name: String,
350 canonical: String,
351 existing: Vec<(String, semver::Version)>,
352 },
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358 use serde::ser::Error as _;
359
360 fn every_variant() -> Vec<SchemaError> {
363 vec![
364 SchemaError::InvalidPluginName {
365 name: "Bad Name".into(),
366 },
367 SchemaError::ReservedPluginName { name: "con".into() },
368 SchemaError::InvalidVersion {
369 version: "1.2".into(),
370 source: semver::Version::parse("1.2").unwrap_err(),
371 },
372 SchemaError::DescriptionTooLong { len: 201 },
373 SchemaError::DescriptionEmpty,
374 SchemaError::DescriptionMultiline { len: 201 },
375 SchemaError::InvalidUrlScheme {
376 url: "ftp://bad".into(),
377 scheme: "ftp".into(),
378 },
379 SchemaError::InvalidUrl {
380 url: "not a url".into(),
381 source: url::Url::parse("not a url").unwrap_err(),
382 },
383 SchemaError::UnknownTriggerType {
384 trigger: "on_startup".into(),
385 },
386 SchemaError::EmptyTriggers,
387 SchemaError::InvalidDatabaseVersion {
388 range: ">=bad".into(),
389 source: semver::VersionReq::parse(">=bad").unwrap_err(),
390 },
391 SchemaError::InvalidPythonRequirement {
395 requirement: "requests>>=2.0".into(),
396 source: Box::new(
397 "requests>>=2.0"
398 .parse::<pep508_rs::Requirement<pep508_rs::VerbatimUrl>>()
399 .unwrap_err(),
400 ),
401 },
402 SchemaError::UnsupportedArtifactScheme {
403 url: "s3://bucket/foo".into(),
404 scheme: "s3".into(),
405 },
406 SchemaError::UnsupportedIndexUrlScheme {
407 url: "s3://registry/index.json".into(),
408 scheme: "s3".into(),
409 },
410 SchemaError::InvalidPluginDependencyVersion {
411 range: ">=bad".into(),
412 source: semver::VersionReq::parse(">=bad").unwrap_err(),
413 },
414 SchemaError::DuplicatePluginDependency {
415 index_url: "https://plugins.example.com/index.json".into(),
416 name: "geo-lookup".into(),
417 },
418 SchemaError::InvalidHash {
419 value: "notahash".into(),
420 },
421 SchemaError::InvalidPublishedAt {
422 value: "2026-04-29T18:45:12.123Z".into(),
423 },
424 SchemaError::DuplicateIndexEntry {
425 name: "dup".into(),
426 version: "1.0.0".into(),
427 },
428 SchemaError::CanonicalCollision {
429 name: "my-plugin".into(),
430 canonical: "my_plugin".into(),
431 existing: vec![("my_plugin".into(), "1.0.0".into())],
432 },
433 SchemaError::UnsupportedManifestMajor {
434 found: "2.0".into(),
435 supported: 1,
436 },
437 SchemaError::UnsupportedIndexMajor {
438 found: "3.0".into(),
439 supported: 2,
440 },
441 SchemaError::MalformedSchemaVersion {
442 value: "abc".into(),
443 },
444 SchemaError::TomlParse {
445 source: toml::from_str::<toml::Value>("= ").unwrap_err(),
446 },
447 SchemaError::JsonParse {
448 source: serde_json::from_str::<serde_json::Value>("{").unwrap_err(),
449 },
450 SchemaError::JsonSerialize {
451 source: serde_json::Error::custom("forced"),
452 },
453 ]
454 }
455
456 #[test]
459 fn display_shape_is_stable() {
460 let rendered: Vec<String> = every_variant().iter().map(|e| e.to_string()).collect();
461 insta::assert_yaml_snapshot!("display_shape", rendered);
462 }
463
464 #[test]
468 fn variant_tags_are_stable() {
469 let tags: Vec<&'static str> = every_variant().iter().map(|e| e.variant_name()).collect();
470 insta::assert_yaml_snapshot!("variant_tags", tags);
471 }
472
473 #[test]
476 fn schema_errors_display_single_error() {
477 let se = SchemaErrors::single_at_root(SchemaError::EmptyTriggers);
478 insta::assert_snapshot!("schema_errors_single", se.to_string());
479 }
480
481 #[test]
484 fn schema_errors_display_multiple_errors() {
485 let se = SchemaErrors::new(vec![
486 ReportedError::new(
487 FieldPath::root().field("plugin").field("name"),
488 SchemaError::InvalidPluginName {
489 name: "Bad Name".into(),
490 },
491 ),
492 ReportedError::new(
493 FieldPath::root().field("plugin").field("triggers").index(0),
494 SchemaError::UnknownTriggerType {
495 trigger: "on_startup".into(),
496 },
497 ),
498 ]);
499 insta::assert_snapshot!("schema_errors_multiple", se.to_string());
500 }
501
502 #[test]
505 fn reported_error_source_chain_reaches_schema_error() {
506 use std::error::Error as _;
507 let re = ReportedError::new(
508 FieldPath::root().field("plugin").field("name"),
509 SchemaError::InvalidPluginName { name: "Bad".into() },
510 );
511 let src = re.source().expect("source exists");
512 assert!(src.downcast_ref::<SchemaError>().is_some());
513 }
514
515 #[test]
516 fn reserved_plugin_name_variant_renders_windows_message() {
517 let err = SchemaError::ReservedPluginName { name: "con".into() };
518 let text = err.to_string();
519 assert!(
520 text.contains("Windows reserved"),
521 "expected Windows-reserved mention, got: {text}"
522 );
523 assert!(
524 text.contains("\"con\""),
525 "expected original name, got: {text}"
526 );
527 assert_eq!(err.variant_name(), "ReservedPluginName");
528 }
529}