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("hash {value:?} must be formatted as sha256:<64 lowercase hex chars>")]
87 InvalidHash { value: String },
88
89 #[error("published_at {value:?} must be formatted as YYYY-MM-DDTHH:MM:SSZ in UTC")]
90 InvalidPublishedAt { value: String },
91
92 #[error("duplicate plugin entry ({name:?}, {version:?}) in index")]
93 DuplicateIndexEntry { name: String, version: String },
94
95 #[error(
96 "canonical collision: plugin name {name:?} conflicts with existing \
97 entries sharing canonical form {canonical:?}: {existing:?}. \
98 Rename to one of the existing spellings or choose a distinct name."
99 )]
100 CanonicalCollision {
101 name: String,
102 canonical: String,
103 existing: Vec<(String, String)>,
104 },
105
106 #[error(
107 "manifest_schema_version {found:?} has unsupported major; \
108 this library supports major {supported}"
109 )]
110 UnsupportedManifestMajor { found: String, supported: u32 },
111
112 #[error(
113 "index_schema_version {found:?} has unsupported major; \
114 this library supports major {supported}"
115 )]
116 UnsupportedIndexMajor { found: String, supported: u32 },
117
118 #[error("schema version {value:?} must be formatted as <major>.<minor>")]
119 MalformedSchemaVersion { value: String },
120
121 #[error("TOML parse error: {source}")]
122 TomlParse {
123 #[source]
124 source: toml::de::Error,
125 },
126
127 #[error("JSON parse error: {source}")]
128 JsonParse {
129 #[source]
130 source: serde_json::Error,
131 },
132
133 #[error("JSON serialization error: {source}")]
134 JsonSerialize {
135 #[source]
136 source: serde_json::Error,
137 },
138}
139
140impl SchemaError {
141 pub fn variant_name(&self) -> &'static str {
147 match self {
148 Self::InvalidPluginName { .. } => "InvalidPluginName",
149 Self::ReservedPluginName { .. } => "ReservedPluginName",
150 Self::InvalidVersion { .. } => "InvalidVersion",
151 Self::DescriptionTooLong { .. } => "DescriptionTooLong",
152 Self::DescriptionEmpty => "DescriptionEmpty",
153 Self::DescriptionMultiline { .. } => "DescriptionMultiline",
154 Self::InvalidUrlScheme { .. } => "InvalidUrlScheme",
155 Self::InvalidUrl { .. } => "InvalidUrl",
156 Self::UnknownTriggerType { .. } => "UnknownTriggerType",
157 Self::EmptyTriggers => "EmptyTriggers",
158 Self::InvalidDatabaseVersion { .. } => "InvalidDatabaseVersion",
159 Self::InvalidPythonRequirement { .. } => "InvalidPythonRequirement",
160 Self::UnsupportedArtifactScheme { .. } => "UnsupportedArtifactScheme",
161 Self::InvalidHash { .. } => "InvalidHash",
162 Self::InvalidPublishedAt { .. } => "InvalidPublishedAt",
163 Self::DuplicateIndexEntry { .. } => "DuplicateIndexEntry",
164 Self::CanonicalCollision { .. } => "CanonicalCollision",
165 Self::UnsupportedManifestMajor { .. } => "UnsupportedManifestMajor",
166 Self::UnsupportedIndexMajor { .. } => "UnsupportedIndexMajor",
167 Self::MalformedSchemaVersion { .. } => "MalformedSchemaVersion",
168 Self::TomlParse { .. } => "TomlParse",
169 Self::JsonParse { .. } => "JsonParse",
170 Self::JsonSerialize { .. } => "JsonSerialize",
171 }
172 }
173}
174
175use crate::FieldPath;
176
177#[derive(Debug)]
182pub struct ReportedError {
183 pub path: FieldPath,
184 pub error: SchemaError,
185}
186
187impl ReportedError {
188 pub fn new(path: FieldPath, error: SchemaError) -> Self {
189 Self { path, error }
190 }
191
192 pub fn at_root(error: SchemaError) -> Self {
195 Self::new(FieldPath::root(), error)
196 }
197}
198
199impl std::fmt::Display for ReportedError {
200 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
201 if self.path.as_str().is_empty() {
202 write!(f, "{}", self.error)
203 } else {
204 write!(f, "{}: {}", self.path, self.error)
205 }
206 }
207}
208
209impl std::error::Error for ReportedError {
210 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
211 Some(&self.error)
212 }
213}
214
215#[derive(Debug)]
221pub struct SchemaErrors(Vec<ReportedError>);
222
223impl SchemaErrors {
224 pub fn new(errors: Vec<ReportedError>) -> Self {
227 debug_assert!(
228 !errors.is_empty(),
229 "SchemaErrors must contain at least one error; use Ok(_) for the no-error case"
230 );
231 Self(errors)
232 }
233
234 pub fn single_at_root(error: SchemaError) -> Self {
237 Self(vec![ReportedError::at_root(error)])
238 }
239
240 pub fn errors(&self) -> &[ReportedError] {
241 &self.0
242 }
243
244 pub fn into_vec(self) -> Vec<ReportedError> {
245 self.0
246 }
247
248 pub fn len(&self) -> usize {
249 self.0.len()
250 }
251
252 pub fn is_empty(&self) -> bool {
253 self.0.is_empty()
254 }
255}
256
257impl std::fmt::Display for SchemaErrors {
258 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
259 match self.0.len() {
260 0 => f.write_str("(no errors)"),
261 1 => self.0[0].fmt(f),
262 n => {
263 writeln!(f, "{n} schema validation errors:")?;
264 for (i, err) in self.0.iter().enumerate() {
265 writeln!(f, " {}. {err}", i + 1)?;
266 }
267 Ok(())
268 }
269 }
270 }
271}
272
273impl std::error::Error for SchemaErrors {
274 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
275 self.0
277 .first()
278 .map(|r| &r.error as &(dyn std::error::Error + 'static))
279 }
280}
281
282impl From<SchemaErrors> for Vec<ReportedError> {
283 fn from(errors: SchemaErrors) -> Self {
284 errors.into_vec()
285 }
286}
287
288impl IntoIterator for SchemaErrors {
289 type Item = ReportedError;
290 type IntoIter = std::vec::IntoIter<ReportedError>;
291
292 fn into_iter(self) -> Self::IntoIter {
293 self.0.into_iter()
294 }
295}
296
297impl<'a> IntoIterator for &'a SchemaErrors {
298 type Item = &'a ReportedError;
299 type IntoIter = std::slice::Iter<'a, ReportedError>;
300
301 fn into_iter(self) -> Self::IntoIter {
302 self.0.iter()
303 }
304}
305
306#[derive(Debug, thiserror::Error)]
313#[non_exhaustive]
314pub enum IndexInsertError {
315 #[error("plugin ({name:?}, {version:?}) already exists in the target index")]
316 Duplicate {
317 name: String,
318 version: semver::Version,
319 existing_versions: Vec<semver::Version>,
320 },
321
322 #[error(
323 "canonical collision: plugin name {name:?} conflicts with existing \
324 entries sharing canonical form {canonical:?}: {existing:?}"
325 )]
326 CanonicalCollision {
327 name: String,
328 canonical: String,
329 existing: Vec<(String, semver::Version)>,
330 },
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336 use serde::ser::Error as _;
337
338 fn every_variant() -> Vec<SchemaError> {
341 vec![
342 SchemaError::InvalidPluginName {
343 name: "Bad Name".into(),
344 },
345 SchemaError::ReservedPluginName { name: "con".into() },
346 SchemaError::InvalidVersion {
347 version: "1.2".into(),
348 source: semver::Version::parse("1.2").unwrap_err(),
349 },
350 SchemaError::DescriptionTooLong { len: 201 },
351 SchemaError::DescriptionEmpty,
352 SchemaError::DescriptionMultiline { len: 201 },
353 SchemaError::InvalidUrlScheme {
354 url: "ftp://bad".into(),
355 scheme: "ftp".into(),
356 },
357 SchemaError::InvalidUrl {
358 url: "not a url".into(),
359 source: url::Url::parse("not a url").unwrap_err(),
360 },
361 SchemaError::UnknownTriggerType {
362 trigger: "on_startup".into(),
363 },
364 SchemaError::EmptyTriggers,
365 SchemaError::InvalidDatabaseVersion {
366 range: ">=bad".into(),
367 source: semver::VersionReq::parse(">=bad").unwrap_err(),
368 },
369 SchemaError::InvalidPythonRequirement {
373 requirement: "requests>>=2.0".into(),
374 source: Box::new(
375 "requests>>=2.0"
376 .parse::<pep508_rs::Requirement<pep508_rs::VerbatimUrl>>()
377 .unwrap_err(),
378 ),
379 },
380 SchemaError::UnsupportedArtifactScheme {
381 url: "s3://bucket/foo".into(),
382 scheme: "s3".into(),
383 },
384 SchemaError::InvalidHash {
385 value: "notahash".into(),
386 },
387 SchemaError::InvalidPublishedAt {
388 value: "2026-04-29T18:45:12.123Z".into(),
389 },
390 SchemaError::DuplicateIndexEntry {
391 name: "dup".into(),
392 version: "1.0.0".into(),
393 },
394 SchemaError::CanonicalCollision {
395 name: "my-plugin".into(),
396 canonical: "my_plugin".into(),
397 existing: vec![("my_plugin".into(), "1.0.0".into())],
398 },
399 SchemaError::UnsupportedManifestMajor {
400 found: "2.0".into(),
401 supported: 1,
402 },
403 SchemaError::UnsupportedIndexMajor {
404 found: "3.0".into(),
405 supported: 2,
406 },
407 SchemaError::MalformedSchemaVersion {
408 value: "abc".into(),
409 },
410 SchemaError::TomlParse {
411 source: toml::from_str::<toml::Value>("= ").unwrap_err(),
412 },
413 SchemaError::JsonParse {
414 source: serde_json::from_str::<serde_json::Value>("{").unwrap_err(),
415 },
416 SchemaError::JsonSerialize {
417 source: serde_json::Error::custom("forced"),
418 },
419 ]
420 }
421
422 #[test]
425 fn display_shape_is_stable() {
426 let rendered: Vec<String> = every_variant().iter().map(|e| e.to_string()).collect();
427 insta::assert_yaml_snapshot!("display_shape", rendered);
428 }
429
430 #[test]
434 fn variant_tags_are_stable() {
435 let tags: Vec<&'static str> = every_variant().iter().map(|e| e.variant_name()).collect();
436 insta::assert_yaml_snapshot!("variant_tags", tags);
437 }
438
439 #[test]
442 fn schema_errors_display_single_error() {
443 let se = SchemaErrors::single_at_root(SchemaError::EmptyTriggers);
444 insta::assert_snapshot!("schema_errors_single", se.to_string());
445 }
446
447 #[test]
450 fn schema_errors_display_multiple_errors() {
451 let se = SchemaErrors::new(vec![
452 ReportedError::new(
453 FieldPath::root().field("plugin").field("name"),
454 SchemaError::InvalidPluginName {
455 name: "Bad Name".into(),
456 },
457 ),
458 ReportedError::new(
459 FieldPath::root().field("plugin").field("triggers").index(0),
460 SchemaError::UnknownTriggerType {
461 trigger: "on_startup".into(),
462 },
463 ),
464 ]);
465 insta::assert_snapshot!("schema_errors_multiple", se.to_string());
466 }
467
468 #[test]
471 fn reported_error_source_chain_reaches_schema_error() {
472 use std::error::Error as _;
473 let re = ReportedError::new(
474 FieldPath::root().field("plugin").field("name"),
475 SchemaError::InvalidPluginName { name: "Bad".into() },
476 );
477 let src = re.source().expect("source exists");
478 assert!(src.downcast_ref::<SchemaError>().is_some());
479 }
480
481 #[test]
482 fn reserved_plugin_name_variant_renders_windows_message() {
483 let err = SchemaError::ReservedPluginName { name: "con".into() };
484 let text = err.to_string();
485 assert!(
486 text.contains("Windows reserved"),
487 "expected Windows-reserved mention, got: {text}"
488 );
489 assert!(
490 text.contains("\"con\""),
491 "expected original name, got: {text}"
492 );
493 assert_eq!(err.variant_name(), "ReservedPluginName");
494 }
495}