nimbus_fml/
frontend.rs

1/* This Source Code Form is subject to the terms of the Mozilla Public
2* License, v. 2.0. If a copy of the MPL was not distributed with this
3* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5use std::collections::{BTreeMap, HashMap, HashSet};
6
7use serde::{Deserialize, Serialize};
8use serde_json::json;
9use url::Url;
10
11use crate::{
12    defaults::DefaultsMerger,
13    error::Result,
14    intermediate_representation::{
15        EnumDef, FeatureDef, FeatureManifest, GeckoPrefDef, ModuleId, ObjectDef, PropDef,
16        TargetLanguage, TypeRef, VariantDef,
17    },
18    parser::get_typeref_from_string,
19};
20
21#[derive(Debug, Deserialize, Serialize, Clone)]
22#[serde(deny_unknown_fields)]
23pub(crate) struct EnumVariantBody {
24    pub(crate) description: String,
25}
26
27#[derive(Debug, Deserialize, Serialize, Clone)]
28#[serde(deny_unknown_fields)]
29pub(crate) struct EnumBody {
30    pub(crate) description: String,
31    pub(crate) variants: BTreeMap<String, EnumVariantBody>,
32}
33
34#[derive(Debug, Deserialize, Serialize, Clone)]
35#[serde(deny_unknown_fields)]
36#[serde(rename_all = "kebab-case")]
37pub(crate) struct FeatureFieldBody {
38    #[serde(flatten)]
39    pub(crate) field: FieldBody,
40
41    #[serde(default)]
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub(crate) pref_key: Option<String>,
44
45    #[serde(default)]
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub(crate) gecko_pref: Option<GeckoPrefDef>,
48
49    #[serde(default)]
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub(crate) string_alias: Option<String>,
52}
53
54#[derive(Debug, Deserialize, Serialize, Clone)]
55#[serde(deny_unknown_fields)]
56pub(crate) struct FieldBody {
57    pub(crate) description: String,
58    #[serde(rename = "type")]
59    pub(crate) variable_type: String,
60    pub(crate) default: Option<serde_json::Value>,
61}
62
63#[derive(Debug, Deserialize, Serialize, Clone)]
64#[serde(deny_unknown_fields)]
65pub(crate) struct ObjectBody {
66    pub(crate) description: String,
67    // We need these in a deterministic order, so they are stable across multiple
68    // runs of the same manifests.
69    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
70    pub(crate) fields: BTreeMap<String, FieldBody>,
71}
72
73#[derive(Debug, Deserialize, Serialize, Clone, Default)]
74#[serde(deny_unknown_fields)]
75pub(crate) struct Types {
76    #[serde(default)]
77    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
78    pub(crate) enums: BTreeMap<String, EnumBody>,
79    #[serde(default)]
80    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
81    pub(crate) objects: BTreeMap<String, ObjectBody>,
82}
83
84#[derive(Debug, Deserialize, Serialize, Clone, Default, PartialEq, Eq)]
85#[serde(deny_unknown_fields)]
86pub(crate) struct AboutBlock {
87    pub(crate) description: String,
88    #[serde(skip_serializing_if = "Option::is_none")]
89    #[serde(alias = "kotlin", alias = "android")]
90    pub(crate) kotlin_about: Option<KotlinAboutBlock>,
91    #[serde(skip_serializing_if = "Option::is_none")]
92    #[serde(alias = "swift", alias = "ios")]
93    pub(crate) swift_about: Option<SwiftAboutBlock>,
94}
95
96impl AboutBlock {
97    pub(crate) fn is_includable(&self) -> bool {
98        self.kotlin_about.is_none() && self.swift_about.is_none()
99    }
100
101    #[allow(unused)]
102    pub(crate) fn supports(&self, lang: &TargetLanguage) -> bool {
103        match lang {
104            TargetLanguage::Kotlin => self.kotlin_about.is_some(),
105            TargetLanguage::Swift => self.swift_about.is_some(),
106            TargetLanguage::IR => true,
107            TargetLanguage::ExperimenterYAML => true,
108            TargetLanguage::ExperimenterJSON => true,
109        }
110    }
111
112    #[allow(unused)]
113    pub fn description_only(&self) -> Self {
114        AboutBlock {
115            description: self.description.clone(),
116            kotlin_about: None,
117            swift_about: None,
118        }
119    }
120}
121
122#[derive(Debug, Deserialize, Serialize, Clone, Default, PartialEq, Eq)]
123pub(crate) struct SwiftAboutBlock {
124    pub(crate) module: String,
125    pub(crate) class: String,
126}
127
128#[derive(Debug, Deserialize, Serialize, Clone, Default, PartialEq, Eq)]
129pub(crate) struct KotlinAboutBlock {
130    pub(crate) package: String,
131    pub(crate) class: String,
132}
133
134#[derive(Debug, Deserialize, Serialize, Clone, Default)]
135pub(crate) struct ImportBlock {
136    pub(crate) path: String,
137    pub(crate) channel: String,
138    #[serde(default)]
139    pub(crate) features: BTreeMap<String, FeatureAdditionChoices>,
140}
141
142#[derive(Debug, Deserialize, Serialize, Clone, Default)]
143pub(crate) struct FeatureAdditions {
144    #[serde(default)]
145    pub(crate) defaults: Vec<DefaultBlock>,
146    #[serde(default)]
147    pub(crate) examples: Vec<ExampleBlock>,
148}
149
150#[derive(Debug, Deserialize, Serialize, Clone)]
151#[serde(untagged)]
152pub(crate) enum FeatureAdditionChoices {
153    AllDefaults(Vec<DefaultOrExampleBlock>),
154    FeatureAdditions(FeatureAdditions),
155}
156
157#[derive(Debug, Deserialize, Serialize, Clone)]
158#[serde(untagged)]
159pub(crate) enum DefaultOrExampleBlock {
160    Default(DefaultBlock),
161    LabelledDefault(LabelledDefaultBlock),
162    LabelledExample(LabelledExampleBlock),
163}
164
165#[derive(Debug, Deserialize, Serialize, Clone)]
166pub(crate) struct LabelledExampleBlock {
167    pub(crate) example: ExampleBlock,
168}
169
170#[derive(Debug, Deserialize, Serialize, Clone)]
171pub(crate) struct LabelledDefaultBlock {
172    #[serde(alias = "defaults")]
173    pub(crate) default: DefaultBlock,
174}
175
176// Rationalize all the different ways we can express imported
177// defaults and examples for a feature into a single way that the parser
178// can use to merge.
179impl From<FeatureAdditionChoices> for FeatureAdditions {
180    fn from(choices: FeatureAdditionChoices) -> Self {
181        match choices {
182            FeatureAdditionChoices::FeatureAdditions(a) => a,
183            FeatureAdditionChoices::AllDefaults(list) => {
184                let mut examples = Vec::new();
185                let mut defaults = Vec::new();
186                for addition in list {
187                    match addition {
188                        DefaultOrExampleBlock::Default(default)
189                        | DefaultOrExampleBlock::LabelledDefault(LabelledDefaultBlock {
190                            default,
191                        }) => defaults.push(default),
192                        DefaultOrExampleBlock::LabelledExample(LabelledExampleBlock {
193                            example,
194                        }) => examples.push(example),
195                    }
196                }
197
198                FeatureAdditions { examples, defaults }
199            }
200        }
201    }
202}
203
204impl From<FeatureAdditions> for FeatureAdditionChoices {
205    fn from(additions: FeatureAdditions) -> Self {
206        FeatureAdditionChoices::FeatureAdditions(additions)
207    }
208}
209
210#[derive(Debug, Deserialize, Serialize, Clone)]
211#[serde(deny_unknown_fields)]
212#[serde(rename_all = "kebab-case")]
213pub(crate) struct FeatureBody {
214    #[serde(flatten)]
215    pub(crate) metadata: FeatureMetadata,
216    // We need these in a deterministic order, so they are stable across multiple
217    // runs of the same manifests:
218    // 1. Swift insists on args in the same order they were declared.
219    // 2. imported features are declared and constructed in different runs of the tool.
220    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
221    pub(crate) variables: BTreeMap<String, FeatureFieldBody>,
222    #[serde(alias = "defaults")]
223    #[serde(skip_serializing_if = "Option::is_none")]
224    pub(crate) default: Option<Vec<DefaultBlock>>,
225    #[serde(default)]
226    #[serde(skip_serializing_if = "std::ops::Not::not")]
227    pub(crate) allow_coenrollment: bool,
228
229    #[serde(default)]
230    #[serde(skip_serializing_if = "Vec::is_empty")]
231    pub(crate) examples: Vec<ExampleBlock>,
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
235#[serde(deny_unknown_fields)]
236#[serde(rename_all = "kebab-case")]
237pub(crate) struct FeatureMetadata {
238    pub(crate) description: String,
239    /// A list of named URLs to documentation for this feature.
240    #[serde(default)]
241    #[serde(skip_serializing_if = "Vec::is_empty")]
242    pub(crate) documentation: Vec<DocumentationLink>,
243    /// A list of contacts (engineers, product owners) who can be contacted for
244    /// help with this feature. Specifically for QA questions.
245    #[serde(default)]
246    #[serde(alias = "owners", alias = "owner")]
247    #[serde(skip_serializing_if = "Vec::is_empty")]
248    pub(crate) contacts: Vec<String>,
249    /// Where should QA file issues for this feature?
250    #[serde(default)]
251    #[serde(skip_serializing_if = "Option::is_none")]
252    pub(crate) meta_bug: Option<Url>,
253    /// What Glean events can the feature produce?
254    /// These should be links to a Glean dictionary.
255    #[serde(default)]
256    #[serde(skip_serializing_if = "Vec::is_empty")]
257    pub(crate) events: Vec<Url>,
258    /// A link to a Web based configuration UI for this feature.
259    /// This UI should produce the valid JSON instead of typing it
260    /// by hand.
261    #[serde(default)]
262    #[serde(skip_serializing_if = "Option::is_none")]
263    pub(crate) configurator: Option<Url>,
264}
265
266#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
267#[serde(deny_unknown_fields)]
268pub(crate) struct DocumentationLink {
269    pub(crate) name: String,
270    pub(crate) url: Url,
271}
272
273#[derive(Debug, Deserialize, Serialize, Clone, Default)]
274#[serde(deny_unknown_fields)]
275pub struct ManifestFrontEnd {
276    #[serde(default)]
277    pub(crate) version: String,
278    #[serde(default)]
279    pub(crate) about: Option<AboutBlock>,
280
281    #[serde(default)]
282    #[serde(skip_serializing_if = "Vec::is_empty")]
283    pub(crate) channels: Vec<String>,
284
285    #[serde(default)]
286    #[serde(alias = "include")]
287    #[serde(skip_serializing_if = "Vec::is_empty")]
288    pub(crate) includes: Vec<String>,
289
290    #[serde(default)]
291    #[serde(alias = "import")]
292    #[serde(skip_serializing_if = "Vec::is_empty")]
293    pub(crate) imports: Vec<ImportBlock>,
294
295    #[serde(default)]
296    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
297    pub(crate) features: BTreeMap<String, FeatureBody>,
298
299    // We'd like to get rid of the `types` property,
300    // but we need to keep supporting it.
301    #[serde(default)]
302    #[serde(rename = "types")]
303    #[serde(skip_serializing_if = "Option::is_none")]
304    pub(crate) legacy_types: Option<Types>,
305
306    // If a types attribute isn't explicitly expressed,
307    // then we should assume that we use the flattened version.
308    #[serde(default)]
309    #[serde(flatten)]
310    pub(crate) types: Types,
311}
312
313impl ManifestFrontEnd {
314    pub fn channels(&self) -> Vec<String> {
315        self.channels.clone()
316    }
317
318    pub fn includes(&self) -> Vec<String> {
319        self.includes.clone()
320    }
321
322    /// Retrieves all the types represented in the Manifest
323    ///
324    /// # Returns
325    /// Returns a [`std::collections::HashMap<String,TypeRef>`] where
326    /// the key is the name of the type, and the TypeRef represents the type itself
327    fn get_types(&self) -> HashMap<String, TypeRef> {
328        let mut res: HashMap<_, _> = Default::default();
329
330        let types = self.legacy_types.as_ref().unwrap_or(&self.types);
331        for s in types.enums.keys() {
332            res.insert(s.clone(), TypeRef::Enum(s.clone()));
333        }
334
335        for s in types.objects.keys() {
336            res.insert(s.clone(), TypeRef::Object(s.clone()));
337        }
338
339        for f in self.features.values() {
340            for p in f.variables.values() {
341                if let Some(s) = &p.string_alias {
342                    res.insert(s.clone(), TypeRef::StringAlias(s.clone()));
343                }
344            }
345        }
346        res
347    }
348
349    fn get_prop_def_from_feature_field(&self, nm: &str, body: &FeatureFieldBody) -> PropDef {
350        let mut prop = self.get_prop_def_from_field(nm, &body.field);
351        prop.pref_key.clone_from(&body.pref_key);
352        prop.gecko_pref.clone_from(&body.gecko_pref);
353        if let Some(s) = &body.string_alias {
354            prop.string_alias = Some(TypeRef::StringAlias(s.clone()));
355        }
356        prop
357    }
358
359    /// Transforms a front-end field definition, a tuple of [`String`] and [`FieldBody`],
360    /// into a [`PropDef`]
361    ///
362    /// # Arguments
363    /// - `field`: The [`(&String, &FieldBody)`] tuple to get the propdef from
364    ///
365    /// # Returns
366    /// return the IR [`PropDef`]
367    fn get_prop_def_from_field(&self, nm: &str, body: &FieldBody) -> PropDef {
368        let types = self.get_types();
369        PropDef {
370            name: nm.into(),
371            doc: body.description.clone(),
372            typ: match get_typeref_from_string(body.variable_type.to_owned(), &types) {
373                Ok(type_ref) => type_ref,
374                Err(e) => {
375                    // Try matching against the user defined types
376                    match types.get(&body.variable_type) {
377                        Some(type_ref) => type_ref.to_owned(),
378                        None => panic!(
379                            "{}\n{} is not a valid FML type or user defined type",
380                            e, body.variable_type
381                        ),
382                    }
383                }
384            },
385            default: json!(body.default),
386            string_alias: None,
387            pref_key: Default::default(),
388            gecko_pref: Default::default(),
389        }
390    }
391
392    /// Retrieves all the feature definitions represented in the manifest
393    ///
394    /// # Returns
395    /// Returns a [`std::collections::BTreeMap<String, FeatureDef>`]
396    fn get_feature_defs(&self, merger: &DefaultsMerger) -> Result<BTreeMap<String, FeatureDef>> {
397        let mut features: BTreeMap<_, _> = Default::default();
398        for (nm, body) in &self.features {
399            let mut fields: Vec<_> = Default::default();
400            for (fnm, field) in &body.variables {
401                fields.push(self.get_prop_def_from_feature_field(fnm, field));
402            }
403            let examples = body.examples.iter().map(Into::into).collect();
404
405            let mut def = FeatureDef {
406                name: nm.clone(),
407                metadata: body.metadata.clone(),
408                props: fields,
409                allow_coenrollment: body.allow_coenrollment,
410                examples,
411            };
412            merger.merge_feature_defaults(&mut def, &body.default)?;
413            features.insert(nm.to_owned(), def);
414        }
415        Ok(features)
416    }
417
418    /// Retrieves all the Object type definitions represented in the manifest
419    ///
420    /// # Returns
421    /// Returns a [`std::collections::BTreeMap<String. ObjectDef>`]
422    fn get_objects(&self) -> BTreeMap<String, ObjectDef> {
423        let types = self.legacy_types.as_ref().unwrap_or(&self.types);
424        let mut objs: BTreeMap<_, _> = Default::default();
425        for (nm, body) in &types.objects {
426            let mut fields: Vec<_> = Default::default();
427            for (fnm, field) in &body.fields {
428                fields.push(self.get_prop_def_from_field(fnm, field));
429            }
430            objs.insert(
431                nm.to_owned(),
432                ObjectDef {
433                    name: nm.clone(),
434                    doc: body.description.clone(),
435                    props: fields,
436                },
437            );
438        }
439        objs
440    }
441
442    /// Retrieves all the Enum type definitions represented in the manifest
443    ///
444    /// # Returns
445    /// Returns a [`std::collections::BTreeMap<String, EnumDef>`]
446    fn get_enums(&self) -> BTreeMap<String, EnumDef> {
447        let types = self.legacy_types.as_ref().unwrap_or(&self.types);
448        let mut enums: BTreeMap<_, _> = Default::default();
449        for (name, body) in &types.enums {
450            let mut variants: Vec<_> = Default::default();
451            for (v_name, v_body) in &body.variants {
452                variants.push(VariantDef {
453                    name: v_name.clone(),
454                    doc: v_body.description.clone(),
455                });
456            }
457            enums.insert(
458                name.to_owned(),
459                EnumDef {
460                    name: name.clone(),
461                    doc: body.description.clone(),
462                    variants,
463                },
464            );
465        }
466        enums
467    }
468
469    pub(crate) fn get_intermediate_representation(
470        &self,
471        id: &ModuleId,
472        channel: Option<&str>,
473    ) -> Result<FeatureManifest> {
474        let enums = self.get_enums();
475        let objects = self.get_objects();
476        let merger =
477            DefaultsMerger::new(&objects, self.channels.clone(), channel.map(str::to_string));
478
479        let features = self.get_feature_defs(&merger)?;
480
481        let about = match &self.about {
482            Some(a) => a.clone(),
483            None => Default::default(),
484        };
485
486        Ok(FeatureManifest::new(
487            id.clone(),
488            channel,
489            features,
490            enums,
491            objects,
492            about,
493        ))
494    }
495}
496
497#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
498#[serde(untagged)]
499pub enum ExampleBlock {
500    /// This is the complete example inlined into the manifest file.
501    Inline(InlineExampleBlock),
502    /// This is the name, description and URL inlined into the manifest, but a path to a JSON file containing the feature configuration.
503    Partial(PartialExampleBlock),
504    /// This is a path to a YAML or JSON file containing the name, description, and URL as well as the actual feature configuration.
505    Path(PathOnly),
506    /// This is a path to a YAML or JSON file containing the name, description, and URL as well as the actual feature configuration.
507    BarePath(String),
508}
509
510#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
511#[serde(deny_unknown_fields)]
512pub struct InlineExampleBlock {
513    #[serde(flatten)]
514    pub(crate) metadata: FeatureExampleMetadata,
515    pub(crate) value: serde_json::Value,
516}
517
518#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
519#[serde(deny_unknown_fields)]
520pub struct PartialExampleBlock {
521    #[serde(flatten)]
522    pub(crate) metadata: FeatureExampleMetadata,
523    pub(crate) path: String,
524}
525
526#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
527#[serde(deny_unknown_fields)]
528pub struct PathOnly {
529    pub(crate) path: String,
530}
531
532#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
533#[serde(deny_unknown_fields)]
534pub struct FeatureExampleMetadata {
535    pub(crate) name: String,
536    #[serde(skip_serializing_if = "Option::is_none")]
537    pub(crate) description: Option<String>,
538    #[serde(skip_serializing_if = "Option::is_none")]
539    pub(crate) url: Option<Url>,
540}
541
542#[derive(Debug, Deserialize, Serialize, Clone)]
543pub struct DefaultBlock {
544    #[serde(skip_serializing_if = "Option::is_none")]
545    pub(crate) channel: Option<String>,
546    #[serde(skip_serializing_if = "Option::is_none")]
547    pub(crate) channels: Option<Vec<String>>,
548    pub(crate) value: serde_json::Value,
549    #[serde(skip_serializing_if = "Option::is_none")]
550    pub(crate) targeting: Option<String>,
551}
552
553impl DefaultBlock {
554    pub fn merge_channels(&self) -> Option<Vec<String>> {
555        let mut res = HashSet::new();
556
557        if let Some(channels) = self.channels.clone() {
558            res.extend(channels)
559        }
560
561        if let Some(channel) = &self.channel {
562            res.extend(
563                channel
564                    .split(',')
565                    .filter(|channel_name| !channel_name.is_empty())
566                    .map(|channel_name| channel_name.trim().to_string())
567                    .collect::<HashSet<String>>(),
568            )
569        }
570
571        let res: Vec<String> = res.into_iter().collect();
572        if res.is_empty() {
573            None
574        } else {
575            Some(res)
576        }
577    }
578}
579
580impl From<serde_json::Value> for DefaultBlock {
581    fn from(value: serde_json::Value) -> Self {
582        Self {
583            value,
584            channels: None,
585            channel: None,
586            targeting: None,
587        }
588    }
589}
590
591#[cfg(test)]
592mod about_block {
593    use super::*;
594
595    #[test]
596    fn test_parsing_about_block() -> Result<()> {
597        let about = AboutBlock {
598            kotlin_about: Some(KotlinAboutBlock {
599                package: "com.example".to_string(),
600                class: "KotlinAbout".to_string(),
601            }),
602            ..Default::default()
603        };
604
605        let yaml = serde_yaml::to_value(&about)?;
606
607        let rehydrated = serde_yaml::from_value(yaml)?;
608
609        assert_eq!(about, rehydrated);
610
611        Ok(())
612    }
613}
614
615#[cfg(test)]
616mod default_block {
617    use super::*;
618
619    #[test]
620    fn test_merge_channels_none_when_empty() {
621        let input: DefaultBlock = serde_json::from_value(json!(
622            {
623                "channel": "",
624                "channels": [],
625                "value": {
626                    "button-color": "green"
627                }
628            }
629        ))
630        .unwrap();
631        assert!(input.merge_channels().is_none())
632    }
633
634    #[test]
635    fn test_merge_channels_merged_when_present() {
636        let input: DefaultBlock = serde_json::from_value(json!(
637            {
638                "channel": "a, b",
639                "channels": ["c"],
640                "value": {
641                    "button-color": "green"
642                }
643            }
644        ))
645        .unwrap();
646        let res = input.merge_channels();
647        assert!(res.is_some());
648        let res = res.unwrap();
649        assert!(res.contains(&"a".to_string()));
650        assert!(res.contains(&"b".to_string()));
651        assert!(res.contains(&"c".to_string()));
652    }
653
654    #[test]
655    fn test_merge_channels_merged_without_duplicates() {
656        let input: DefaultBlock = serde_json::from_value(json!(
657            {
658                "channel": "a, a",
659                "channels": ["a"],
660                "value": {
661                    "button-color": "green"
662                }
663            }
664        ))
665        .unwrap();
666        let res = input.merge_channels();
667        assert!(res.is_some());
668        let res = res.unwrap();
669        assert!(res.contains(&"a".to_string()));
670        assert!(res.len() == 1)
671    }
672}
673
674#[cfg(test)]
675mod feature_metadata {
676    use super::*;
677    use std::str::FromStr;
678
679    impl DocumentationLink {
680        fn new(nm: &str, url: &str) -> Result<Self> {
681            Ok(Self {
682                name: nm.to_string(),
683                url: Url::from_str(url)?,
684            })
685        }
686    }
687
688    #[test]
689    fn test_happy_path() -> Result<()> {
690        let fm = serde_json::from_str::<FeatureMetadata>(
691            r#"{
692            "description": "A description",
693            "meta-bug": "https://example.com/EXP-23",
694            "contacts": [
695                "jdoe@example.com"
696            ],
697            "documentation": [
698                {
699                    "name": "User documentation",
700                    "url": "https://example.info/my-feature"
701                }
702            ],
703            "events": [
704                "https://example.com/glean/dictionary/button-pressed"
705            ],
706            "configurator": "https://auth.example.com/my-feature/configuration-ui"
707        }"#,
708        )?;
709        assert_eq!(
710            fm,
711            FeatureMetadata {
712                description: "A description".to_string(),
713                meta_bug: Some(Url::from_str("https://example.com/EXP-23")?),
714                contacts: vec!["jdoe@example.com".to_string()],
715                documentation: vec![DocumentationLink::new(
716                    "User documentation",
717                    "https://example.info/my-feature"
718                )?],
719                configurator: Some(Url::from_str(
720                    "https://auth.example.com/my-feature/configuration-ui"
721                )?),
722                events: vec![Url::from_str(
723                    "https://example.com/glean/dictionary/button-pressed"
724                )?,],
725            }
726        );
727
728        let fm = serde_json::from_str::<FeatureMetadata>(
729            r#"{
730            "description": "A description",
731            "meta-bug": "https://example.com/EXP-23",
732            "documentation": [
733                {
734                    "name": "User documentation",
735                    "url": "https://example.info/my-feature"
736                }
737            ]
738        }"#,
739        )?;
740        assert_eq!(
741            fm,
742            FeatureMetadata {
743                description: "A description".to_string(),
744                meta_bug: Some(Url::from_str("https://example.com/EXP-23")?),
745                contacts: Default::default(),
746                documentation: vec![DocumentationLink::new(
747                    "User documentation",
748                    "https://example.info/my-feature"
749                )?],
750                ..Default::default()
751            }
752        );
753
754        let fm = serde_json::from_str::<FeatureMetadata>(
755            r#"{
756            "description": "A description",
757            "contacts": [
758                "jdoe@example.com"
759            ]
760        }"#,
761        )?;
762        assert_eq!(
763            fm,
764            FeatureMetadata {
765                description: "A description".to_string(),
766                contacts: vec!["jdoe@example.com".to_string()],
767                ..Default::default()
768            }
769        );
770
771        Ok(())
772    }
773
774    #[test]
775    fn test_invalid_urls() -> Result<()> {
776        let fm = serde_json::from_str::<FeatureMetadata>(
777            r#"{
778            "description": "A description",
779            "documentation": [
780                "Not a url"
781            ],
782        }"#,
783        );
784        assert!(fm.is_err());
785
786        let fm = serde_json::from_str::<FeatureMetadata>(
787            r#"{
788            "description": "A description",
789            "meta-bug": "Not a url"
790        }"#,
791        );
792        assert!(fm.is_err());
793        Ok(())
794    }
795}
796
797#[cfg(test)]
798mod example_block {
799    use super::*;
800
801    #[test]
802    fn test_inline() -> Result<()> {
803        let v1 = json!({ "my-int": 1 });
804        let input = json!({
805            "value": v1.clone(),
806            "name": "An example example",
807            "description": "A paragraph of description",
808        });
809
810        let frontend: ExampleBlock = serde_json::from_value(input)?;
811        assert_eq!(
812            frontend,
813            ExampleBlock::Inline(InlineExampleBlock {
814                value: v1,
815                metadata: FeatureExampleMetadata {
816                    name: "An example example".to_owned(),
817                    description: Some("A paragraph of description".to_owned()),
818                    url: None
819                }
820            })
821        );
822
823        Ok(())
824    }
825
826    #[test]
827    fn test_partial_inline() -> Result<()> {
828        let input = json!({
829            "name": "An example example",
830            "description": "A paragraph of description",
831            "path": "./path/to-example.json"
832        });
833
834        let frontend: ExampleBlock = serde_json::from_value(input)?;
835        assert_eq!(
836            frontend,
837            ExampleBlock::Partial(PartialExampleBlock {
838                path: "./path/to-example.json".to_owned(),
839                metadata: FeatureExampleMetadata {
840                    name: "An example example".to_owned(),
841                    description: Some("A paragraph of description".to_owned()),
842                    url: None
843                }
844            })
845        );
846
847        Ok(())
848    }
849
850    #[test]
851    fn test_path_only() -> Result<()> {
852        let input = json!({
853            "path": "./path/to-example-with-name-and-description.yaml"
854        });
855
856        let frontend: ExampleBlock = serde_json::from_value(input)?;
857        assert_eq!(
858            frontend,
859            ExampleBlock::Path(PathOnly {
860                path: "./path/to-example-with-name-and-description.yaml".to_owned(),
861            })
862        );
863
864        Ok(())
865    }
866    #[test]
867    fn test_bare_path() -> Result<()> {
868        let input = json!("./path/to-example-with-name-and-description.yaml");
869
870        let frontend: ExampleBlock = serde_json::from_value(input)?;
871        assert_eq!(
872            frontend,
873            ExampleBlock::BarePath("./path/to-example-with-name-and-description.yaml".to_owned(),)
874        );
875
876        Ok(())
877    }
878}
879
880#[cfg(test)]
881mod feature_additions {
882    use super::*;
883
884    #[test]
885    fn test_legacy_case() -> Result<()> {
886        let v1 = json!({ "my-int": 1 });
887        let v2 = json!({ "my-int": 2 });
888        let input = json!([
889            { "value": v1.clone(), "channel": "a-channel" },
890            { "value": v2.clone() },
891        ]);
892
893        let frontend: FeatureAdditionChoices = serde_json::from_value(input)?;
894        let observed: FeatureAdditions = frontend.into();
895
896        assert_eq!(observed.defaults.len(), 2);
897        assert_eq!(
898            observed.defaults[0].channel.as_ref().unwrap().as_str(),
899            "a-channel"
900        );
901        assert_eq!(observed.defaults[0].value, v1);
902
903        assert_eq!(observed.defaults[1].channel, None);
904        assert_eq!(observed.defaults[1].value, v2);
905
906        Ok(())
907    }
908
909    #[test]
910    fn test_easy_addition_example() -> Result<()> {
911        let v1 = json!({ "my-int": 1 });
912        let v2 = json!({ "my-int": 2 });
913        let input = json!([
914            { "value": v1.clone(), "channel": "a-channel" },
915            { "example": { "name": "Example example", "value": v2.clone() } },
916            { "example": { "name": "Example example", "path": "./path/to-example.json"} },
917            { "example": { "path": "./path/to-example.yaml"} },
918            { "example": "./path/to-example.yaml" },
919        ]);
920
921        let frontend: FeatureAdditionChoices = serde_json::from_value(input)?;
922        let observed: FeatureAdditions = frontend.into();
923
924        assert_eq!(observed.defaults.len(), 1);
925        assert_eq!(
926            observed.defaults[0].channel.as_ref().unwrap().as_str(),
927            "a-channel"
928        );
929        assert_eq!(observed.defaults[0].value, v1);
930
931        assert_eq!(observed.examples.len(), 4);
932        assert!(matches!(&observed.examples[0], ExampleBlock::Inline(..)));
933        assert!(matches!(&observed.examples[1], ExampleBlock::Partial(..)));
934        assert!(matches!(&observed.examples[2], ExampleBlock::Path(..)));
935        assert!(matches!(&observed.examples[3], ExampleBlock::BarePath(..)));
936
937        Ok(())
938    }
939
940    #[test]
941    fn test_list_of_defaults_or_examples() -> Result<()> {
942        let v1 = json!({ "my-int": 1 });
943        let v2 = json!({ "my-int": 2 });
944        let input = json!([
945            { "default": { "value": v1.clone(), "channel": "a-channel" } },
946            { "example": { "name": "Example example", "value": v2.clone() } },
947        ]);
948
949        let frontend: FeatureAdditionChoices = serde_json::from_value(input)?;
950        let observed: FeatureAdditions = frontend.into();
951
952        assert_eq!(observed.defaults.len(), 1);
953        assert_eq!(
954            observed.defaults[0].channel.as_ref().unwrap().as_str(),
955            "a-channel"
956        );
957        assert_eq!(observed.defaults[0].value, v1);
958
959        assert_eq!(observed.examples.len(), 1);
960        assert!(matches!(&observed.examples[0], ExampleBlock::Inline(..)));
961
962        Ok(())
963    }
964
965    #[test]
966    fn test_list_of_defaults_and_list_of_examples() -> Result<()> {
967        let v1 = json!({ "my-int": 1 });
968        let v2 = json!({ "my-int": 2 });
969        let input = json!({
970            "defaults": [ { "value": v1.clone(), "channel": "a-channel" } ],
971            "examples": [ { "name": "Example example", "value": v2.clone() } ],
972        });
973
974        let frontend: FeatureAdditionChoices = serde_json::from_value(input)?;
975        let observed: FeatureAdditions = frontend.into();
976
977        assert_eq!(observed.defaults.len(), 1);
978        assert_eq!(
979            observed.defaults[0].channel.as_ref().unwrap().as_str(),
980            "a-channel"
981        );
982        assert_eq!(observed.defaults[0].value, v1);
983
984        assert_eq!(observed.examples.len(), 1);
985        assert!(matches!(&observed.examples[0], ExampleBlock::Inline(..)));
986
987        Ok(())
988    }
989}