1use crate::{PluginName, SchemaError};
4use std::fmt;
5use std::str::FromStr;
6
7pub(crate) const SUPPORTED_MANIFEST_MAJOR: u32 = 1;
10
11#[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#[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 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#[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#[derive(Debug, Clone, PartialEq, Eq)]
193pub struct PythonRequirement(String);
194
195impl PythonRequirement {
196 pub fn try_new(s: &str) -> Result<Self, SchemaError> {
197 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#[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 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 let raw: RawManifest = toml::from_str(input)
280 .map_err(|source| SchemaErrors::single_at_root(SchemaError::TomlParse { source }))?;
281
282 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 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 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 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 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
402pub(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#[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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
464 pub exclude: Vec<String>,
465}
466
467#[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 #[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 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 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 #[test]
820 fn collects_multiple_defects_in_one_pass() {
821 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 #[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 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 #[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 #[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}