1use crate::{IndexUrl, 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: 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#[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)]
237pub struct PluginDependency {
238 pub index_url: IndexUrl,
239 pub name: crate::PluginName,
240 pub version: semver::VersionReq,
241}
242
243#[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 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 let raw: RawManifest = toml::from_str(input)
290 .map_err(|source| SchemaErrors::single_at_root(SchemaError::TomlParse { source }))?;
291
292 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 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 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 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 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
416pub(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
455pub(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#[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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
562 pub exclude: Vec<String>,
563}
564
565#[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 #[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 #[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 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 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 #[test]
924 fn collects_multiple_defects_in_one_pass() {
925 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 #[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 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 #[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 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 #[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 #[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 #[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 #[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 #[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}