Skip to main content

influxdb3_plugin_schemas/
manifest.rs

1//! Plugin manifest (`manifest.toml`) types and parsing.
2
3use crate::{IndexUrl, PluginName, SchemaError};
4use std::fmt;
5use std::str::FromStr;
6
7/// Supported major. Parsers refuse unsupported majors; bumped on breaking
8/// schema changes.
9pub(crate) const SUPPORTED_MANIFEST_MAJOR: u32 = 1;
10
11/// The `manifest_schema_version` top-level field, format `<major>.<minor>`.
12///
13/// Unsupported majors are rejected. Within a known major, unknown fields are
14/// tolerated by the structural parser.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
16pub struct ManifestSchemaVersion {
17    major: u32,
18    minor: u32,
19}
20
21impl ManifestSchemaVersion {
22    pub const CURRENT: Self = Self { major: 1, minor: 3 };
23
24    pub fn new(major: u32, minor: u32) -> Self {
25        Self { major, minor }
26    }
27    pub fn major(&self) -> u32 {
28        self.major
29    }
30    pub fn minor(&self) -> u32 {
31        self.minor
32    }
33}
34
35impl fmt::Display for ManifestSchemaVersion {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        write!(f, "{}.{}", self.major, self.minor)
38    }
39}
40
41impl FromStr for ManifestSchemaVersion {
42    type Err = SchemaError;
43
44    fn from_str(s: &str) -> Result<Self, Self::Err> {
45        let malformed = || SchemaError::MalformedSchemaVersion {
46            value: s.to_owned(),
47        };
48        let (major_str, minor_str) = s.split_once('.').ok_or_else(malformed)?;
49        if major_str.is_empty() || minor_str.is_empty() || minor_str.contains('.') {
50            return Err(malformed());
51        }
52        let major: u32 = major_str.parse().map_err(|_| malformed())?;
53        let minor: u32 = minor_str.parse().map_err(|_| malformed())?;
54
55        if major != SUPPORTED_MANIFEST_MAJOR {
56            return Err(SchemaError::UnsupportedManifestMajor {
57                found: s.to_owned(),
58                supported: SUPPORTED_MANIFEST_MAJOR,
59            });
60        }
61        Ok(Self { major, minor })
62    }
63}
64
65impl<'de> serde::Deserialize<'de> for ManifestSchemaVersion {
66    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
67    where
68        D: serde::Deserializer<'de>,
69    {
70        let raw = String::deserialize(deserializer)?;
71        Self::from_str(&raw).map_err(serde::de::Error::custom)
72    }
73}
74
75impl serde::Serialize for ManifestSchemaVersion {
76    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
77    where
78        S: serde::Serializer,
79    {
80        serializer.collect_str(self)
81    }
82}
83
84/// One-line plugin description. 1–200 characters.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct Description(String);
87
88impl Description {
89    pub fn try_new(s: &str) -> Result<Self, SchemaError> {
90        if s.is_empty() {
91            return Err(SchemaError::DescriptionEmpty);
92        }
93        // The newline check is the more specific rule, so it precedes the
94        // length check: a 201-char string that also contains a newline is
95        // reported as multiline rather than too-long.
96        if s.contains('\n') || s.contains('\r') {
97            return Err(SchemaError::DescriptionMultiline {
98                len: s.chars().count(),
99            });
100        }
101        let len = s.chars().count();
102        if len > 200 {
103            return Err(SchemaError::DescriptionTooLong { len });
104        }
105        Ok(Self(s.to_owned()))
106    }
107
108    pub fn as_str(&self) -> &str {
109        &self.0
110    }
111}
112
113impl<'de> serde::Deserialize<'de> for Description {
114    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
115    where
116        D: serde::Deserializer<'de>,
117    {
118        let raw = String::deserialize(deserializer)?;
119        Self::try_new(&raw).map_err(serde::de::Error::custom)
120    }
121}
122
123impl serde::Serialize for Description {
124    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
125    where
126        S: serde::Serializer,
127    {
128        serializer.serialize_str(&self.0)
129    }
130}
131
132/// Closed set of supported trigger types. Manifests are rejected if any
133/// trigger identifier is outside this set.
134///
135/// Serde goes through `TryFrom<String>` / `Into<String>`, so `rename_all`
136/// would be a no-op.
137#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
138#[serde(try_from = "String", into = "String")]
139pub enum TriggerType {
140    ProcessWrites,
141    ProcessScheduledCall,
142    ProcessRequest,
143}
144
145impl TriggerType {
146    pub fn as_str(&self) -> &'static str {
147        match self {
148            Self::ProcessWrites => "process_writes",
149            Self::ProcessScheduledCall => "process_scheduled_call",
150            Self::ProcessRequest => "process_request",
151        }
152    }
153}
154
155impl fmt::Display for TriggerType {
156    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
157        f.write_str(self.as_str())
158    }
159}
160
161impl FromStr for TriggerType {
162    type Err = SchemaError;
163
164    fn from_str(s: &str) -> Result<Self, Self::Err> {
165        match s {
166            "process_writes" => Ok(Self::ProcessWrites),
167            "process_scheduled_call" => Ok(Self::ProcessScheduledCall),
168            "process_request" => Ok(Self::ProcessRequest),
169            other => Err(SchemaError::UnknownTriggerType {
170                trigger: other.to_owned(),
171            }),
172        }
173    }
174}
175
176impl TryFrom<String> for TriggerType {
177    type Error = SchemaError;
178    fn try_from(value: String) -> Result<Self, Self::Error> {
179        value.parse()
180    }
181}
182
183impl From<TriggerType> for String {
184    fn from(value: TriggerType) -> Self {
185        value.as_str().to_owned()
186    }
187}
188
189/// A PEP 508 Python package requirement string (e.g., `requests>=2.31,<3`).
190/// Validated for parseability at construction; stored in its canonical string
191/// form.
192#[derive(Debug, Clone, PartialEq, Eq)]
193pub struct PythonRequirement(String);
194
195impl PythonRequirement {
196    pub fn try_new(s: &str) -> Result<Self, SchemaError> {
197        // Parse for validation only; store the original string. The
198        // `<VerbatimUrl>` turbofish tracks pep508_rs's pre-1.0 generic
199        // Requirement; on upgrade, also review SchemaError::InvalidPythonRequirement.
200        pep508_rs::Requirement::<pep508_rs::VerbatimUrl>::from_str(s).map_err(|e| {
201            SchemaError::InvalidPythonRequirement {
202                requirement: s.to_owned(),
203                source: Box::new(e),
204            }
205        })?;
206        Ok(Self(s.to_owned()))
207    }
208
209    pub fn as_str(&self) -> &str {
210        &self.0
211    }
212}
213
214impl<'de> serde::Deserialize<'de> for PythonRequirement {
215    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
216    where
217        D: serde::Deserializer<'de>,
218    {
219        let raw = String::deserialize(deserializer)?;
220        Self::try_new(&raw).map_err(serde::de::Error::custom)
221    }
222}
223
224impl serde::Serialize for PythonRequirement {
225    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
226    where
227        S: serde::Serializer,
228    {
229        serializer.serialize_str(&self.0)
230    }
231}
232
233/// One `[[dependencies.plugins]]` entry: a fully-resolved reference to a
234/// plugin at another (or the same) registry. `version` is a SemVer range —
235/// "any version of `name` at `index_url` that satisfies `version`".
236#[derive(Debug, Clone, PartialEq, serde::Deserialize, serde::Serialize)]
237pub struct PluginDependency {
238    pub index_url: IndexUrl,
239    pub name: crate::PluginName,
240    pub version: semver::VersionReq,
241}
242
243/// A parsed plugin manifest.
244#[derive(Debug, Clone, PartialEq, serde::Deserialize, serde::Serialize)]
245pub struct Manifest {
246    pub manifest_schema_version: ManifestSchemaVersion,
247    pub plugin: PluginMetadata,
248    pub dependencies: Dependencies,
249}
250
251impl Manifest {
252    /// Parses a manifest from TOML, reporting every field-level defect in one
253    /// pass via `SchemaErrors`.
254    ///
255    /// # Errors
256    ///
257    /// Returns `Err(SchemaErrors)` with a single `TomlParse` error if TOML
258    /// syntax fails; a single error if `manifest_schema_version` is malformed
259    /// or unsupported (short-circuit, no field-level validation); or one or
260    /// more field-level errors with field-path context.
261    ///
262    /// # Examples
263    ///
264    /// ```
265    /// use influxdb3_plugin_schemas::Manifest;
266    ///
267    /// let source = r#"
268    /// manifest_schema_version = "1.0"
269    ///
270    /// [plugin]
271    /// name = "example"
272    /// version = "0.1.0"
273    /// description = "Example plugin."
274    /// triggers = ["process_writes"]
275    ///
276    /// [dependencies]
277    /// database_version = ">=3.0.0"
278    /// "#;
279    ///
280    /// let manifest = Manifest::parse_toml(source).unwrap();
281    /// assert_eq!(manifest.plugin.name.as_str(), "example");
282    /// ```
283    pub fn parse_toml(input: &str) -> Result<Self, crate::SchemaErrors> {
284        use crate::raw::RawManifest;
285        use crate::{FieldPath, ReportedError, SchemaErrors};
286        use std::str::FromStr;
287
288        // Phase 1: raw deserialize. Syntax / required-field errors are fatal.
289        let raw: RawManifest = toml::from_str(input)
290            .map_err(|source| SchemaErrors::single_at_root(SchemaError::TomlParse { source }))?;
291
292        // Phase 2a: schema-version short-circuit — skips field-level validation.
293        let schema_version = ManifestSchemaVersion::from_str(&raw.manifest_schema_version)
294            .map_err(|e| {
295                SchemaErrors::new(vec![ReportedError::new(
296                    FieldPath::root().field("manifest_schema_version"),
297                    e,
298                )])
299            })?;
300
301        // Phase 2b: collect field-level errors.
302        let mut errors = Vec::new();
303        let plugin_path = FieldPath::root().field("plugin");
304        let deps_path = FieldPath::root().field("dependencies");
305
306        let name = PluginName::from_str(&raw.plugin.name);
307        let name_ok = name.as_ref().ok().cloned();
308        if let Err(e) = name {
309            errors.push(ReportedError::new(plugin_path.field("name"), e));
310        }
311
312        let version = semver::Version::parse(&raw.plugin.version).map_err(|source| {
313            SchemaError::InvalidVersion {
314                version: raw.plugin.version.clone(),
315                source,
316            }
317        });
318        let version_ok = version.as_ref().ok().cloned();
319        if let Err(e) = version {
320            errors.push(ReportedError::new(plugin_path.field("version"), e));
321        }
322
323        let description = Description::try_new(&raw.plugin.description);
324        let description_ok = description.as_ref().ok().cloned();
325        if let Err(e) = description {
326            errors.push(ReportedError::new(plugin_path.field("description"), e));
327        }
328
329        // Triggers: non-empty + each entry must parse as TriggerType.
330        let mut triggers_ok: Vec<TriggerType> = Vec::with_capacity(raw.plugin.triggers.len());
331        if raw.plugin.triggers.is_empty() {
332            errors.push(ReportedError::new(
333                plugin_path.field("triggers"),
334                SchemaError::EmptyTriggers,
335            ));
336        } else {
337            for (i, trig) in raw.plugin.triggers.iter().enumerate() {
338                match TriggerType::from_str(trig) {
339                    Ok(t) => triggers_ok.push(t),
340                    Err(e) => errors.push(ReportedError::new(
341                        plugin_path.field("triggers").index(i),
342                        e,
343                    )),
344                }
345            }
346        }
347
348        // Optional URL fields: must parse and use http/https scheme when present.
349        let homepage = parse_optional_http_url_from_path(
350            &raw.plugin.homepage,
351            &mut errors,
352            &plugin_path,
353            "homepage",
354        );
355        let repository = parse_optional_http_url_from_path(
356            &raw.plugin.repository,
357            &mut errors,
358            &plugin_path,
359            "repository",
360        );
361        let documentation = parse_optional_http_url_from_path(
362            &raw.plugin.documentation,
363            &mut errors,
364            &plugin_path,
365            "documentation",
366        );
367
368        let database_version = semver::VersionReq::parse(&raw.dependencies.database_version)
369            .map_err(|source| SchemaError::InvalidDatabaseVersion {
370                range: raw.dependencies.database_version.clone(),
371                source,
372            });
373        let database_version_ok = database_version.as_ref().ok().cloned();
374        if let Err(e) = database_version {
375            errors.push(ReportedError::new(deps_path.field("database_version"), e));
376        }
377
378        let mut python_ok: Vec<PythonRequirement> =
379            Vec::with_capacity(raw.dependencies.python.len());
380        for (i, p) in raw.dependencies.python.iter().enumerate() {
381            match PythonRequirement::try_new(p) {
382                Ok(pr) => python_ok.push(pr),
383                Err(e) => errors.push(ReportedError::new(deps_path.field("python").index(i), e)),
384            }
385        }
386
387        let plugins_ok =
388            validate_raw_plugin_dependencies(&raw.dependencies.plugins, &deps_path, &mut errors);
389
390        if !errors.is_empty() {
391            return Err(SchemaErrors::new(errors));
392        }
393
394        // Safe unwraps: each `_ok` is `Some(_)` whenever no error was pushed.
395        Ok(Manifest {
396            manifest_schema_version: schema_version,
397            plugin: PluginMetadata {
398                name: name_ok.unwrap(),
399                version: version_ok.unwrap(),
400                description: description_ok.unwrap(),
401                triggers: triggers_ok,
402                homepage,
403                repository,
404                documentation,
405                exclude: raw.plugin.exclude,
406            },
407            dependencies: Dependencies {
408                database_version: database_version_ok.unwrap(),
409                python: python_ok,
410                plugins: plugins_ok,
411            },
412        })
413    }
414}
415
416/// Parses an optional URL field, requiring `http` or `https` scheme. Returns
417/// `None` when absent; on parse or scheme failure, pushes a `ReportedError`
418/// and returns `None`. Shared with `index.rs` for per-entry URL validation.
419pub(crate) fn parse_optional_http_url_from_path(
420    raw: &Option<String>,
421    errors: &mut Vec<crate::ReportedError>,
422    parent: &crate::FieldPath,
423    field_name: &str,
424) -> Option<url::Url> {
425    use crate::ReportedError;
426
427    let raw = raw.as_deref()?;
428    match url::Url::parse(raw) {
429        Ok(u) => match u.scheme() {
430            "http" | "https" => Some(u),
431            other => {
432                errors.push(ReportedError::new(
433                    parent.field(field_name),
434                    SchemaError::InvalidUrlScheme {
435                        url: raw.to_owned(),
436                        scheme: other.to_owned(),
437                    },
438                ));
439                None
440            }
441        },
442        Err(source) => {
443            errors.push(ReportedError::new(
444                parent.field(field_name),
445                SchemaError::InvalidUrl {
446                    url: raw.to_owned(),
447                    source,
448                },
449            ));
450            None
451        }
452    }
453}
454
455/// Validates `dependencies.plugins` entries, pushing errors into `errors`
456/// with paths relative to `deps_path` (the `dependencies` table). Returns the
457/// successfully validated entries. Shared by `Manifest::parse_toml` and
458/// `Index::parse_json` so both parsers apply identical rules.
459///
460/// Entries must be unique by `(index_url, canonical(name))`: `index_url`
461/// compares by parsed-URL equality (normalized) and `name` by the existing
462/// lowercase-and-underscore folding. The duplicate check considers only
463/// entries whose `index_url` and `name` parsed cleanly, so one malformed
464/// field never cascades into spurious duplicate errors.
465pub(crate) fn validate_raw_plugin_dependencies(
466    raw: &[crate::raw::RawPluginDependency],
467    deps_path: &crate::FieldPath,
468    errors: &mut Vec<crate::ReportedError>,
469) -> Vec<PluginDependency> {
470    use crate::ReportedError;
471    use std::collections::HashSet;
472
473    let mut out: Vec<PluginDependency> = Vec::with_capacity(raw.len());
474    let mut seen: HashSet<(String, String)> = HashSet::new();
475
476    for (i, dep) in raw.iter().enumerate() {
477        let entry_path = deps_path.field("plugins").index(i);
478
479        let index_url = match IndexUrl::try_new(&dep.index_url) {
480            Ok(u) => Some(u),
481            Err(e) => {
482                errors.push(ReportedError::new(entry_path.field("index_url"), e));
483                None
484            }
485        };
486
487        let name = match crate::PluginName::from_str(&dep.name) {
488            Ok(n) => Some(n),
489            Err(e) => {
490                errors.push(ReportedError::new(entry_path.field("name"), e));
491                None
492            }
493        };
494
495        let version = match semver::VersionReq::parse(&dep.version) {
496            Ok(v) => Some(v),
497            Err(source) => {
498                errors.push(ReportedError::new(
499                    entry_path.field("version"),
500                    SchemaError::InvalidPluginDependencyVersion {
501                        range: dep.version.clone(),
502                        source,
503                    },
504                ));
505                None
506            }
507        };
508
509        let duplicate = if let (Some(u), Some(n)) = (&index_url, &name) {
510            let key = (u.as_url().as_str().to_owned(), n.canonical());
511            let is_dup = !seen.insert(key);
512            if is_dup {
513                errors.push(ReportedError::new(
514                    entry_path,
515                    SchemaError::DuplicatePluginDependency {
516                        index_url: u.as_url().as_str().to_owned(),
517                        name: n.as_str().to_owned(),
518                    },
519                ));
520            }
521            is_dup
522        } else {
523            false
524        };
525
526        if let (Some(index_url), Some(name), Some(version), false) =
527            (index_url, name, version, duplicate)
528        {
529            out.push(PluginDependency {
530                index_url,
531                name,
532                version,
533            });
534        }
535    }
536    out
537}
538
539// No TOML serializer: manifests are author-written and the SDK never emits
540// them. If one is added later, introduce a dedicated
541// `SchemaError::TomlSerialize { source: toml::ser::Error }` variant rather
542// than casting through `toml::de::Error::custom`.
543
544/// `[plugin]` section of the manifest.
545#[derive(Debug, Clone, PartialEq, serde::Deserialize, serde::Serialize)]
546pub struct PluginMetadata {
547    pub name: crate::PluginName,
548    pub version: semver::Version,
549    pub description: Description,
550    pub triggers: Vec<TriggerType>,
551    #[serde(default, skip_serializing_if = "Option::is_none")]
552    pub homepage: Option<url::Url>,
553    #[serde(default, skip_serializing_if = "Option::is_none")]
554    pub repository: Option<url::Url>,
555    #[serde(default, skip_serializing_if = "Option::is_none")]
556    pub documentation: Option<url::Url>,
557    /// Gitignore-style patterns, relative to the plugin root, naming files to
558    /// omit from source-file selection (packaging + validation). Optional;
559    /// missing or `[]` means no manifest-level exclusions. Pattern *syntax* is
560    /// validated by the SDK at selection time, not here.
561    #[serde(default, skip_serializing_if = "Vec::is_empty")]
562    pub exclude: Vec<String>,
563}
564
565/// `[dependencies]` section of the manifest.
566#[derive(Debug, Clone, PartialEq, serde::Deserialize, serde::Serialize)]
567pub struct Dependencies {
568    pub database_version: semver::VersionReq,
569    #[serde(default)]
570    pub python: Vec<PythonRequirement>,
571    /// Inter-plugin dependencies. Deliberately not the `python` serde pattern
572    /// (always emitted): omitting the empty field keeps pre-existing index
573    /// entries byte-identical when legacy indexes are rewritten by newer
574    /// tooling (design doc D4).
575    #[serde(default, skip_serializing_if = "Vec::is_empty")]
576    pub plugins: Vec<PluginDependency>,
577}
578
579#[cfg(test)]
580mod schema_version_tests {
581    use super::*;
582    use assert_matches::assert_matches;
583
584    #[test]
585    fn parses_major_minor() {
586        let v: ManifestSchemaVersion = "1.0".parse().unwrap();
587        assert_eq!(v.major(), 1);
588        assert_eq!(v.minor(), 0);
589    }
590
591    #[test]
592    fn parses_higher_minor_within_known_major() {
593        let v: ManifestSchemaVersion = "1.42".parse().unwrap();
594        assert_eq!((v.major(), v.minor()), (1, 42));
595    }
596
597    #[test]
598    fn rejects_malformed() {
599        assert_matches!(
600            "1".parse::<ManifestSchemaVersion>(),
601            Err(SchemaError::MalformedSchemaVersion { .. })
602        );
603        assert_matches!(
604            "1.0.0".parse::<ManifestSchemaVersion>(),
605            Err(SchemaError::MalformedSchemaVersion { .. })
606        );
607        assert_matches!(
608            "a.b".parse::<ManifestSchemaVersion>(),
609            Err(SchemaError::MalformedSchemaVersion { .. })
610        );
611    }
612
613    #[test]
614    fn rejects_unsupported_major() {
615        let err = "2.0".parse::<ManifestSchemaVersion>().unwrap_err();
616        assert_matches!(err, SchemaError::UnsupportedManifestMajor { .. });
617    }
618
619    #[test]
620    fn display_round_trip() {
621        let v = ManifestSchemaVersion::new(1, 3);
622        assert_eq!(format!("{v}"), "1.3");
623        let parsed: ManifestSchemaVersion = "1.3".parse().unwrap();
624        assert_eq!(parsed, v);
625    }
626
627    #[test]
628    fn current_major_equals_supported() {
629        assert_eq!(
630            ManifestSchemaVersion::CURRENT.major(),
631            SUPPORTED_MANIFEST_MAJOR
632        );
633    }
634
635    #[test]
636    fn current_to_string_round_trips() {
637        let s = ManifestSchemaVersion::CURRENT.to_string();
638        let parsed: ManifestSchemaVersion = s.parse().unwrap();
639        assert_eq!(parsed, ManifestSchemaVersion::CURRENT);
640    }
641
642    #[test]
643    fn current_is_one_three() {
644        assert_eq!(
645            (
646                ManifestSchemaVersion::CURRENT.major(),
647                ManifestSchemaVersion::CURRENT.minor()
648            ),
649            (1, 3)
650        );
651    }
652}
653
654#[cfg(test)]
655mod description_tests {
656    use super::*;
657    use assert_matches::assert_matches;
658
659    #[test]
660    fn accepts_up_to_200_chars() {
661        let ok_200 = "a".repeat(200);
662        let d = Description::try_new(&ok_200).unwrap();
663        assert_eq!(d.as_str().chars().count(), 200);
664    }
665
666    #[test]
667    fn rejects_201_chars() {
668        let too_long = "a".repeat(201);
669        assert_matches!(
670            Description::try_new(&too_long),
671            Err(SchemaError::DescriptionTooLong { len: 201 })
672        );
673    }
674
675    #[test]
676    fn rejects_empty() {
677        assert_matches!(Description::try_new(""), Err(SchemaError::DescriptionEmpty));
678    }
679
680    #[test]
681    fn accepts_single_char() {
682        assert!(Description::try_new("x").is_ok());
683    }
684
685    #[test]
686    fn rejects_multiline_description_lf() {
687        assert_matches!(
688            Description::try_new("first\nsecond"),
689            Err(SchemaError::DescriptionMultiline { .. })
690        );
691    }
692
693    #[test]
694    fn rejects_multiline_description_crlf() {
695        assert_matches!(
696            Description::try_new("first\r\nsecond"),
697            Err(SchemaError::DescriptionMultiline { .. })
698        );
699    }
700
701    #[test]
702    fn rejects_multiline_description_cr() {
703        assert_matches!(
704            Description::try_new("first\rsecond"),
705            Err(SchemaError::DescriptionMultiline { .. })
706        );
707    }
708
709    /// A 201-char string containing a newline must be reported as multiline,
710    /// not as too-long. The newline rule is the more specific.
711    /// `rejects_201_chars` proves that the same 201-char input absent a
712    /// newline fires `DescriptionTooLong`; together they pin precedence.
713    #[test]
714    fn multiline_check_precedes_length_check() {
715        let s = format!("{}\n{}", "a".repeat(100), "b".repeat(100));
716        assert_eq!(s.chars().count(), 201, "fixture sanity: input is 201 chars");
717        let err = Description::try_new(&s).expect_err("must reject");
718        let SchemaError::DescriptionMultiline { len } = err else {
719            panic!("expected DescriptionMultiline, got {err:?}");
720        };
721        assert_eq!(len, 201);
722    }
723}
724
725#[cfg(test)]
726mod trigger_type_tests {
727    use super::*;
728    use rstest::rstest;
729
730    #[rstest]
731    #[case("process_writes", TriggerType::ProcessWrites)]
732    #[case("process_scheduled_call", TriggerType::ProcessScheduledCall)]
733    #[case("process_request", TriggerType::ProcessRequest)]
734    fn valid_triggers_parse(#[case] input: &str, #[case] expected: TriggerType) {
735        assert_eq!(input.parse::<TriggerType>().unwrap(), expected);
736    }
737
738    #[rstest]
739    #[case("on_startup")]
740    #[case("process_Writes")]
741    #[case("")]
742    fn invalid_triggers_rejected(#[case] input: &str) {
743        use assert_matches::assert_matches;
744        assert_matches!(
745            input.parse::<TriggerType>(),
746            Err(SchemaError::UnknownTriggerType { .. })
747        );
748    }
749
750    #[test]
751    fn serde_round_trip() {
752        let t = TriggerType::ProcessScheduledCall;
753        let json = serde_json::to_string(&t).unwrap();
754        assert_eq!(json, "\"process_scheduled_call\"");
755        let back: TriggerType = serde_json::from_str(&json).unwrap();
756        assert_eq!(back, t);
757    }
758
759    #[test]
760    fn serde_rejects_unknown() {
761        let result: Result<TriggerType, _> = serde_json::from_str("\"on_startup\"");
762        let err = result.expect_err("should reject unknown trigger");
763        assert!(
764            err.to_string().contains("on_startup"),
765            "error should name the rejected trigger, got: {err}"
766        );
767    }
768}
769
770#[cfg(test)]
771mod python_requirement_tests {
772    use super::*;
773    use assert_matches::assert_matches;
774
775    #[test]
776    fn accepts_simple_requirement() {
777        assert!(PythonRequirement::try_new("requests>=2.31,<3").is_ok());
778    }
779
780    #[test]
781    fn accepts_compatible_release() {
782        assert!(PythonRequirement::try_new("pydantic~=2.0").is_ok());
783    }
784
785    #[test]
786    fn rejects_malformed() {
787        // `>>=` (double operator) is unambiguously rejected by PEP 508.
788        assert_matches!(
789            PythonRequirement::try_new("requests>>=2.0"),
790            Err(SchemaError::InvalidPythonRequirement { .. })
791        );
792    }
793
794    #[test]
795    fn preserves_original_string() {
796        let r = PythonRequirement::try_new("requests>=2.31,<3").unwrap();
797        assert_eq!(r.as_str(), "requests>=2.31,<3");
798    }
799}
800
801#[cfg(test)]
802mod manifest_parse_tests {
803    use super::*;
804    use assert_matches::assert_matches;
805    use pretty_assertions::assert_eq;
806
807    const MINIMAL: &str = r#"
808manifest_schema_version = "1.0"
809
810[plugin]
811name = "downsampler"
812version = "1.2.0"
813description = "Test plugin"
814triggers = ["process_writes"]
815
816[dependencies]
817database_version = ">=3.2.0,<4.0.0"
818"#;
819
820    const FULL: &str = r#"
821manifest_schema_version = "1.0"
822
823[plugin]
824name = "downsampler"
825version = "1.2.0"
826description = "Notify an HTTP endpoint on every WAL commit."
827triggers = ["process_writes", "process_scheduled_call"]
828homepage = "https://influxdata.com"
829repository = "https://github.com/influxdata/plugin-downsampler"
830documentation = "https://github.com/influxdata/plugin-downsampler/readme.md"
831
832[dependencies]
833database_version = ">=3.2.0,<4.0.0"
834python = ["requests>=2.31,<3", "pydantic~=2.0"]
835"#;
836
837    #[test]
838    fn parses_minimal_manifest() {
839        let m = Manifest::parse_toml(MINIMAL).expect("minimal manifest should parse");
840        assert_eq!(m.plugin.name.as_str(), "downsampler");
841        assert_eq!(m.plugin.version, semver::Version::new(1, 2, 0));
842        assert_eq!(m.plugin.triggers.len(), 1);
843    }
844
845    #[test]
846    fn parses_full_manifest() {
847        let m = Manifest::parse_toml(FULL).expect("full manifest should parse");
848        assert_eq!(m.plugin.triggers.len(), 2);
849        assert_eq!(m.dependencies.python.len(), 2);
850        assert!(m.plugin.homepage.is_some());
851    }
852
853    #[test]
854    fn parses_snapshot_matches() {
855        let m = Manifest::parse_toml(FULL).unwrap();
856        insta::assert_debug_snapshot!("full_manifest_parsed", m);
857    }
858
859    #[test]
860    fn rejects_missing_plugin_section() {
861        let missing = r#"
862manifest_schema_version = "1.0"
863
864[dependencies]
865database_version = ">=3.2.0"
866"#;
867        let errors = Manifest::parse_toml(missing).unwrap_err();
868        assert_eq!(errors.errors().len(), 1);
869        assert_eq!(errors.errors()[0].path.as_str(), "");
870        assert_matches!(errors.errors()[0].error, SchemaError::TomlParse { .. });
871    }
872
873    #[test]
874    fn rejects_missing_schema_version() {
875        let missing = r#"
876[plugin]
877name = "x"
878version = "1.0.0"
879description = "x"
880triggers = ["process_writes"]
881
882[dependencies]
883database_version = ">=3.2.0"
884"#;
885        let errors = Manifest::parse_toml(missing).unwrap_err();
886        assert_eq!(errors.errors().len(), 1);
887        assert_eq!(errors.errors()[0].path.as_str(), "");
888        assert_matches!(errors.errors()[0].error, SchemaError::TomlParse { .. });
889    }
890
891    #[test]
892    fn ignores_unknown_top_level_field() {
893        // Field is placed above any table header so it's unambiguously
894        // top-level (appending to MINIMAL would land it in `[dependencies]`).
895        let with_unknown = r#"
896manifest_schema_version = "1.0"
897experimental_feature = true
898
899[plugin]
900name = "downsampler"
901version = "1.2.0"
902description = "Test plugin"
903triggers = ["process_writes"]
904
905[dependencies]
906database_version = ">=3.2.0,<4.0.0"
907"#;
908        assert!(Manifest::parse_toml(with_unknown).is_ok());
909    }
910
911    #[test]
912    fn parses_one_one_schema_version() {
913        let src = MINIMAL.replace(
914            r#"manifest_schema_version = "1.0""#,
915            r#"manifest_schema_version = "1.1""#,
916        );
917        let m = Manifest::parse_toml(&src).unwrap();
918        assert_eq!(m.manifest_schema_version.minor(), 1);
919    }
920
921    /// N distinct field-level defects must produce exactly N errors in one
922    /// pass — guards against accidental short-circuiting in Phase 2.
923    #[test]
924    fn collects_multiple_defects_in_one_pass() {
925        // Four defects: name contains a space, non-SemVer version, unknown
926        // trigger, ftp URL.
927        let input = r#"
928manifest_schema_version = "1.0"
929
930[plugin]
931name = "Bad Name"
932version = "1.2"
933description = "multi-defect fixture"
934triggers = ["on_startup"]
935homepage = "ftp://bad"
936
937[dependencies]
938database_version = ">=3.0.0"
939"#;
940        let errors = Manifest::parse_toml(input).expect_err("should fail");
941        let e = errors.errors();
942        assert_eq!(
943            e.len(),
944            4,
945            "expected 4 errors, got {}: {:?}",
946            e.len(),
947            e.iter().map(|r| &r.error).collect::<Vec<_>>()
948        );
949
950        let paths: Vec<&str> = e.iter().map(|r| r.path.as_str()).collect();
951        assert!(
952            paths.contains(&"plugin.name"),
953            "missing plugin.name: {paths:?}"
954        );
955        assert!(
956            paths.contains(&"plugin.version"),
957            "missing plugin.version: {paths:?}"
958        );
959        assert!(
960            paths.contains(&"plugin.triggers[0]"),
961            "missing plugin.triggers[0]: {paths:?}"
962        );
963        assert!(
964            paths.contains(&"plugin.homepage"),
965            "missing plugin.homepage: {paths:?}"
966        );
967    }
968
969    /// An unsupported major short-circuits before field-level validation,
970    /// returning exactly 1 error even when other defects exist.
971    #[test]
972    fn schema_version_mismatch_short_circuits_with_single_error() {
973        let input = r#"
974manifest_schema_version = "99.0"
975
976[plugin]
977name = "Bad Name"
978version = "1.0.0"
979description = "x"
980triggers = ["process_writes"]
981
982[dependencies]
983database_version = ">=3.0.0"
984"#;
985        let errors = Manifest::parse_toml(input).expect_err("should fail");
986        assert_eq!(
987            errors.errors().len(),
988            1,
989            "short-circuit: expected exactly 1 error"
990        );
991        assert_matches::assert_matches!(
992            errors.errors()[0].error,
993            SchemaError::UnsupportedManifestMajor { .. }
994        );
995    }
996
997    #[test]
998    fn accepts_missing_exclude_defaults_empty() {
999        let m = Manifest::parse_toml(MINIMAL).unwrap();
1000        assert!(m.plugin.exclude.is_empty());
1001    }
1002
1003    #[test]
1004    fn accepts_empty_exclude() {
1005        let src = MINIMAL.replace(
1006            r#"triggers = ["process_writes"]"#,
1007            "triggers = [\"process_writes\"]\nexclude = []",
1008        );
1009        let m = Manifest::parse_toml(&src).unwrap();
1010        assert!(m.plugin.exclude.is_empty());
1011    }
1012
1013    #[test]
1014    fn accepts_exclude_patterns_verbatim() {
1015        let src = MINIMAL.replace(
1016            r#"triggers = ["process_writes"]"#,
1017            "triggers = [\"process_writes\"]\nexclude = [\"tests/**\", \"*.pyc\"]",
1018        );
1019        let m = Manifest::parse_toml(&src).unwrap();
1020        assert_eq!(
1021            m.plugin.exclude,
1022            vec!["tests/**".to_string(), "*.pyc".to_string()]
1023        );
1024    }
1025
1026    #[test]
1027    fn exclude_works_regardless_of_minor_version() {
1028        // Parser must not branch exclude support on the minor version.
1029        for ver in ["1.0", "1.1"] {
1030            let src = MINIMAL
1031                .replace(
1032                    r#"manifest_schema_version = "1.0""#,
1033                    &format!("manifest_schema_version = \"{ver}\""),
1034                )
1035                .replace(
1036                    r#"triggers = ["process_writes"]"#,
1037                    "triggers = [\"process_writes\"]\nexclude = [\"tests/**\"]",
1038                );
1039            let m = Manifest::parse_toml(&src).unwrap_or_else(|e| panic!("ver {ver}: {e}"));
1040            assert_eq!(m.plugin.exclude, vec!["tests/**".to_string()], "ver {ver}");
1041        }
1042    }
1043
1044    #[test]
1045    fn rejects_non_array_exclude() {
1046        let src = MINIMAL.replace(
1047            r#"triggers = ["process_writes"]"#,
1048            "triggers = [\"process_writes\"]\nexclude = \"tests\"",
1049        );
1050        let errs = Manifest::parse_toml(&src).unwrap_err();
1051        assert_matches!(errs.errors()[0].error, SchemaError::TomlParse { .. });
1052    }
1053
1054    #[test]
1055    fn rejects_non_string_exclude_item() {
1056        let src = MINIMAL.replace(
1057            r#"triggers = ["process_writes"]"#,
1058            "triggers = [\"process_writes\"]\nexclude = [1, 2]",
1059        );
1060        let errs = Manifest::parse_toml(&src).unwrap_err();
1061        assert_matches!(errs.errors()[0].error, SchemaError::TomlParse { .. });
1062    }
1063
1064    /// A triple-quoted TOML string with embedded newlines must be rejected
1065    /// for `plugin.description`. (TOML strips the leading newline immediately
1066    /// after `"""`, so the rejection here fires on the inner `\n`s.)
1067    #[test]
1068    fn rejects_description_with_embedded_newline_in_toml() {
1069        let input = r#"
1070manifest_schema_version = "1.0"
1071
1072[plugin]
1073name = "downsampler"
1074version = "1.2.0"
1075description = """
1076line one
1077line two
1078"""
1079triggers = ["process_writes"]
1080
1081[dependencies]
1082database_version = ">=3.0.0"
1083"#;
1084        let errors = Manifest::parse_toml(input).expect_err("multiline description must fail");
1085        assert_eq!(errors.errors().len(), 1);
1086        let e = &errors.errors()[0];
1087        assert_eq!(e.path.as_str(), "plugin.description");
1088        assert_matches!(e.error, SchemaError::DescriptionMultiline { .. });
1089    }
1090}
1091
1092#[cfg(test)]
1093mod plugin_dependency_tests {
1094    use super::*;
1095    use assert_matches::assert_matches;
1096    use rstest::rstest;
1097
1098    fn manifest_with_plugins(plugins_toml: &str) -> String {
1099        format!(
1100            r#"
1101manifest_schema_version = "1.3"
1102
1103[plugin]
1104name = "downsampler"
1105version = "1.2.0"
1106description = "Test plugin"
1107triggers = ["process_writes"]
1108
1109[dependencies]
1110database_version = ">=3.2.0,<4.0.0"
1111{plugins_toml}
1112"#
1113        )
1114    }
1115
1116    #[test]
1117    fn parses_plugin_dependencies() {
1118        let src = manifest_with_plugins(
1119            r#"
1120[[dependencies.plugins]]
1121index_url = "https://plugins.example.com/index.json"
1122name = "geo-lookup"
1123version = ">=1.0.0,<2.0.0"
1124
1125[[dependencies.plugins]]
1126index_url = "https://other.example.com/index.json"
1127name = "geo-lookup"
1128version = "2.1"
1129"#,
1130        );
1131        let m = Manifest::parse_toml(&src).expect("plugin deps should parse");
1132        assert_eq!(m.dependencies.plugins.len(), 2);
1133        let dep = &m.dependencies.plugins[0];
1134        assert_eq!(
1135            dep.index_url.as_url().as_str(),
1136            "https://plugins.example.com/index.json"
1137        );
1138        assert_eq!(dep.name.as_str(), "geo-lookup");
1139        assert!(dep.version.matches(&semver::Version::new(1, 5, 0)));
1140        // Cargo semantics: bare "2.1" means ^2.1.
1141        assert!(
1142            m.dependencies.plugins[1]
1143                .version
1144                .matches(&semver::Version::new(2, 5, 0))
1145        );
1146    }
1147
1148    #[test]
1149    fn missing_plugins_defaults_empty() {
1150        let src = manifest_with_plugins("");
1151        let m = Manifest::parse_toml(&src).unwrap();
1152        assert!(m.dependencies.plugins.is_empty());
1153    }
1154
1155    #[rstest]
1156    #[case(
1157        r#"index_url = "s3://bucket/index.json""#,
1158        "dependencies.plugins[0].index_url",
1159        "UnsupportedIndexUrlScheme"
1160    )]
1161    #[case(
1162        r#"index_url = "not a url""#,
1163        "dependencies.plugins[0].index_url",
1164        "InvalidUrl"
1165    )]
1166    #[case(
1167        r#"name = "Bad Name""#,
1168        "dependencies.plugins[0].name",
1169        "InvalidPluginName"
1170    )]
1171    #[case(
1172        r#"name = "con""#,
1173        "dependencies.plugins[0].name",
1174        "ReservedPluginName"
1175    )]
1176    #[case(
1177        r#"version = ">=bad""#,
1178        "dependencies.plugins[0].version",
1179        "InvalidPluginDependencyVersion"
1180    )]
1181    fn rejects_invalid_entry_field(
1182        #[case] override_line: &str,
1183        #[case] expected_path: &str,
1184        #[case] expected_variant: &str,
1185    ) {
1186        let (key, _) = override_line.split_once(" = ").unwrap();
1187        let mut lines = vec![
1188            r#"index_url = "https://plugins.example.com/index.json""#,
1189            r#"name = "geo-lookup""#,
1190            r#"version = ">=1.0.0""#,
1191        ];
1192        for line in &mut lines {
1193            if line.starts_with(key) {
1194                *line = override_line;
1195            }
1196        }
1197        let src =
1198            manifest_with_plugins(&format!("[[dependencies.plugins]]\n{}\n", lines.join("\n")));
1199        let errors = Manifest::parse_toml(&src).expect_err("should reject");
1200        assert_eq!(errors.errors().len(), 1, "errors: {errors}");
1201        assert_eq!(errors.errors()[0].path.as_str(), expected_path);
1202        assert_eq!(errors.errors()[0].error.variant_name(), expected_variant);
1203    }
1204
1205    /// Duplicates fold the name (`geo-lookup` == `geo_lookup`) and compare
1206    /// `index_url` by parsed-URL equality (`EXAMPLE.com` == `example.com`).
1207    #[rstest]
1208    #[case("https://plugins.example.com/index.json", "geo-lookup")]
1209    #[case("https://plugins.example.com/index.json", "geo_lookup")]
1210    #[case("https://plugins.EXAMPLE.com/index.json", "GEO-LOOKUP")]
1211    fn rejects_duplicate_entries(#[case] second_url: &str, #[case] second_name: &str) {
1212        let src = manifest_with_plugins(&format!(
1213            r#"
1214[[dependencies.plugins]]
1215index_url = "https://plugins.example.com/index.json"
1216name = "geo-lookup"
1217version = ">=1.0.0"
1218
1219[[dependencies.plugins]]
1220index_url = "{second_url}"
1221name = "{second_name}"
1222version = ">=2.0.0"
1223"#
1224        ));
1225        let errors = Manifest::parse_toml(&src).expect_err("duplicate should reject");
1226        assert_eq!(errors.errors().len(), 1, "errors: {errors}");
1227        assert_eq!(errors.errors()[0].path.as_str(), "dependencies.plugins[1]");
1228        assert_matches!(
1229            errors.errors()[0].error,
1230            SchemaError::DuplicatePluginDependency { .. }
1231        );
1232    }
1233
1234    /// The same canonical name at two different registries is legitimate —
1235    /// different `index_url`s are distinct plugins by the identity model.
1236    #[test]
1237    fn same_name_at_different_registries_allowed() {
1238        let src = manifest_with_plugins(
1239            r#"
1240[[dependencies.plugins]]
1241index_url = "https://a.example.com/index.json"
1242name = "geo-lookup"
1243version = ">=1.0.0"
1244
1245[[dependencies.plugins]]
1246index_url = "https://b.example.com/index.json"
1247name = "geo-lookup"
1248version = ">=1.0.0"
1249"#,
1250        );
1251        let m = Manifest::parse_toml(&src).expect("distinct registries should parse");
1252        assert_eq!(m.dependencies.plugins.len(), 2);
1253    }
1254
1255    /// One malformed field must not cascade into spurious duplicate errors,
1256    /// and defects across entries collect in one pass.
1257    #[test]
1258    fn collects_multiple_entry_defects_in_one_pass() {
1259        let src = manifest_with_plugins(
1260            r#"
1261[[dependencies.plugins]]
1262index_url = "s3://bucket/index.json"
1263name = "geo-lookup"
1264version = ">=1.0.0"
1265
1266[[dependencies.plugins]]
1267index_url = "https://plugins.example.com/index.json"
1268name = "geo-lookup"
1269version = ">=bad"
1270"#,
1271        );
1272        let errors = Manifest::parse_toml(&src).expect_err("should reject");
1273        let paths: Vec<&str> = errors.errors().iter().map(|r| r.path.as_str()).collect();
1274        assert_eq!(
1275            paths,
1276            vec![
1277                "dependencies.plugins[0].index_url",
1278                "dependencies.plugins[1].version"
1279            ],
1280            "no duplicate error should fire: entry 0's url never parsed"
1281        );
1282    }
1283
1284    /// A dependency entry missing a required key fails phase 1 as a
1285    /// root-level TOML parse error, consistent with other required fields.
1286    #[test]
1287    fn missing_required_key_is_root_parse_error() {
1288        let src = manifest_with_plugins(
1289            r#"
1290[[dependencies.plugins]]
1291index_url = "https://plugins.example.com/index.json"
1292version = ">=1.0.0"
1293"#,
1294        );
1295        let errors = Manifest::parse_toml(&src).expect_err("missing name should reject");
1296        assert_eq!(errors.errors().len(), 1);
1297        assert_eq!(errors.errors()[0].path.as_str(), "");
1298        assert_matches!(errors.errors()[0].error, SchemaError::TomlParse { .. });
1299    }
1300}
1301
1302#[cfg(test)]
1303mod validation_tests {
1304    use super::*;
1305    use assert_matches::assert_matches;
1306    use rstest::rstest;
1307
1308    fn with_fragment(key: &str, value: &str) -> String {
1309        format!(
1310            r#"
1311manifest_schema_version = "1.0"
1312
1313[plugin]
1314name = "x"
1315version = "1.0.0"
1316description = "x"
1317triggers = ["process_writes"]
1318{key} = {value}
1319
1320[dependencies]
1321database_version = ">=3.0.0"
1322"#
1323        )
1324    }
1325
1326    #[rstest]
1327    #[case("homepage", r#""ftp://bad/""#)]
1328    #[case("homepage", r#""file:///local""#)]
1329    #[case("repository", r#""git://bad""#)]
1330    #[case("documentation", r#""s3://bucket""#)]
1331    fn rejects_non_http_urls(#[case] field: &str, #[case] value: &str) {
1332        let manifest = with_fragment(field, value);
1333        let errors = Manifest::parse_toml(&manifest).unwrap_err();
1334        assert_eq!(errors.errors().len(), 1);
1335        assert_matches!(
1336            errors.errors()[0].error,
1337            SchemaError::InvalidUrlScheme { .. }
1338        );
1339        assert_eq!(errors.errors()[0].path.as_str(), &format!("plugin.{field}"));
1340    }
1341
1342    #[rstest]
1343    #[case("homepage", r#""http://example.com""#)]
1344    #[case("homepage", r#""https://example.com""#)]
1345    #[case("repository", r#""https://github.com/foo/bar""#)]
1346    #[case("documentation", r#""http://docs.example.com/plugin""#)]
1347    fn accepts_http_and_https_urls(#[case] field: &str, #[case] value: &str) {
1348        let manifest = with_fragment(field, value);
1349        Manifest::parse_toml(&manifest)
1350            .unwrap_or_else(|e| panic!("expected {field}={value} to parse, got {e}"));
1351    }
1352
1353    #[test]
1354    fn rejects_empty_triggers() {
1355        let input = r#"
1356manifest_schema_version = "1.0"
1357
1358[plugin]
1359name = "x"
1360version = "1.0.0"
1361description = "x"
1362triggers = []
1363
1364[dependencies]
1365database_version = ">=3.0.0"
1366"#;
1367        let errors = Manifest::parse_toml(input).unwrap_err();
1368        assert_eq!(errors.errors().len(), 1);
1369        assert_matches!(errors.errors()[0].error, SchemaError::EmptyTriggers);
1370        assert_eq!(errors.errors()[0].path.as_str(), "plugin.triggers");
1371    }
1372
1373    /// Invalid `dependencies.database_version` surfaces as
1374    /// `InvalidDatabaseVersion` with the `dependencies.database_version`
1375    /// path, not flattened through `serde::Error::custom`.
1376    #[test]
1377    fn rejects_invalid_database_version() {
1378        let input = r#"
1379manifest_schema_version = "1.0"
1380
1381[plugin]
1382name = "x"
1383version = "1.0.0"
1384description = "x"
1385triggers = ["process_writes"]
1386
1387[dependencies]
1388database_version = ">=not-a-version"
1389"#;
1390        let errors = Manifest::parse_toml(input).unwrap_err();
1391        assert_eq!(errors.errors().len(), 1);
1392        assert_matches!(
1393            errors.errors()[0].error,
1394            SchemaError::InvalidDatabaseVersion { .. }
1395        );
1396        assert_eq!(
1397            errors.errors()[0].path.as_str(),
1398            "dependencies.database_version"
1399        );
1400    }
1401}