Skip to main content

influxdb3_plugin_schemas/
manifest.rs

1//! Plugin manifest (`manifest.toml`) types and parsing.
2
3use crate::{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: 2 };
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/// A parsed plugin manifest.
234#[derive(Debug, Clone, PartialEq, serde::Deserialize, serde::Serialize)]
235pub struct Manifest {
236    pub manifest_schema_version: ManifestSchemaVersion,
237    pub plugin: PluginMetadata,
238    pub dependencies: Dependencies,
239}
240
241impl Manifest {
242    /// Parses a manifest from TOML, reporting every field-level defect in one
243    /// pass via `SchemaErrors`.
244    ///
245    /// # Errors
246    ///
247    /// Returns `Err(SchemaErrors)` with a single `TomlParse` error if TOML
248    /// syntax fails; a single error if `manifest_schema_version` is malformed
249    /// or unsupported (short-circuit, no field-level validation); or one or
250    /// more field-level errors with field-path context.
251    ///
252    /// # Examples
253    ///
254    /// ```
255    /// use influxdb3_plugin_schemas::Manifest;
256    ///
257    /// let source = r#"
258    /// manifest_schema_version = "1.0"
259    ///
260    /// [plugin]
261    /// name = "example"
262    /// version = "0.1.0"
263    /// description = "Example plugin."
264    /// triggers = ["process_writes"]
265    ///
266    /// [dependencies]
267    /// database_version = ">=3.0.0"
268    /// "#;
269    ///
270    /// let manifest = Manifest::parse_toml(source).unwrap();
271    /// assert_eq!(manifest.plugin.name.as_str(), "example");
272    /// ```
273    pub fn parse_toml(input: &str) -> Result<Self, crate::SchemaErrors> {
274        use crate::raw::RawManifest;
275        use crate::{FieldPath, ReportedError, SchemaErrors};
276        use std::str::FromStr;
277
278        // Phase 1: raw deserialize. Syntax / required-field errors are fatal.
279        let raw: RawManifest = toml::from_str(input)
280            .map_err(|source| SchemaErrors::single_at_root(SchemaError::TomlParse { source }))?;
281
282        // Phase 2a: schema-version short-circuit — skips field-level validation.
283        let schema_version = ManifestSchemaVersion::from_str(&raw.manifest_schema_version)
284            .map_err(|e| {
285                SchemaErrors::new(vec![ReportedError::new(
286                    FieldPath::root().field("manifest_schema_version"),
287                    e,
288                )])
289            })?;
290
291        // Phase 2b: collect field-level errors.
292        let mut errors = Vec::new();
293        let plugin_path = FieldPath::root().field("plugin");
294        let deps_path = FieldPath::root().field("dependencies");
295
296        let name = PluginName::from_str(&raw.plugin.name);
297        let name_ok = name.as_ref().ok().cloned();
298        if let Err(e) = name {
299            errors.push(ReportedError::new(plugin_path.field("name"), e));
300        }
301
302        let version = semver::Version::parse(&raw.plugin.version).map_err(|source| {
303            SchemaError::InvalidVersion {
304                version: raw.plugin.version.clone(),
305                source,
306            }
307        });
308        let version_ok = version.as_ref().ok().cloned();
309        if let Err(e) = version {
310            errors.push(ReportedError::new(plugin_path.field("version"), e));
311        }
312
313        let description = Description::try_new(&raw.plugin.description);
314        let description_ok = description.as_ref().ok().cloned();
315        if let Err(e) = description {
316            errors.push(ReportedError::new(plugin_path.field("description"), e));
317        }
318
319        // Triggers: non-empty + each entry must parse as TriggerType.
320        let mut triggers_ok: Vec<TriggerType> = Vec::with_capacity(raw.plugin.triggers.len());
321        if raw.plugin.triggers.is_empty() {
322            errors.push(ReportedError::new(
323                plugin_path.field("triggers"),
324                SchemaError::EmptyTriggers,
325            ));
326        } else {
327            for (i, trig) in raw.plugin.triggers.iter().enumerate() {
328                match TriggerType::from_str(trig) {
329                    Ok(t) => triggers_ok.push(t),
330                    Err(e) => errors.push(ReportedError::new(
331                        plugin_path.field("triggers").index(i),
332                        e,
333                    )),
334                }
335            }
336        }
337
338        // Optional URL fields: must parse and use http/https scheme when present.
339        let homepage = parse_optional_http_url_from_path(
340            &raw.plugin.homepage,
341            &mut errors,
342            &plugin_path,
343            "homepage",
344        );
345        let repository = parse_optional_http_url_from_path(
346            &raw.plugin.repository,
347            &mut errors,
348            &plugin_path,
349            "repository",
350        );
351        let documentation = parse_optional_http_url_from_path(
352            &raw.plugin.documentation,
353            &mut errors,
354            &plugin_path,
355            "documentation",
356        );
357
358        let database_version = semver::VersionReq::parse(&raw.dependencies.database_version)
359            .map_err(|source| SchemaError::InvalidDatabaseVersion {
360                range: raw.dependencies.database_version.clone(),
361                source,
362            });
363        let database_version_ok = database_version.as_ref().ok().cloned();
364        if let Err(e) = database_version {
365            errors.push(ReportedError::new(deps_path.field("database_version"), e));
366        }
367
368        let mut python_ok: Vec<PythonRequirement> =
369            Vec::with_capacity(raw.dependencies.python.len());
370        for (i, p) in raw.dependencies.python.iter().enumerate() {
371            match PythonRequirement::try_new(p) {
372                Ok(pr) => python_ok.push(pr),
373                Err(e) => errors.push(ReportedError::new(deps_path.field("python").index(i), e)),
374            }
375        }
376
377        if !errors.is_empty() {
378            return Err(SchemaErrors::new(errors));
379        }
380
381        // Safe unwraps: each `_ok` is `Some(_)` whenever no error was pushed.
382        Ok(Manifest {
383            manifest_schema_version: schema_version,
384            plugin: PluginMetadata {
385                name: name_ok.unwrap(),
386                version: version_ok.unwrap(),
387                description: description_ok.unwrap(),
388                triggers: triggers_ok,
389                homepage,
390                repository,
391                documentation,
392                exclude: raw.plugin.exclude,
393            },
394            dependencies: Dependencies {
395                database_version: database_version_ok.unwrap(),
396                python: python_ok,
397            },
398        })
399    }
400}
401
402/// Parses an optional URL field, requiring `http` or `https` scheme. Returns
403/// `None` when absent; on parse or scheme failure, pushes a `ReportedError`
404/// and returns `None`. Shared with `index.rs` for per-entry URL validation.
405pub(crate) fn parse_optional_http_url_from_path(
406    raw: &Option<String>,
407    errors: &mut Vec<crate::ReportedError>,
408    parent: &crate::FieldPath,
409    field_name: &str,
410) -> Option<url::Url> {
411    use crate::ReportedError;
412
413    let raw = raw.as_deref()?;
414    match url::Url::parse(raw) {
415        Ok(u) => match u.scheme() {
416            "http" | "https" => Some(u),
417            other => {
418                errors.push(ReportedError::new(
419                    parent.field(field_name),
420                    SchemaError::InvalidUrlScheme {
421                        url: raw.to_owned(),
422                        scheme: other.to_owned(),
423                    },
424                ));
425                None
426            }
427        },
428        Err(source) => {
429            errors.push(ReportedError::new(
430                parent.field(field_name),
431                SchemaError::InvalidUrl {
432                    url: raw.to_owned(),
433                    source,
434                },
435            ));
436            None
437        }
438    }
439}
440
441// No TOML serializer: manifests are author-written and the SDK never emits
442// them. If one is added later, introduce a dedicated
443// `SchemaError::TomlSerialize { source: toml::ser::Error }` variant rather
444// than casting through `toml::de::Error::custom`.
445
446/// `[plugin]` section of the manifest.
447#[derive(Debug, Clone, PartialEq, serde::Deserialize, serde::Serialize)]
448pub struct PluginMetadata {
449    pub name: crate::PluginName,
450    pub version: semver::Version,
451    pub description: Description,
452    pub triggers: Vec<TriggerType>,
453    #[serde(default, skip_serializing_if = "Option::is_none")]
454    pub homepage: Option<url::Url>,
455    #[serde(default, skip_serializing_if = "Option::is_none")]
456    pub repository: Option<url::Url>,
457    #[serde(default, skip_serializing_if = "Option::is_none")]
458    pub documentation: Option<url::Url>,
459    /// Gitignore-style patterns, relative to the plugin root, naming files to
460    /// omit from source-file selection (packaging + validation). Optional;
461    /// missing or `[]` means no manifest-level exclusions. Pattern *syntax* is
462    /// validated by the SDK at selection time, not here.
463    #[serde(default, skip_serializing_if = "Vec::is_empty")]
464    pub exclude: Vec<String>,
465}
466
467/// `[dependencies]` section of the manifest.
468#[derive(Debug, Clone, PartialEq, serde::Deserialize, serde::Serialize)]
469pub struct Dependencies {
470    pub database_version: semver::VersionReq,
471    #[serde(default)]
472    pub python: Vec<PythonRequirement>,
473}
474
475#[cfg(test)]
476mod schema_version_tests {
477    use super::*;
478    use assert_matches::assert_matches;
479
480    #[test]
481    fn parses_major_minor() {
482        let v: ManifestSchemaVersion = "1.0".parse().unwrap();
483        assert_eq!(v.major(), 1);
484        assert_eq!(v.minor(), 0);
485    }
486
487    #[test]
488    fn parses_higher_minor_within_known_major() {
489        let v: ManifestSchemaVersion = "1.42".parse().unwrap();
490        assert_eq!((v.major(), v.minor()), (1, 42));
491    }
492
493    #[test]
494    fn rejects_malformed() {
495        assert_matches!(
496            "1".parse::<ManifestSchemaVersion>(),
497            Err(SchemaError::MalformedSchemaVersion { .. })
498        );
499        assert_matches!(
500            "1.0.0".parse::<ManifestSchemaVersion>(),
501            Err(SchemaError::MalformedSchemaVersion { .. })
502        );
503        assert_matches!(
504            "a.b".parse::<ManifestSchemaVersion>(),
505            Err(SchemaError::MalformedSchemaVersion { .. })
506        );
507    }
508
509    #[test]
510    fn rejects_unsupported_major() {
511        let err = "2.0".parse::<ManifestSchemaVersion>().unwrap_err();
512        assert_matches!(err, SchemaError::UnsupportedManifestMajor { .. });
513    }
514
515    #[test]
516    fn display_round_trip() {
517        let v = ManifestSchemaVersion::new(1, 3);
518        assert_eq!(format!("{v}"), "1.3");
519        let parsed: ManifestSchemaVersion = "1.3".parse().unwrap();
520        assert_eq!(parsed, v);
521    }
522
523    #[test]
524    fn current_major_equals_supported() {
525        assert_eq!(
526            ManifestSchemaVersion::CURRENT.major(),
527            SUPPORTED_MANIFEST_MAJOR
528        );
529    }
530
531    #[test]
532    fn current_to_string_round_trips() {
533        let s = ManifestSchemaVersion::CURRENT.to_string();
534        let parsed: ManifestSchemaVersion = s.parse().unwrap();
535        assert_eq!(parsed, ManifestSchemaVersion::CURRENT);
536    }
537
538    #[test]
539    fn current_is_one_two() {
540        assert_eq!(
541            (
542                ManifestSchemaVersion::CURRENT.major(),
543                ManifestSchemaVersion::CURRENT.minor()
544            ),
545            (1, 2)
546        );
547    }
548}
549
550#[cfg(test)]
551mod description_tests {
552    use super::*;
553    use assert_matches::assert_matches;
554
555    #[test]
556    fn accepts_up_to_200_chars() {
557        let ok_200 = "a".repeat(200);
558        let d = Description::try_new(&ok_200).unwrap();
559        assert_eq!(d.as_str().chars().count(), 200);
560    }
561
562    #[test]
563    fn rejects_201_chars() {
564        let too_long = "a".repeat(201);
565        assert_matches!(
566            Description::try_new(&too_long),
567            Err(SchemaError::DescriptionTooLong { len: 201 })
568        );
569    }
570
571    #[test]
572    fn rejects_empty() {
573        assert_matches!(Description::try_new(""), Err(SchemaError::DescriptionEmpty));
574    }
575
576    #[test]
577    fn accepts_single_char() {
578        assert!(Description::try_new("x").is_ok());
579    }
580
581    #[test]
582    fn rejects_multiline_description_lf() {
583        assert_matches!(
584            Description::try_new("first\nsecond"),
585            Err(SchemaError::DescriptionMultiline { .. })
586        );
587    }
588
589    #[test]
590    fn rejects_multiline_description_crlf() {
591        assert_matches!(
592            Description::try_new("first\r\nsecond"),
593            Err(SchemaError::DescriptionMultiline { .. })
594        );
595    }
596
597    #[test]
598    fn rejects_multiline_description_cr() {
599        assert_matches!(
600            Description::try_new("first\rsecond"),
601            Err(SchemaError::DescriptionMultiline { .. })
602        );
603    }
604
605    /// A 201-char string containing a newline must be reported as multiline,
606    /// not as too-long. The newline rule is the more specific.
607    /// `rejects_201_chars` proves that the same 201-char input absent a
608    /// newline fires `DescriptionTooLong`; together they pin precedence.
609    #[test]
610    fn multiline_check_precedes_length_check() {
611        let s = format!("{}\n{}", "a".repeat(100), "b".repeat(100));
612        assert_eq!(s.chars().count(), 201, "fixture sanity: input is 201 chars");
613        let err = Description::try_new(&s).expect_err("must reject");
614        let SchemaError::DescriptionMultiline { len } = err else {
615            panic!("expected DescriptionMultiline, got {err:?}");
616        };
617        assert_eq!(len, 201);
618    }
619}
620
621#[cfg(test)]
622mod trigger_type_tests {
623    use super::*;
624    use rstest::rstest;
625
626    #[rstest]
627    #[case("process_writes", TriggerType::ProcessWrites)]
628    #[case("process_scheduled_call", TriggerType::ProcessScheduledCall)]
629    #[case("process_request", TriggerType::ProcessRequest)]
630    fn valid_triggers_parse(#[case] input: &str, #[case] expected: TriggerType) {
631        assert_eq!(input.parse::<TriggerType>().unwrap(), expected);
632    }
633
634    #[rstest]
635    #[case("on_startup")]
636    #[case("process_Writes")]
637    #[case("")]
638    fn invalid_triggers_rejected(#[case] input: &str) {
639        use assert_matches::assert_matches;
640        assert_matches!(
641            input.parse::<TriggerType>(),
642            Err(SchemaError::UnknownTriggerType { .. })
643        );
644    }
645
646    #[test]
647    fn serde_round_trip() {
648        let t = TriggerType::ProcessScheduledCall;
649        let json = serde_json::to_string(&t).unwrap();
650        assert_eq!(json, "\"process_scheduled_call\"");
651        let back: TriggerType = serde_json::from_str(&json).unwrap();
652        assert_eq!(back, t);
653    }
654
655    #[test]
656    fn serde_rejects_unknown() {
657        let result: Result<TriggerType, _> = serde_json::from_str("\"on_startup\"");
658        let err = result.expect_err("should reject unknown trigger");
659        assert!(
660            err.to_string().contains("on_startup"),
661            "error should name the rejected trigger, got: {err}"
662        );
663    }
664}
665
666#[cfg(test)]
667mod python_requirement_tests {
668    use super::*;
669    use assert_matches::assert_matches;
670
671    #[test]
672    fn accepts_simple_requirement() {
673        assert!(PythonRequirement::try_new("requests>=2.31,<3").is_ok());
674    }
675
676    #[test]
677    fn accepts_compatible_release() {
678        assert!(PythonRequirement::try_new("pydantic~=2.0").is_ok());
679    }
680
681    #[test]
682    fn rejects_malformed() {
683        // `>>=` (double operator) is unambiguously rejected by PEP 508.
684        assert_matches!(
685            PythonRequirement::try_new("requests>>=2.0"),
686            Err(SchemaError::InvalidPythonRequirement { .. })
687        );
688    }
689
690    #[test]
691    fn preserves_original_string() {
692        let r = PythonRequirement::try_new("requests>=2.31,<3").unwrap();
693        assert_eq!(r.as_str(), "requests>=2.31,<3");
694    }
695}
696
697#[cfg(test)]
698mod manifest_parse_tests {
699    use super::*;
700    use assert_matches::assert_matches;
701    use pretty_assertions::assert_eq;
702
703    const MINIMAL: &str = r#"
704manifest_schema_version = "1.0"
705
706[plugin]
707name = "downsampler"
708version = "1.2.0"
709description = "Test plugin"
710triggers = ["process_writes"]
711
712[dependencies]
713database_version = ">=3.2.0,<4.0.0"
714"#;
715
716    const FULL: &str = r#"
717manifest_schema_version = "1.0"
718
719[plugin]
720name = "downsampler"
721version = "1.2.0"
722description = "Notify an HTTP endpoint on every WAL commit."
723triggers = ["process_writes", "process_scheduled_call"]
724homepage = "https://influxdata.com"
725repository = "https://github.com/influxdata/plugin-downsampler"
726documentation = "https://github.com/influxdata/plugin-downsampler/readme.md"
727
728[dependencies]
729database_version = ">=3.2.0,<4.0.0"
730python = ["requests>=2.31,<3", "pydantic~=2.0"]
731"#;
732
733    #[test]
734    fn parses_minimal_manifest() {
735        let m = Manifest::parse_toml(MINIMAL).expect("minimal manifest should parse");
736        assert_eq!(m.plugin.name.as_str(), "downsampler");
737        assert_eq!(m.plugin.version, semver::Version::new(1, 2, 0));
738        assert_eq!(m.plugin.triggers.len(), 1);
739    }
740
741    #[test]
742    fn parses_full_manifest() {
743        let m = Manifest::parse_toml(FULL).expect("full manifest should parse");
744        assert_eq!(m.plugin.triggers.len(), 2);
745        assert_eq!(m.dependencies.python.len(), 2);
746        assert!(m.plugin.homepage.is_some());
747    }
748
749    #[test]
750    fn parses_snapshot_matches() {
751        let m = Manifest::parse_toml(FULL).unwrap();
752        insta::assert_debug_snapshot!("full_manifest_parsed", m);
753    }
754
755    #[test]
756    fn rejects_missing_plugin_section() {
757        let missing = r#"
758manifest_schema_version = "1.0"
759
760[dependencies]
761database_version = ">=3.2.0"
762"#;
763        let errors = Manifest::parse_toml(missing).unwrap_err();
764        assert_eq!(errors.errors().len(), 1);
765        assert_eq!(errors.errors()[0].path.as_str(), "");
766        assert_matches!(errors.errors()[0].error, SchemaError::TomlParse { .. });
767    }
768
769    #[test]
770    fn rejects_missing_schema_version() {
771        let missing = r#"
772[plugin]
773name = "x"
774version = "1.0.0"
775description = "x"
776triggers = ["process_writes"]
777
778[dependencies]
779database_version = ">=3.2.0"
780"#;
781        let errors = Manifest::parse_toml(missing).unwrap_err();
782        assert_eq!(errors.errors().len(), 1);
783        assert_eq!(errors.errors()[0].path.as_str(), "");
784        assert_matches!(errors.errors()[0].error, SchemaError::TomlParse { .. });
785    }
786
787    #[test]
788    fn ignores_unknown_top_level_field() {
789        // Field is placed above any table header so it's unambiguously
790        // top-level (appending to MINIMAL would land it in `[dependencies]`).
791        let with_unknown = r#"
792manifest_schema_version = "1.0"
793experimental_feature = true
794
795[plugin]
796name = "downsampler"
797version = "1.2.0"
798description = "Test plugin"
799triggers = ["process_writes"]
800
801[dependencies]
802database_version = ">=3.2.0,<4.0.0"
803"#;
804        assert!(Manifest::parse_toml(with_unknown).is_ok());
805    }
806
807    #[test]
808    fn parses_one_one_schema_version() {
809        let src = MINIMAL.replace(
810            r#"manifest_schema_version = "1.0""#,
811            r#"manifest_schema_version = "1.1""#,
812        );
813        let m = Manifest::parse_toml(&src).unwrap();
814        assert_eq!(m.manifest_schema_version.minor(), 1);
815    }
816
817    /// N distinct field-level defects must produce exactly N errors in one
818    /// pass — guards against accidental short-circuiting in Phase 2.
819    #[test]
820    fn collects_multiple_defects_in_one_pass() {
821        // Four defects: name contains a space, non-SemVer version, unknown
822        // trigger, ftp URL.
823        let input = r#"
824manifest_schema_version = "1.0"
825
826[plugin]
827name = "Bad Name"
828version = "1.2"
829description = "multi-defect fixture"
830triggers = ["on_startup"]
831homepage = "ftp://bad"
832
833[dependencies]
834database_version = ">=3.0.0"
835"#;
836        let errors = Manifest::parse_toml(input).expect_err("should fail");
837        let e = errors.errors();
838        assert_eq!(
839            e.len(),
840            4,
841            "expected 4 errors, got {}: {:?}",
842            e.len(),
843            e.iter().map(|r| &r.error).collect::<Vec<_>>()
844        );
845
846        let paths: Vec<&str> = e.iter().map(|r| r.path.as_str()).collect();
847        assert!(
848            paths.contains(&"plugin.name"),
849            "missing plugin.name: {paths:?}"
850        );
851        assert!(
852            paths.contains(&"plugin.version"),
853            "missing plugin.version: {paths:?}"
854        );
855        assert!(
856            paths.contains(&"plugin.triggers[0]"),
857            "missing plugin.triggers[0]: {paths:?}"
858        );
859        assert!(
860            paths.contains(&"plugin.homepage"),
861            "missing plugin.homepage: {paths:?}"
862        );
863    }
864
865    /// An unsupported major short-circuits before field-level validation,
866    /// returning exactly 1 error even when other defects exist.
867    #[test]
868    fn schema_version_mismatch_short_circuits_with_single_error() {
869        let input = r#"
870manifest_schema_version = "99.0"
871
872[plugin]
873name = "Bad Name"
874version = "1.0.0"
875description = "x"
876triggers = ["process_writes"]
877
878[dependencies]
879database_version = ">=3.0.0"
880"#;
881        let errors = Manifest::parse_toml(input).expect_err("should fail");
882        assert_eq!(
883            errors.errors().len(),
884            1,
885            "short-circuit: expected exactly 1 error"
886        );
887        assert_matches::assert_matches!(
888            errors.errors()[0].error,
889            SchemaError::UnsupportedManifestMajor { .. }
890        );
891    }
892
893    #[test]
894    fn accepts_missing_exclude_defaults_empty() {
895        let m = Manifest::parse_toml(MINIMAL).unwrap();
896        assert!(m.plugin.exclude.is_empty());
897    }
898
899    #[test]
900    fn accepts_empty_exclude() {
901        let src = MINIMAL.replace(
902            r#"triggers = ["process_writes"]"#,
903            "triggers = [\"process_writes\"]\nexclude = []",
904        );
905        let m = Manifest::parse_toml(&src).unwrap();
906        assert!(m.plugin.exclude.is_empty());
907    }
908
909    #[test]
910    fn accepts_exclude_patterns_verbatim() {
911        let src = MINIMAL.replace(
912            r#"triggers = ["process_writes"]"#,
913            "triggers = [\"process_writes\"]\nexclude = [\"tests/**\", \"*.pyc\"]",
914        );
915        let m = Manifest::parse_toml(&src).unwrap();
916        assert_eq!(
917            m.plugin.exclude,
918            vec!["tests/**".to_string(), "*.pyc".to_string()]
919        );
920    }
921
922    #[test]
923    fn exclude_works_regardless_of_minor_version() {
924        // Parser must not branch exclude support on the minor version.
925        for ver in ["1.0", "1.1"] {
926            let src = MINIMAL
927                .replace(
928                    r#"manifest_schema_version = "1.0""#,
929                    &format!("manifest_schema_version = \"{ver}\""),
930                )
931                .replace(
932                    r#"triggers = ["process_writes"]"#,
933                    "triggers = [\"process_writes\"]\nexclude = [\"tests/**\"]",
934                );
935            let m = Manifest::parse_toml(&src).unwrap_or_else(|e| panic!("ver {ver}: {e}"));
936            assert_eq!(m.plugin.exclude, vec!["tests/**".to_string()], "ver {ver}");
937        }
938    }
939
940    #[test]
941    fn rejects_non_array_exclude() {
942        let src = MINIMAL.replace(
943            r#"triggers = ["process_writes"]"#,
944            "triggers = [\"process_writes\"]\nexclude = \"tests\"",
945        );
946        let errs = Manifest::parse_toml(&src).unwrap_err();
947        assert_matches!(errs.errors()[0].error, SchemaError::TomlParse { .. });
948    }
949
950    #[test]
951    fn rejects_non_string_exclude_item() {
952        let src = MINIMAL.replace(
953            r#"triggers = ["process_writes"]"#,
954            "triggers = [\"process_writes\"]\nexclude = [1, 2]",
955        );
956        let errs = Manifest::parse_toml(&src).unwrap_err();
957        assert_matches!(errs.errors()[0].error, SchemaError::TomlParse { .. });
958    }
959
960    /// A triple-quoted TOML string with embedded newlines must be rejected
961    /// for `plugin.description`. (TOML strips the leading newline immediately
962    /// after `"""`, so the rejection here fires on the inner `\n`s.)
963    #[test]
964    fn rejects_description_with_embedded_newline_in_toml() {
965        let input = r#"
966manifest_schema_version = "1.0"
967
968[plugin]
969name = "downsampler"
970version = "1.2.0"
971description = """
972line one
973line two
974"""
975triggers = ["process_writes"]
976
977[dependencies]
978database_version = ">=3.0.0"
979"#;
980        let errors = Manifest::parse_toml(input).expect_err("multiline description must fail");
981        assert_eq!(errors.errors().len(), 1);
982        let e = &errors.errors()[0];
983        assert_eq!(e.path.as_str(), "plugin.description");
984        assert_matches!(e.error, SchemaError::DescriptionMultiline { .. });
985    }
986}
987
988#[cfg(test)]
989mod validation_tests {
990    use super::*;
991    use assert_matches::assert_matches;
992    use rstest::rstest;
993
994    fn with_fragment(key: &str, value: &str) -> String {
995        format!(
996            r#"
997manifest_schema_version = "1.0"
998
999[plugin]
1000name = "x"
1001version = "1.0.0"
1002description = "x"
1003triggers = ["process_writes"]
1004{key} = {value}
1005
1006[dependencies]
1007database_version = ">=3.0.0"
1008"#
1009        )
1010    }
1011
1012    #[rstest]
1013    #[case("homepage", r#""ftp://bad/""#)]
1014    #[case("homepage", r#""file:///local""#)]
1015    #[case("repository", r#""git://bad""#)]
1016    #[case("documentation", r#""s3://bucket""#)]
1017    fn rejects_non_http_urls(#[case] field: &str, #[case] value: &str) {
1018        let manifest = with_fragment(field, value);
1019        let errors = Manifest::parse_toml(&manifest).unwrap_err();
1020        assert_eq!(errors.errors().len(), 1);
1021        assert_matches!(
1022            errors.errors()[0].error,
1023            SchemaError::InvalidUrlScheme { .. }
1024        );
1025        assert_eq!(errors.errors()[0].path.as_str(), &format!("plugin.{field}"));
1026    }
1027
1028    #[rstest]
1029    #[case("homepage", r#""http://example.com""#)]
1030    #[case("homepage", r#""https://example.com""#)]
1031    #[case("repository", r#""https://github.com/foo/bar""#)]
1032    #[case("documentation", r#""http://docs.example.com/plugin""#)]
1033    fn accepts_http_and_https_urls(#[case] field: &str, #[case] value: &str) {
1034        let manifest = with_fragment(field, value);
1035        Manifest::parse_toml(&manifest)
1036            .unwrap_or_else(|e| panic!("expected {field}={value} to parse, got {e}"));
1037    }
1038
1039    #[test]
1040    fn rejects_empty_triggers() {
1041        let input = r#"
1042manifest_schema_version = "1.0"
1043
1044[plugin]
1045name = "x"
1046version = "1.0.0"
1047description = "x"
1048triggers = []
1049
1050[dependencies]
1051database_version = ">=3.0.0"
1052"#;
1053        let errors = Manifest::parse_toml(input).unwrap_err();
1054        assert_eq!(errors.errors().len(), 1);
1055        assert_matches!(errors.errors()[0].error, SchemaError::EmptyTriggers);
1056        assert_eq!(errors.errors()[0].path.as_str(), "plugin.triggers");
1057    }
1058
1059    /// Invalid `dependencies.database_version` surfaces as
1060    /// `InvalidDatabaseVersion` with the `dependencies.database_version`
1061    /// path, not flattened through `serde::Error::custom`.
1062    #[test]
1063    fn rejects_invalid_database_version() {
1064        let input = r#"
1065manifest_schema_version = "1.0"
1066
1067[plugin]
1068name = "x"
1069version = "1.0.0"
1070description = "x"
1071triggers = ["process_writes"]
1072
1073[dependencies]
1074database_version = ">=not-a-version"
1075"#;
1076        let errors = Manifest::parse_toml(input).unwrap_err();
1077        assert_eq!(errors.errors().len(), 1);
1078        assert_matches!(
1079            errors.errors()[0].error,
1080            SchemaError::InvalidDatabaseVersion { .. }
1081        );
1082        assert_eq!(
1083            errors.errors()[0].path.as_str(),
1084            "dependencies.database_version"
1085        );
1086    }
1087}