spin_manifest/schema/
v2.rs

1use anyhow::Context;
2use schemars::JsonSchema;
3use serde::{Deserialize, Serialize};
4use spin_serde::{DependencyName, DependencyPackageName, FixedVersion, LowerSnakeId};
5pub use spin_serde::{KebabId, SnakeId};
6use std::path::PathBuf;
7
8pub use super::common::{ComponentBuildConfig, ComponentSource, Variable, WasiFilesMount};
9use super::json_schema;
10
11pub(crate) type Map<K, V> = indexmap::IndexMap<K, V>;
12
13/// App manifest
14#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
15#[serde(deny_unknown_fields)]
16pub struct AppManifest {
17    /// `spin_manifest_version = 2`
18    #[schemars(with = "usize", range = (min = 2, max = 2))]
19    pub spin_manifest_version: FixedVersion<2>,
20    /// `[application]`
21    pub application: AppDetails,
22    /// `[variables]`
23    #[serde(default, skip_serializing_if = "Map::is_empty")]
24    pub variables: Map<LowerSnakeId, Variable>,
25    /// `[[trigger.<type>]]`
26    #[serde(rename = "trigger")]
27    #[schemars(with = "json_schema::TriggerSchema")]
28    pub triggers: Map<String, Vec<Trigger>>,
29    /// `[component.<id>]`
30    #[serde(rename = "component")]
31    #[serde(default, skip_serializing_if = "Map::is_empty")]
32    pub components: Map<KebabId, Component>,
33}
34
35impl AppManifest {
36    /// This method ensures that the dependencies of each component are valid.
37    pub fn validate_dependencies(&self) -> anyhow::Result<()> {
38        for (component_id, component) in &self.components {
39            component
40                .dependencies
41                .validate()
42                .with_context(|| format!("component {component_id:?} has invalid dependencies"))?;
43        }
44        Ok(())
45    }
46}
47
48/// App details
49#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
50#[serde(deny_unknown_fields)]
51pub struct AppDetails {
52    /// `name = "my-app"`
53    pub name: String,
54    /// `version = "1.0.0"`
55    #[serde(default, skip_serializing_if = "String::is_empty")]
56    pub version: String,
57    /// `description = "App description"`
58    #[serde(default, skip_serializing_if = "String::is_empty")]
59    pub description: String,
60    /// `authors = ["author@example.com"]`
61    #[serde(default, skip_serializing_if = "Vec::is_empty")]
62    pub authors: Vec<String>,
63    /// `[application.triggers.<type>]`
64    #[serde(rename = "trigger", default, skip_serializing_if = "Map::is_empty")]
65    #[schemars(schema_with = "json_schema::map_of_toml_tables")]
66    pub trigger_global_configs: Map<String, toml::Table>,
67    /// Settings for custom tools or plugins. Spin ignores this field.
68    #[serde(default, skip_serializing_if = "Map::is_empty")]
69    #[schemars(schema_with = "json_schema::map_of_toml_tables")]
70    pub tool: Map<String, toml::Table>,
71}
72
73/// Trigger configuration
74#[derive(Clone, Debug, Serialize, Deserialize)]
75pub struct Trigger {
76    /// `id = "trigger-id"`
77    #[serde(default, skip_serializing_if = "String::is_empty")]
78    pub id: String,
79    /// `component = ...`
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub component: Option<ComponentSpec>,
82    /// `components = { ... }`
83    #[serde(default, skip_serializing_if = "Map::is_empty")]
84    pub components: Map<String, OneOrManyComponentSpecs>,
85    /// Opaque trigger-type-specific config
86    #[serde(flatten)]
87    pub config: toml::Table,
88}
89
90/// One or many `ComponentSpec`(s)
91#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
92#[serde(transparent)]
93pub struct OneOrManyComponentSpecs(
94    #[serde(with = "one_or_many")]
95    #[schemars(schema_with = "json_schema::one_or_many::<ComponentSpec>")]
96    pub Vec<ComponentSpec>,
97);
98
99/// Component reference or inline definition
100#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
101#[serde(deny_unknown_fields, untagged, try_from = "toml::Value")]
102pub enum ComponentSpec {
103    /// `"component-id"`
104    Reference(KebabId),
105    /// `{ ... }`
106    Inline(Box<Component>),
107}
108
109impl TryFrom<toml::Value> for ComponentSpec {
110    type Error = toml::de::Error;
111
112    fn try_from(value: toml::Value) -> Result<Self, Self::Error> {
113        if value.is_str() {
114            Ok(ComponentSpec::Reference(KebabId::deserialize(value)?))
115        } else {
116            Ok(ComponentSpec::Inline(Box::new(Component::deserialize(
117                value,
118            )?)))
119        }
120    }
121}
122
123/// Component dependency
124#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
125#[serde(untagged, deny_unknown_fields)]
126pub enum ComponentDependency {
127    /// `... = ">= 0.1.0"`
128    Version(String),
129    /// `... = { version = "0.1.0", registry = "registry.io", ...}`
130    Package {
131        /// Package version requirement
132        version: String,
133        /// Optional registry spec
134        registry: Option<String>,
135        /// Optional package name `foo:bar`. If not specified, the package name
136        /// is inferred from the DependencyName key.
137        package: Option<String>,
138        /// Optional export name
139        export: Option<String>,
140    },
141    /// `... = { path = "path/to/component.wasm", export = "my-export" }`
142    Local {
143        /// Path to Wasm
144        path: PathBuf,
145        /// Optional export name
146        export: Option<String>,
147    },
148    /// `... = { url = "https://example.com/component.wasm", sha256 = "..." }`
149    HTTP {
150        /// URL to Wasm
151        url: String,
152        /// SHA256 Checksum of the component. The string should start with 'sha256:'
153        digest: String,
154        /// Optional export name
155        export: Option<String>,
156    },
157}
158
159/// Component definition
160#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
161#[serde(deny_unknown_fields)]
162pub struct Component {
163    /// `source = ...`
164    pub source: ComponentSource,
165    /// `description = "Component description"`
166    #[serde(default, skip_serializing_if = "String::is_empty")]
167    pub description: String,
168    /// `variables = { name = "{{ app_var }}"}`
169    #[serde(default, skip_serializing_if = "Map::is_empty")]
170    pub variables: Map<LowerSnakeId, String>,
171    /// `environment = { VAR = "value" }`
172    #[serde(default, skip_serializing_if = "Map::is_empty")]
173    pub environment: Map<String, String>,
174    /// `files = [...]`
175    #[serde(default, skip_serializing_if = "Vec::is_empty")]
176    pub files: Vec<WasiFilesMount>,
177    /// `exclude_files = ["secrets/*"]`
178    #[serde(default, skip_serializing_if = "Vec::is_empty")]
179    pub exclude_files: Vec<String>,
180    /// `allowed_http_hosts = ["example.com"]`
181    #[serde(default, skip_serializing_if = "Vec::is_empty")]
182    #[deprecated]
183    pub allowed_http_hosts: Vec<String>,
184    /// `allowed_outbound_hosts = ["redis://myredishost.com:6379"]`
185    #[serde(default, skip_serializing_if = "Vec::is_empty")]
186    pub allowed_outbound_hosts: Vec<String>,
187    /// `key_value_stores = ["default", "my-store"]`
188    #[serde(
189        default,
190        with = "kebab_or_snake_case",
191        skip_serializing_if = "Vec::is_empty"
192    )]
193    #[schemars(with = "Vec<String>")]
194    pub key_value_stores: Vec<String>,
195    /// `sqlite_databases = ["default", "my-database"]`
196    #[serde(
197        default,
198        with = "kebab_or_snake_case",
199        skip_serializing_if = "Vec::is_empty"
200    )]
201    #[schemars(with = "Vec<String>")]
202    pub sqlite_databases: Vec<String>,
203    /// `ai_models = ["llama2-chat"]`
204    #[serde(default, skip_serializing_if = "Vec::is_empty")]
205    pub ai_models: Vec<KebabId>,
206    /// Build configuration
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub build: Option<ComponentBuildConfig>,
209    /// Settings for custom tools or plugins. Spin ignores this field.
210    #[serde(default, skip_serializing_if = "Map::is_empty")]
211    #[schemars(schema_with = "json_schema::map_of_toml_tables")]
212    pub tool: Map<String, toml::Table>,
213    /// If true, allow dependencies to inherit configuration.
214    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
215    pub dependencies_inherit_configuration: bool,
216    /// Component dependencies
217    #[serde(default, skip_serializing_if = "ComponentDependencies::is_empty")]
218    pub dependencies: ComponentDependencies,
219}
220
221/// Component dependencies
222#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
223#[serde(transparent)]
224pub struct ComponentDependencies {
225    /// `dependencies = { "foo:bar" = ">= 0.1.0" }`
226    pub inner: Map<DependencyName, ComponentDependency>,
227}
228
229impl ComponentDependencies {
230    /// This method validates the correct specification of dependencies in a
231    /// component section of the manifest. See the documentation on the methods
232    /// called for more information on the specific checks.
233    fn validate(&self) -> anyhow::Result<()> {
234        self.ensure_plain_names_have_package()?;
235        self.ensure_package_names_no_export()?;
236        self.ensure_disjoint()?;
237        Ok(())
238    }
239
240    /// This method ensures that all dependency names in plain form (e.g.
241    /// "foo-bar") do not map to a `ComponentDependency::Version`, or a
242    /// `ComponentDependency::Package` where the `package` is `None`.
243    fn ensure_plain_names_have_package(&self) -> anyhow::Result<()> {
244        for (dependency_name, dependency) in self.inner.iter() {
245            let DependencyName::Plain(plain) = dependency_name else {
246                continue;
247            };
248            match dependency {
249                ComponentDependency::Package { package, .. } if package.is_none() => {}
250                ComponentDependency::Version(_) => {}
251                _ => continue,
252            }
253            anyhow::bail!("dependency {plain:?} must specify a package name");
254        }
255        Ok(())
256    }
257
258    /// This method ensures that dependency names in the package form (e.g.
259    /// "foo:bar" or "foo:bar@0.1.0") do not map to specific exported
260    /// interfaces, e.g. `"foo:bar = { ..., export = "my-export" }"` is invalid.
261    fn ensure_package_names_no_export(&self) -> anyhow::Result<()> {
262        for (dependency_name, dependency) in self.inner.iter() {
263            if let DependencyName::Package(name) = dependency_name {
264                if name.interface.is_none() {
265                    let export = match dependency {
266                        ComponentDependency::Package { export, .. } => export,
267                        ComponentDependency::Local { export, .. } => export,
268                        _ => continue,
269                    };
270
271                    anyhow::ensure!(
272                        export.is_none(),
273                        "using an export to satisfy the package dependency {dependency_name:?} is not currently permitted",
274                    );
275                }
276            }
277        }
278        Ok(())
279    }
280
281    /// This method ensures that dependencies names do not conflict with each other. That is to say
282    /// that two dependencies of the same package must have disjoint versions or interfaces.
283    fn ensure_disjoint(&self) -> anyhow::Result<()> {
284        for (idx, this) in self.inner.keys().enumerate() {
285            for other in self.inner.keys().skip(idx + 1) {
286                let DependencyName::Package(other) = other else {
287                    continue;
288                };
289                let DependencyName::Package(this) = this else {
290                    continue;
291                };
292
293                if this.package == other.package {
294                    Self::check_disjoint(this, other)?;
295                }
296            }
297        }
298        Ok(())
299    }
300
301    fn check_disjoint(
302        this: &DependencyPackageName,
303        other: &DependencyPackageName,
304    ) -> anyhow::Result<()> {
305        assert_eq!(this.package, other.package);
306
307        if let (Some(this_ver), Some(other_ver)) = (this.version.clone(), other.version.clone()) {
308            if Self::normalize_compatible_version(this_ver)
309                != Self::normalize_compatible_version(other_ver)
310            {
311                return Ok(());
312            }
313        }
314
315        if let (Some(this_itf), Some(other_itf)) =
316            (this.interface.as_ref(), other.interface.as_ref())
317        {
318            if this_itf != other_itf {
319                return Ok(());
320            }
321        }
322
323        anyhow::bail!("{this:?} dependency conflicts with {other:?}")
324    }
325
326    /// Normalize version to perform a compatibility check against another version.
327    ///
328    /// See backwards comptabilitiy rules at https://semver.org/
329    fn normalize_compatible_version(mut version: semver::Version) -> semver::Version {
330        version.build = semver::BuildMetadata::EMPTY;
331
332        if version.pre != semver::Prerelease::EMPTY {
333            return version;
334        }
335        if version.major > 0 {
336            version.minor = 0;
337            version.patch = 0;
338            return version;
339        }
340
341        if version.minor > 0 {
342            version.patch = 0;
343            return version;
344        }
345
346        version
347    }
348
349    fn is_empty(&self) -> bool {
350        self.inner.is_empty()
351    }
352}
353
354mod kebab_or_snake_case {
355    use serde::{Deserialize, Serialize};
356    pub use spin_serde::{KebabId, SnakeId};
357    pub fn serialize<S>(value: &[String], serializer: S) -> Result<S::Ok, S::Error>
358    where
359        S: serde::ser::Serializer,
360    {
361        if value.iter().all(|s| {
362            KebabId::try_from(s.clone()).is_ok() || SnakeId::try_from(s.to_owned()).is_ok()
363        }) {
364            value.serialize(serializer)
365        } else {
366            Err(serde::ser::Error::custom(
367                "expected kebab-case or snake_case",
368            ))
369        }
370    }
371
372    pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
373    where
374        D: serde::Deserializer<'de>,
375    {
376        let value = toml::Value::deserialize(deserializer)?;
377        let list: Vec<String> = Vec::deserialize(value).map_err(serde::de::Error::custom)?;
378        if list.iter().all(|s| {
379            KebabId::try_from(s.clone()).is_ok() || SnakeId::try_from(s.to_owned()).is_ok()
380        }) {
381            Ok(list)
382        } else {
383            Err(serde::de::Error::custom(
384                "expected kebab-case or snake_case",
385            ))
386        }
387    }
388}
389
390impl Component {
391    /// Combine `allowed_outbound_hosts` with the deprecated `allowed_http_hosts` into
392    /// one array all normalized to the syntax of `allowed_outbound_hosts`.
393    pub fn normalized_allowed_outbound_hosts(&self) -> anyhow::Result<Vec<String>> {
394        #[allow(deprecated)]
395        let normalized =
396            crate::compat::convert_allowed_http_to_allowed_hosts(&self.allowed_http_hosts, false)?;
397        if !normalized.is_empty() {
398            terminal::warn!(
399                "Use of the deprecated field `allowed_http_hosts` - to fix, \
400            replace `allowed_http_hosts` with `allowed_outbound_hosts = {normalized:?}`",
401            )
402        }
403
404        Ok(self
405            .allowed_outbound_hosts
406            .iter()
407            .cloned()
408            .chain(normalized)
409            .collect())
410    }
411}
412
413mod one_or_many {
414    use serde::{Deserialize, Deserializer, Serialize, Serializer};
415
416    pub fn serialize<T, S>(vec: &Vec<T>, serializer: S) -> Result<S::Ok, S::Error>
417    where
418        T: Serialize,
419        S: Serializer,
420    {
421        if vec.len() == 1 {
422            vec[0].serialize(serializer)
423        } else {
424            vec.serialize(serializer)
425        }
426    }
427
428    pub fn deserialize<'de, T, D>(deserializer: D) -> Result<Vec<T>, D::Error>
429    where
430        T: Deserialize<'de>,
431        D: Deserializer<'de>,
432    {
433        let value = toml::Value::deserialize(deserializer)?;
434        if let Ok(val) = T::deserialize(value.clone()) {
435            Ok(vec![val])
436        } else {
437            Vec::deserialize(value).map_err(serde::de::Error::custom)
438        }
439    }
440}
441
442#[cfg(test)]
443mod tests {
444    use toml::toml;
445
446    use super::*;
447
448    #[derive(Deserialize)]
449    #[allow(dead_code)]
450    struct FakeGlobalTriggerConfig {
451        global_option: bool,
452    }
453
454    #[derive(Deserialize)]
455    #[allow(dead_code)]
456    struct FakeTriggerConfig {
457        option: Option<bool>,
458    }
459
460    #[test]
461    fn deserializing_trigger_configs() {
462        let manifest = AppManifest::deserialize(toml! {
463            spin_manifest_version = 2
464            [application]
465            name = "trigger-configs"
466            [application.trigger.fake]
467            global_option = true
468            [[trigger.fake]]
469            component = { source = "inline.wasm" }
470            option = true
471        })
472        .unwrap();
473
474        FakeGlobalTriggerConfig::deserialize(
475            manifest.application.trigger_global_configs["fake"].clone(),
476        )
477        .unwrap();
478
479        FakeTriggerConfig::deserialize(manifest.triggers["fake"][0].config.clone()).unwrap();
480    }
481
482    #[derive(Deserialize)]
483    #[allow(dead_code)]
484    struct FakeGlobalToolConfig {
485        lint_level: String,
486    }
487
488    #[derive(Deserialize)]
489    #[allow(dead_code)]
490    struct FakeComponentToolConfig {
491        command: String,
492    }
493
494    #[test]
495    fn deserialising_custom_tool_settings() {
496        let manifest = AppManifest::deserialize(toml! {
497            spin_manifest_version = 2
498            [application]
499            name = "trigger-configs"
500            [application.tool.lint]
501            lint_level = "savage"
502            [[trigger.fake]]
503            something = "something else"
504            [component.fake]
505            source = "dummy"
506            [component.fake.tool.clean]
507            command = "cargo clean"
508        })
509        .unwrap();
510
511        FakeGlobalToolConfig::deserialize(manifest.application.tool["lint"].clone()).unwrap();
512        let fake_id: KebabId = "fake".to_owned().try_into().unwrap();
513        FakeComponentToolConfig::deserialize(manifest.components[&fake_id].tool["clean"].clone())
514            .unwrap();
515    }
516
517    #[test]
518    fn deserializing_labels() {
519        AppManifest::deserialize(toml! {
520            spin_manifest_version = 2
521            [application]
522            name = "trigger-configs"
523            [[trigger.fake]]
524            something = "something else"
525            [component.fake]
526            source = "dummy"
527            key_value_stores = ["default", "snake_case", "kebab-case"]
528            sqlite_databases = ["default", "snake_case", "kebab-case"]
529        })
530        .unwrap();
531    }
532
533    #[test]
534    fn deserializing_labels_fails_for_non_kebab_or_snake() {
535        assert!(AppManifest::deserialize(toml! {
536            spin_manifest_version = 2
537            [application]
538            name = "trigger-configs"
539            [[trigger.fake]]
540            something = "something else"
541            [component.fake]
542            source = "dummy"
543            key_value_stores = ["b@dlabel"]
544        })
545        .is_err());
546    }
547
548    fn get_test_component_with_labels(labels: Vec<String>) -> Component {
549        #[allow(deprecated)]
550        Component {
551            source: ComponentSource::Local("dummy".to_string()),
552            description: "".to_string(),
553            variables: Map::new(),
554            environment: Map::new(),
555            files: vec![],
556            exclude_files: vec![],
557            allowed_http_hosts: vec![],
558            allowed_outbound_hosts: vec![],
559            key_value_stores: labels.clone(),
560            sqlite_databases: labels,
561            ai_models: vec![],
562            build: None,
563            tool: Map::new(),
564            dependencies_inherit_configuration: false,
565            dependencies: Default::default(),
566        }
567    }
568
569    #[test]
570    fn serialize_labels() {
571        let stores = vec![
572            "default".to_string(),
573            "snake_case".to_string(),
574            "kebab-case".to_string(),
575        ];
576        let component = get_test_component_with_labels(stores.clone());
577        let serialized = toml::to_string(&component).unwrap();
578        let deserialized = toml::from_str::<Component>(&serialized).unwrap();
579        assert_eq!(deserialized.key_value_stores, stores);
580    }
581
582    #[test]
583    fn serialize_labels_fails_for_non_kebab_or_snake() {
584        let component = get_test_component_with_labels(vec!["camelCase".to_string()]);
585        assert!(toml::to_string(&component).is_err());
586    }
587
588    #[test]
589    fn test_valid_snake_ids() {
590        for valid in ["default", "mixed_CASE_words", "letters1_then2_numbers345"] {
591            if let Err(err) = SnakeId::try_from(valid.to_string()) {
592                panic!("{valid:?} should be value: {err:?}");
593            }
594        }
595    }
596
597    #[test]
598    fn test_invalid_snake_ids() {
599        for invalid in [
600            "",
601            "kebab-case",
602            "_leading_underscore",
603            "trailing_underscore_",
604            "double__underscore",
605            "1initial_number",
606            "unicode_snowpeople☃☃☃",
607            "mIxEd_case",
608            "MiXeD_case",
609        ] {
610            if SnakeId::try_from(invalid.to_string()).is_ok() {
611                panic!("{invalid:?} should not be a valid SnakeId");
612            }
613        }
614    }
615
616    #[test]
617    fn test_check_disjoint() {
618        for (a, b) in [
619            ("foo:bar@0.1.0", "foo:bar@0.2.0"),
620            ("foo:bar/baz@0.1.0", "foo:bar/baz@0.2.0"),
621            ("foo:bar/baz@0.1.0", "foo:bar/bub@0.1.0"),
622            ("foo:bar@0.1.0", "foo:bar/bub@0.2.0"),
623            ("foo:bar@1.0.0", "foo:bar@2.0.0"),
624            ("foo:bar@0.1.0", "foo:bar@1.0.0"),
625            ("foo:bar/baz", "foo:bar/bub"),
626            ("foo:bar/baz@0.1.0-alpha", "foo:bar/baz@0.1.0-beta"),
627        ] {
628            let a: DependencyPackageName = a.parse().expect(a);
629            let b: DependencyPackageName = b.parse().expect(b);
630            ComponentDependencies::check_disjoint(&a, &b).unwrap();
631        }
632
633        for (a, b) in [
634            ("foo:bar@0.1.0", "foo:bar@0.1.1"),
635            ("foo:bar/baz@0.1.0", "foo:bar@0.1.0"),
636            ("foo:bar/baz@0.1.0", "foo:bar@0.1.0"),
637            ("foo:bar", "foo:bar@0.1.0"),
638            ("foo:bar@0.1.0-pre", "foo:bar@0.1.0-pre"),
639        ] {
640            let a: DependencyPackageName = a.parse().expect(a);
641            let b: DependencyPackageName = b.parse().expect(b);
642            assert!(
643                ComponentDependencies::check_disjoint(&a, &b).is_err(),
644                "{a} should conflict with {b}",
645            );
646        }
647    }
648
649    #[test]
650    fn test_validate_dependencies() {
651        // Specifying a dependency name as a plain-name without a package is an error
652        assert!(ComponentDependencies::deserialize(toml! {
653            "plain-name" = "0.1.0"
654        })
655        .unwrap()
656        .validate()
657        .is_err());
658
659        // Specifying a dependency name as a plain-name without a package is an error
660        assert!(ComponentDependencies::deserialize(toml! {
661            "plain-name" = { version = "0.1.0" }
662        })
663        .unwrap()
664        .validate()
665        .is_err());
666
667        // Specifying an export to satisfy a package dependency name is an error
668        assert!(ComponentDependencies::deserialize(toml! {
669            "foo:baz@0.1.0" = { path = "foo.wasm", export = "foo"}
670        })
671        .unwrap()
672        .validate()
673        .is_err());
674
675        // Two compatible versions of the same package is an error
676        assert!(ComponentDependencies::deserialize(toml! {
677            "foo:baz@0.1.0" = "0.1.0"
678            "foo:bar@0.2.1" = "0.2.1"
679            "foo:bar@0.2.2" = "0.2.2"
680        })
681        .unwrap()
682        .validate()
683        .is_err());
684
685        // Two disjoint versions of the same package is ok
686        assert!(ComponentDependencies::deserialize(toml! {
687            "foo:bar@0.1.0" = "0.1.0"
688            "foo:bar@0.2.0" = "0.2.0"
689            "foo:baz@0.2.0" = "0.1.0"
690        })
691        .unwrap()
692        .validate()
693        .is_ok());
694
695        // Unversioned and versioned dependencies of the same package is an error
696        assert!(ComponentDependencies::deserialize(toml! {
697            "foo:bar@0.1.0" = "0.1.0"
698            "foo:bar" = ">= 0.2.0"
699        })
700        .unwrap()
701        .validate()
702        .is_err());
703
704        // Two interfaces of two disjoint versions of a package is ok
705        assert!(ComponentDependencies::deserialize(toml! {
706            "foo:bar/baz@0.1.0" = "0.1.0"
707            "foo:bar/baz@0.2.0" = "0.2.0"
708        })
709        .unwrap()
710        .validate()
711        .is_ok());
712
713        // A versioned interface and a different versioned package is ok
714        assert!(ComponentDependencies::deserialize(toml! {
715            "foo:bar/baz@0.1.0" = "0.1.0"
716            "foo:bar@0.2.0" = "0.2.0"
717        })
718        .unwrap()
719        .validate()
720        .is_ok());
721
722        // A versioned interface and package of the same version is an error
723        assert!(ComponentDependencies::deserialize(toml! {
724            "foo:bar/baz@0.1.0" = "0.1.0"
725            "foo:bar@0.1.0" = "0.1.0"
726        })
727        .unwrap()
728        .validate()
729        .is_err());
730
731        // A versioned interface and unversioned package is an error
732        assert!(ComponentDependencies::deserialize(toml! {
733            "foo:bar/baz@0.1.0" = "0.1.0"
734            "foo:bar" = "0.1.0"
735        })
736        .unwrap()
737        .validate()
738        .is_err());
739
740        // An unversioned interface and versioned package is an error
741        assert!(ComponentDependencies::deserialize(toml! {
742            "foo:bar/baz" = "0.1.0"
743            "foo:bar@0.1.0" = "0.1.0"
744        })
745        .unwrap()
746        .validate()
747        .is_err());
748
749        // An unversioned interface and unversioned package is an error
750        assert!(ComponentDependencies::deserialize(toml! {
751            "foo:bar/baz" = "0.1.0"
752            "foo:bar" = "0.1.0"
753        })
754        .unwrap()
755        .validate()
756        .is_err());
757    }
758}