Skip to main content

influxdb3_plugin_schemas/
error.rs

1//! Error types for schema parsing and validation.
2
3/// Errors produced during schema parsing and validation.
4///
5/// Adding variants is a minor-version change; renaming, removing, reshaping,
6/// or adding fields to existing variants is a major-version change. To evolve
7/// a variant's payload, introduce a new variant rather than mutating the old.
8///
9/// `#[non_exhaustive]`: downstream matches must include a `_ =>` arm.
10#[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    /// A `dependencies.python` entry failed PEP 508 parsing.
68    ///
69    /// The `source` type comes from pre-1.0 `pep508_rs`; prefer [`.source()`]
70    /// over matching the typed field to avoid coupling to its semver.
71    ///
72    /// [`.source()`]: std::error::Error::source
73    #[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    /// Stable string tag for the variant. Use for metrics keys, log
161    /// categorization, and routing that must survive field-level changes.
162    ///
163    /// The exhaustive match forces new variants to be registered in
164    /// `every_variant()` (compile error otherwise).
165    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/// A `SchemaError` paired with the field path at which it was detected.
200///
201/// `path` is empty for whole-document errors (TOML/JSON syntax); populated
202/// for field-level validation errors.
203#[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    /// Constructs a `ReportedError` at the root path, for whole-document
215    /// errors like `TomlParse` and `JsonParse`.
216    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/// Collection of `ReportedError`s returned by `Manifest::parse_toml` and
238/// `Index::parse_json`.
239///
240/// Always non-empty when returned as `Err(SchemaErrors)` — parse functions
241/// return `Ok(_)` iff no validation errors were found.
242#[derive(Debug)]
243pub struct SchemaErrors(Vec<ReportedError>);
244
245impl SchemaErrors {
246    /// Debug-asserts `errors` is non-empty; constructing an empty
247    /// `SchemaErrors` is a programming error (use `Ok(_)` for no errors).
248    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    /// Convenience for syntax-level errors (TomlParse / JsonParse) and
257    /// schema-version short-circuit errors.
258    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        // First error's source; callers who want the full list use `.errors()`.
298        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/// Errors returned by [`crate::Index::check_entry_insert`] and [`crate::Index::push_entry`].
329///
330/// Adding variants is a minor-version change; renaming, removing, reshaping,
331/// or adding fields to existing variants is a major-version change.
332///
333/// `#[non_exhaustive]`: downstream matches must include a `_ =>` arm.
334#[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    /// Returns one instance of every `SchemaError` variant. Keep in sync with
361    /// the enum: every variant MUST appear here so snapshot tests cover them.
362    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            // Constructing Pep508Error needs a real parse failure.
392            // `requests>>=2.0` (double operator) is unambiguously rejected;
393            // inputs like `!!invalid!!` are accepted by some permissive paths.
394            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    /// Locks `Display` text of every variant — user-facing error messages are
457    /// part of the semver-stable contract.
458    #[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    /// Locks the variant-tag set. Breaking this means a variant was renamed,
465    /// added, or removed — the load-bearing stability contract, since renaming
466    /// can leave `display_shape_is_stable` untouched.
467    #[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    /// `SchemaErrors` Display for the single-error case (TomlParse,
474    /// JsonParse, schema-version short-circuit).
475    #[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    /// `SchemaErrors` Display for the multi-error case (every defect with
482    /// its field path).
483    #[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    /// `ReportedError::source()` walks back to the inner `SchemaError`,
503    /// preserving the structural payload for downstream introspection.
504    #[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}