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("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    /// Stable string tag for the variant. Use for metrics keys, log
142    /// categorization, and routing that must survive field-level changes.
143    ///
144    /// The exhaustive match forces new variants to be registered in
145    /// `every_variant()` (compile error otherwise).
146    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/// A `SchemaError` paired with the field path at which it was detected.
178///
179/// `path` is empty for whole-document errors (TOML/JSON syntax); populated
180/// for field-level validation errors.
181#[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    /// Constructs a `ReportedError` at the root path, for whole-document
193    /// errors like `TomlParse` and `JsonParse`.
194    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/// Collection of `ReportedError`s returned by `Manifest::parse_toml` and
216/// `Index::parse_json`.
217///
218/// Always non-empty when returned as `Err(SchemaErrors)` — parse functions
219/// return `Ok(_)` iff no validation errors were found.
220#[derive(Debug)]
221pub struct SchemaErrors(Vec<ReportedError>);
222
223impl SchemaErrors {
224    /// Debug-asserts `errors` is non-empty; constructing an empty
225    /// `SchemaErrors` is a programming error (use `Ok(_)` for no errors).
226    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    /// Convenience for syntax-level errors (TomlParse / JsonParse) and
235    /// schema-version short-circuit errors.
236    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        // First error's source; callers who want the full list use `.errors()`.
276        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/// Errors returned by [`crate::Index::check_entry_insert`] and [`crate::Index::push_entry`].
307///
308/// Adding variants is a minor-version change; renaming, removing, reshaping,
309/// or adding fields to existing variants is a major-version change.
310///
311/// `#[non_exhaustive]`: downstream matches must include a `_ =>` arm.
312#[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    /// Returns one instance of every `SchemaError` variant. Keep in sync with
339    /// the enum: every variant MUST appear here so snapshot tests cover them.
340    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            // Constructing Pep508Error needs a real parse failure.
370            // `requests>>=2.0` (double operator) is unambiguously rejected;
371            // inputs like `!!invalid!!` are accepted by some permissive paths.
372            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    /// Locks `Display` text of every variant — user-facing error messages are
423    /// part of the semver-stable contract.
424    #[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    /// Locks the variant-tag set. Breaking this means a variant was renamed,
431    /// added, or removed — the load-bearing stability contract, since renaming
432    /// can leave `display_shape_is_stable` untouched.
433    #[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    /// `SchemaErrors` Display for the single-error case (TomlParse,
440    /// JsonParse, schema-version short-circuit).
441    #[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    /// `SchemaErrors` Display for the multi-error case (every defect with
448    /// its field path).
449    #[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    /// `ReportedError::source()` walks back to the inner `SchemaError`,
469    /// preserving the structural payload for downstream introspection.
470    #[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}