Skip to main content

fomod_oxide/
config.rs

1use serde::{Deserialize, Serialize};
2
3use crate::condition::{CompositeDependency, EvalContext};
4use crate::error;
5
6/// Root element of a FOMOD `ModuleConfig.xml`.
7#[derive(Debug, Clone, Serialize, Deserialize)]
8#[serde(rename = "config")]
9pub struct ModuleConfig {
10    #[serde(rename = "moduleName")]
11    pub module_name: ModuleName,
12
13    #[serde(rename = "moduleImage")]
14    pub module_image: Option<ModuleImage>,
15
16    #[serde(rename = "moduleDependencies")]
17    pub module_dependencies: Option<CompositeDependency>,
18
19    #[serde(rename = "requiredInstallFiles")]
20    pub required_install_files: Option<FileList>,
21
22    #[serde(rename = "installSteps")]
23    pub install_steps: Option<InstallSteps>,
24
25    #[serde(rename = "conditionalFileInstalls")]
26    pub conditional_file_installs: Option<ConditionalFileInstalls>,
27}
28
29impl ModuleConfig {
30    pub fn parse(xml: &str) -> error::Result<Self> {
31        quick_xml::de::from_str(xml).map_err(Into::into)
32    }
33}
34
35/// Module display name with optional positioning.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct ModuleName {
38    #[serde(rename = "@position")]
39    pub position: Option<NamePosition>,
40
41    #[serde(rename = "$text")]
42    pub value: String,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
46pub enum NamePosition {
47    Left,
48    Right,
49    RightOfImage,
50}
51
52/// Header/banner image for the installer.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct ModuleImage {
55    #[serde(rename = "@path")]
56    pub path: String,
57
58    #[serde(rename = "@showImage", default = "default_true")]
59    pub show_image: bool,
60
61    #[serde(rename = "@showFade", default = "default_true")]
62    pub show_fade: bool,
63
64    #[serde(rename = "@height", default = "default_neg_one")]
65    pub height: i32,
66}
67
68/// Ordered sequence of installation steps (pages).
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct InstallSteps {
71    #[serde(rename = "@order")]
72    pub order: Option<SortOrder>,
73
74    #[serde(rename = "installStep", default)]
75    pub steps: Vec<InstallStep>,
76}
77
78/// A single page presented to the user.
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct InstallStep {
81    #[serde(rename = "@name")]
82    pub name: String,
83
84    pub visible: Option<CompositeDependency>,
85
86    #[serde(rename = "optionalFileGroups")]
87    pub optional_file_groups: Option<GroupList>,
88}
89
90/// Container for option groups within a step.
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct GroupList {
93    #[serde(rename = "@order")]
94    pub order: Option<SortOrder>,
95
96    #[serde(rename = "group", default)]
97    pub groups: Vec<Group>,
98}
99
100/// A group of related installation options.
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct Group {
103    #[serde(rename = "@name")]
104    pub name: String,
105
106    #[serde(rename = "@type")]
107    pub group_type: GroupType,
108
109    #[serde(rename = "plugins")]
110    pub plugins: PluginList,
111}
112
113/// Container for plugins within a group.
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct PluginList {
116    #[serde(rename = "@order")]
117    pub order: Option<SortOrder>,
118
119    #[serde(rename = "plugin", default)]
120    pub plugins: Vec<Plugin>,
121}
122
123/// An individual installation option the user can select.
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct Plugin {
126    #[serde(rename = "@name")]
127    pub name: String,
128
129    pub description: Option<String>,
130
131    pub image: Option<PluginImage>,
132
133    #[serde(rename = "typeDescriptor")]
134    pub type_descriptor: Option<TypeDescriptor>,
135
136    #[serde(rename = "conditionFlags")]
137    pub condition_flags: Option<ConditionFlagList>,
138
139    pub files: Option<FileList>,
140}
141
142impl Plugin {
143    /// Resolved plugin type using static information only, defaulting to `Optional`.
144    ///
145    /// For `dependencyType` descriptors, returns the default type without
146    /// evaluating patterns. Use [`plugin_type_in_context`](Self::plugin_type_in_context)
147    /// when runtime condition evaluation is needed.
148    pub fn plugin_type(&self) -> PluginType {
149        self.type_descriptor
150            .as_ref()
151            .map(|td| td.resolved_type())
152            .unwrap_or(PluginType::Optional)
153    }
154
155    /// Resolved plugin type with runtime condition evaluation.
156    ///
157    /// Evaluates `dependencyType` patterns against the provided context,
158    /// falling back to the default type if no pattern matches.
159    pub fn plugin_type_in_context(&self, ctx: &EvalContext) -> PluginType {
160        self.type_descriptor
161            .as_ref()
162            .map(|td| td.resolved_type_in_context(ctx))
163            .unwrap_or(PluginType::Optional)
164    }
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct PluginImage {
169    #[serde(rename = "@path")]
170    pub path: String,
171}
172
173/// Describes the selection type of a plugin. Supports both simple
174/// `<type name="..."/>` and conditional `<dependencyType>` forms.
175#[derive(Debug, Clone, Serialize, Deserialize)]
176pub struct TypeDescriptor {
177    #[serde(rename = "type")]
178    pub simple_type: Option<SimpleType>,
179
180    #[serde(rename = "dependencyType")]
181    pub dependency_type: Option<DependencyType>,
182}
183
184impl TypeDescriptor {
185    /// Static type resolution (no condition evaluation).
186    pub fn resolved_type(&self) -> PluginType {
187        self.resolve_with(|_| false)
188    }
189
190    /// Resolve type by evaluating `dependencyType` patterns against context.
191    pub fn resolved_type_in_context(&self, ctx: &EvalContext) -> PluginType {
192        use crate::condition::Evaluate;
193        self.resolve_with(|dep| dep.evaluate(ctx))
194    }
195
196    fn resolve_with(&self, eval: impl Fn(&CompositeDependency) -> bool) -> PluginType {
197        if let Some(ref st) = self.simple_type {
198            return st.name;
199        }
200        if let Some(ref dt) = self.dependency_type {
201            if let Some(ref patterns) = dt.patterns {
202                for pattern in &patterns.patterns {
203                    if eval(&pattern.dependencies) {
204                        return pattern.plugin_type.name;
205                    }
206                }
207            }
208            return dt.default_type.name;
209        }
210        PluginType::Optional
211    }
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct SimpleType {
216    #[serde(rename = "@name")]
217    pub name: PluginType,
218}
219
220/// Conditional type descriptor — type depends on runtime conditions.
221#[derive(Debug, Clone, Serialize, Deserialize)]
222pub struct DependencyType {
223    #[serde(rename = "defaultType")]
224    pub default_type: SimpleType,
225
226    #[serde(rename = "patterns")]
227    pub patterns: Option<TypePatterns>,
228}
229
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct TypePatterns {
232    #[serde(rename = "pattern", default)]
233    pub patterns: Vec<TypePattern>,
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize)]
237pub struct TypePattern {
238    pub dependencies: CompositeDependency,
239
240    #[serde(rename = "type")]
241    pub plugin_type: SimpleType,
242}
243
244/// Flags set when a plugin is selected.
245#[derive(Debug, Clone, Serialize, Deserialize)]
246pub struct ConditionFlagList {
247    #[serde(rename = "flag", default)]
248    pub flags: Vec<ConditionFlag>,
249}
250
251#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct ConditionFlag {
253    #[serde(rename = "@name")]
254    pub name: String,
255
256    #[serde(rename = "$text")]
257    pub value: String,
258}
259
260/// Pattern-based conditional file installations evaluated after all steps.
261#[derive(Debug, Clone, Serialize, Deserialize)]
262pub struct ConditionalFileInstalls {
263    pub patterns: ConditionalPatterns,
264}
265
266#[derive(Debug, Clone, Serialize, Deserialize)]
267pub struct ConditionalPatterns {
268    #[serde(rename = "pattern", default)]
269    pub patterns: Vec<ConditionalPattern>,
270}
271
272#[derive(Debug, Clone, Serialize, Deserialize)]
273pub struct ConditionalPattern {
274    pub dependencies: CompositeDependency,
275    pub files: FileList,
276}
277
278// --- File/folder references ---
279
280#[derive(Debug, Clone, Serialize, Deserialize)]
281pub struct FileList {
282    #[serde(rename = "$value", default)]
283    pub items: Vec<FileItem>,
284}
285
286#[derive(Debug, Clone, Serialize, Deserialize)]
287#[serde(rename_all = "lowercase")]
288pub enum FileItem {
289    File(FileRef),
290    Folder(FileRef),
291}
292
293impl FileItem {
294    pub fn file_ref(&self) -> &FileRef {
295        match self {
296            FileItem::File(r) | FileItem::Folder(r) => r,
297        }
298    }
299
300    pub fn is_folder(&self) -> bool {
301        matches!(self, FileItem::Folder(_))
302    }
303}
304
305#[derive(Debug, Clone, Serialize, Deserialize)]
306pub struct FileRef {
307    #[serde(rename = "@source")]
308    pub source: String,
309
310    #[serde(rename = "@destination", default)]
311    pub destination: String,
312
313    #[serde(rename = "@priority", default)]
314    pub priority: i32,
315
316    #[serde(rename = "@alwaysInstall", default)]
317    pub always_install: bool,
318
319    #[serde(rename = "@installIfUsable", default)]
320    pub install_if_usable: bool,
321}
322
323// --- Enums ---
324
325#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
326pub enum GroupType {
327    SelectExactlyOne,
328    SelectAtMostOne,
329    SelectAtLeastOne,
330    SelectAll,
331    SelectAny,
332}
333
334#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
335pub enum PluginType {
336    Required,
337    Recommended,
338    Optional,
339    CouldBeUsable,
340    NotUsable,
341}
342
343#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
344pub enum SortOrder {
345    Explicit,
346    Ascending,
347    Descending,
348}
349
350// --- Helpers ---
351
352fn default_true() -> bool {
353    true
354}
355
356fn default_neg_one() -> i32 {
357    -1
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    // ---- ModuleConfig parsing ----
365
366    #[test]
367    fn parse_minimal_config() {
368        let xml = r#"<config><moduleName>Test</moduleName></config>"#;
369        let config = ModuleConfig::parse(xml).unwrap();
370        assert_eq!(config.module_name.value, "Test");
371        assert!(config.module_image.is_none());
372        assert!(config.module_dependencies.is_none());
373        assert!(config.required_install_files.is_none());
374        assert!(config.install_steps.is_none());
375        assert!(config.conditional_file_installs.is_none());
376    }
377
378    #[test]
379    fn parse_empty_module_name_fails() {
380        // Empty <moduleName/> fails because quick_xml requires $text content
381        let xml = r#"<config><moduleName></moduleName></config>"#;
382        assert!(ModuleConfig::parse(xml).is_err());
383    }
384
385    #[test]
386    fn parse_module_name_with_position() {
387        let xml = r#"<config><moduleName position="Left">Test</moduleName></config>"#;
388        let config = ModuleConfig::parse(xml).unwrap();
389        assert_eq!(config.module_name.position, Some(NamePosition::Left));
390
391        let xml = r#"<config><moduleName position="RightOfImage">Test</moduleName></config>"#;
392        let config = ModuleConfig::parse(xml).unwrap();
393        assert_eq!(
394            config.module_name.position,
395            Some(NamePosition::RightOfImage)
396        );
397    }
398
399    #[test]
400    fn parse_invalid_xml_fails() {
401        assert!(ModuleConfig::parse("not xml").is_err());
402        assert!(ModuleConfig::parse("").is_err());
403        assert!(ModuleConfig::parse("<config></config>").is_err());
404    }
405
406    #[test]
407    fn parse_module_image_defaults() {
408        let xml = r#"
409            <config>
410                <moduleName>Test</moduleName>
411                <moduleImage path="img.png"/>
412            </config>
413        "#;
414        let config = ModuleConfig::parse(xml).unwrap();
415        let img = config.module_image.unwrap();
416        assert_eq!(img.path, "img.png");
417        assert!(img.show_image);
418        assert!(img.show_fade);
419        assert_eq!(img.height, -1);
420    }
421
422    #[test]
423    fn parse_module_image_custom() {
424        let xml = r#"
425            <config>
426                <moduleName>Test</moduleName>
427                <moduleImage path="img.png" showImage="false" showFade="false" height="200"/>
428            </config>
429        "#;
430        let config = ModuleConfig::parse(xml).unwrap();
431        let img = config.module_image.unwrap();
432        assert!(!img.show_image);
433        assert!(!img.show_fade);
434        assert_eq!(img.height, 200);
435    }
436
437    // ---- GroupType ----
438
439    #[test]
440    fn parse_all_group_types() {
441        for (type_str, expected) in [
442            ("SelectExactlyOne", GroupType::SelectExactlyOne),
443            ("SelectAtMostOne", GroupType::SelectAtMostOne),
444            ("SelectAtLeastOne", GroupType::SelectAtLeastOne),
445            ("SelectAll", GroupType::SelectAll),
446            ("SelectAny", GroupType::SelectAny),
447        ] {
448            let xml = format!(
449                r#"<config><moduleName>T</moduleName>
450                <installSteps><installStep name="S">
451                <optionalFileGroups><group name="G" type="{type_str}">
452                <plugins><plugin name="P"><typeDescriptor><type name="Optional"/></typeDescriptor></plugin></plugins>
453                </group></optionalFileGroups>
454                </installStep></installSteps></config>"#
455            );
456            let config = ModuleConfig::parse(&xml).unwrap();
457            let group = &config.install_steps.as_ref().unwrap().steps[0]
458                .optional_file_groups
459                .as_ref()
460                .unwrap()
461                .groups[0];
462            assert_eq!(group.group_type, expected, "Failed for {type_str}");
463        }
464    }
465
466    // ---- PluginType ----
467
468    #[test]
469    fn parse_all_plugin_types() {
470        for (type_str, expected) in [
471            ("Required", PluginType::Required),
472            ("Recommended", PluginType::Recommended),
473            ("Optional", PluginType::Optional),
474            ("CouldBeUsable", PluginType::CouldBeUsable),
475            ("NotUsable", PluginType::NotUsable),
476        ] {
477            let xml = format!(
478                r#"<config><moduleName>T</moduleName>
479                <installSteps><installStep name="S">
480                <optionalFileGroups><group name="G" type="SelectAny">
481                <plugins><plugin name="P"><typeDescriptor><type name="{type_str}"/></typeDescriptor></plugin></plugins>
482                </group></optionalFileGroups>
483                </installStep></installSteps></config>"#
484            );
485            let config = ModuleConfig::parse(&xml).unwrap();
486            let plugin = &config.install_steps.as_ref().unwrap().steps[0]
487                .optional_file_groups
488                .as_ref()
489                .unwrap()
490                .groups[0]
491                .plugins
492                .plugins[0];
493            assert_eq!(plugin.plugin_type(), expected, "Failed for {type_str}");
494        }
495    }
496
497    #[test]
498    fn plugin_type_defaults_to_optional() {
499        let xml = r#"
500            <config><moduleName>T</moduleName>
501            <installSteps><installStep name="S">
502            <optionalFileGroups><group name="G" type="SelectAny">
503            <plugins><plugin name="P"></plugin></plugins>
504            </group></optionalFileGroups>
505            </installStep></installSteps></config>
506        "#;
507        let config = ModuleConfig::parse(xml).unwrap();
508        let plugin = &config.install_steps.as_ref().unwrap().steps[0]
509            .optional_file_groups
510            .as_ref()
511            .unwrap()
512            .groups[0]
513            .plugins
514            .plugins[0];
515        assert_eq!(plugin.plugin_type(), PluginType::Optional);
516    }
517
518    // ---- TypeDescriptor ----
519
520    #[test]
521    fn type_descriptor_simple_takes_precedence() {
522        // If both simple_type and dependency_type exist, simple_type wins
523        let td = TypeDescriptor {
524            simple_type: Some(SimpleType {
525                name: PluginType::Required,
526            }),
527            dependency_type: Some(DependencyType {
528                default_type: SimpleType {
529                    name: PluginType::NotUsable,
530                },
531                patterns: None,
532            }),
533        };
534        assert_eq!(td.resolved_type(), PluginType::Required);
535
536        let ctx = EvalContext::default();
537        assert_eq!(td.resolved_type_in_context(&ctx), PluginType::Required);
538    }
539
540    #[test]
541    fn type_descriptor_dependency_default() {
542        let td = TypeDescriptor {
543            simple_type: None,
544            dependency_type: Some(DependencyType {
545                default_type: SimpleType {
546                    name: PluginType::NotUsable,
547                },
548                patterns: None,
549            }),
550        };
551        assert_eq!(td.resolved_type(), PluginType::NotUsable);
552    }
553
554    #[test]
555    fn type_descriptor_none_defaults_optional() {
556        let td = TypeDescriptor {
557            simple_type: None,
558            dependency_type: None,
559        };
560        assert_eq!(td.resolved_type(), PluginType::Optional);
561    }
562
563    // ---- FileItem ----
564
565    #[test]
566    fn file_item_is_folder() {
567        let file = FileItem::File(FileRef {
568            source: "a.esp".into(),
569            destination: "".into(),
570            priority: 0,
571            always_install: false,
572            install_if_usable: false,
573        });
574        assert!(!file.is_folder());
575
576        let folder = FileItem::Folder(FileRef {
577            source: "dir".into(),
578            destination: "".into(),
579            priority: 0,
580            always_install: false,
581            install_if_usable: false,
582        });
583        assert!(folder.is_folder());
584    }
585
586    #[test]
587    fn file_item_file_ref() {
588        let file = FileItem::File(FileRef {
589            source: "a.esp".into(),
590            destination: "Data".into(),
591            priority: 5,
592            always_install: true,
593            install_if_usable: false,
594        });
595        let r = file.file_ref();
596        assert_eq!(r.source, "a.esp");
597        assert_eq!(r.destination, "Data");
598        assert_eq!(r.priority, 5);
599        assert!(r.always_install);
600    }
601
602    // ---- FileRef defaults ----
603
604    #[test]
605    fn parse_file_ref_defaults() {
606        let xml = r#"
607            <config><moduleName>T</moduleName>
608            <requiredInstallFiles>
609                <file source="test.esp"/>
610            </requiredInstallFiles></config>
611        "#;
612        let config = ModuleConfig::parse(xml).unwrap();
613        let item = &config.required_install_files.as_ref().unwrap().items[0];
614        let r = item.file_ref();
615        assert_eq!(r.source, "test.esp");
616        assert_eq!(r.destination, "");
617        assert_eq!(r.priority, 0);
618        assert!(!r.always_install);
619        assert!(!r.install_if_usable);
620    }
621
622    #[test]
623    fn parse_file_and_folder_mix() {
624        let xml = r#"
625            <config><moduleName>T</moduleName>
626            <requiredInstallFiles>
627                <file source="a.esp" destination="Data"/>
628                <folder source="meshes" destination="Data/meshes"/>
629                <file source="b.esp" destination="Data" priority="10"/>
630            </requiredInstallFiles></config>
631        "#;
632        let config = ModuleConfig::parse(xml).unwrap();
633        let items = &config.required_install_files.as_ref().unwrap().items;
634        assert_eq!(items.len(), 3);
635        assert!(!items[0].is_folder());
636        assert!(items[1].is_folder());
637        assert!(!items[2].is_folder());
638        assert_eq!(items[2].file_ref().priority, 10);
639    }
640
641    // ---- SortOrder ----
642
643    #[test]
644    fn parse_sort_orders() {
645        for (order_str, expected) in [
646            ("Explicit", SortOrder::Explicit),
647            ("Ascending", SortOrder::Ascending),
648            ("Descending", SortOrder::Descending),
649        ] {
650            let xml = format!(
651                r#"<config><moduleName>T</moduleName>
652                <installSteps order="{order_str}">
653                <installStep name="S">
654                <optionalFileGroups><group name="G" type="SelectAny">
655                <plugins><plugin name="P"><typeDescriptor><type name="Optional"/></typeDescriptor></plugin></plugins>
656                </group></optionalFileGroups>
657                </installStep></installSteps></config>"#
658            );
659            let config = ModuleConfig::parse(&xml).unwrap();
660            assert_eq!(
661                config.install_steps.as_ref().unwrap().order,
662                Some(expected),
663                "Failed for {order_str}"
664            );
665        }
666    }
667
668    // ---- Empty collections ----
669
670    #[test]
671    fn parse_empty_required_files() {
672        let xml = r#"
673            <config><moduleName>T</moduleName>
674            <requiredInstallFiles></requiredInstallFiles></config>
675        "#;
676        let config = ModuleConfig::parse(xml).unwrap();
677        assert!(config.required_install_files.as_ref().unwrap().items.is_empty());
678    }
679
680    #[test]
681    fn parse_empty_install_steps() {
682        let xml = r#"
683            <config><moduleName>T</moduleName>
684            <installSteps></installSteps></config>
685        "#;
686        let config = ModuleConfig::parse(xml).unwrap();
687        assert!(config.install_steps.as_ref().unwrap().steps.is_empty());
688    }
689
690    #[test]
691    fn parse_unicode_names() {
692        let xml = r#"
693            <config><moduleName>日本語MOD</moduleName>
694            <installSteps><installStep name="ステップ1">
695            <optionalFileGroups><group name="グループ" type="SelectAny">
696            <plugins><plugin name="プラグイン"><typeDescriptor><type name="Optional"/></typeDescriptor></plugin></plugins>
697            </group></optionalFileGroups>
698            </installStep></installSteps></config>
699        "#;
700        let config = ModuleConfig::parse(xml).unwrap();
701        assert_eq!(config.module_name.value, "日本語MOD");
702        let step = &config.install_steps.as_ref().unwrap().steps[0];
703        assert_eq!(step.name, "ステップ1");
704    }
705
706    // ---- Conditional file installs ----
707
708    #[test]
709    fn parse_conditional_file_installs() {
710        let xml = r#"
711            <config><moduleName>T</moduleName>
712            <conditionalFileInstalls><patterns>
713                <pattern>
714                    <dependencies operator="And">
715                        <flagDependency flag="f" value="v"/>
716                    </dependencies>
717                    <files><file source="a.esp" destination="Data"/></files>
718                </pattern>
719                <pattern>
720                    <dependencies operator="Or">
721                        <flagDependency flag="x" value="1"/>
722                        <flagDependency flag="y" value="2"/>
723                    </dependencies>
724                    <files>
725                        <folder source="dir" destination="Data/dir"/>
726                    </files>
727                </pattern>
728            </patterns></conditionalFileInstalls></config>
729        "#;
730        let config = ModuleConfig::parse(xml).unwrap();
731        let cfi = config.conditional_file_installs.as_ref().unwrap();
732        assert_eq!(cfi.patterns.patterns.len(), 2);
733        assert_eq!(cfi.patterns.patterns[0].files.items.len(), 1);
734        assert_eq!(cfi.patterns.patterns[1].files.items.len(), 1);
735    }
736
737    // ---- ConditionFlags ----
738
739    #[test]
740    fn parse_condition_flags() {
741        let xml = r#"
742            <config><moduleName>T</moduleName>
743            <installSteps><installStep name="S">
744            <optionalFileGroups><group name="G" type="SelectAny">
745            <plugins><plugin name="P">
746                <conditionFlags>
747                    <flag name="flag_a">value_a</flag>
748                    <flag name="flag_b">value_b</flag>
749                </conditionFlags>
750                <typeDescriptor><type name="Optional"/></typeDescriptor>
751            </plugin></plugins>
752            </group></optionalFileGroups>
753            </installStep></installSteps></config>
754        "#;
755        let config = ModuleConfig::parse(xml).unwrap();
756        let plugin = &config.install_steps.as_ref().unwrap().steps[0]
757            .optional_file_groups
758            .as_ref()
759            .unwrap()
760            .groups[0]
761            .plugins
762            .plugins[0];
763        let flags = plugin.condition_flags.as_ref().unwrap();
764        assert_eq!(flags.flags.len(), 2);
765        assert_eq!(flags.flags[0].name, "flag_a");
766        assert_eq!(flags.flags[0].value, "value_a");
767        assert_eq!(flags.flags[1].name, "flag_b");
768        assert_eq!(flags.flags[1].value, "value_b");
769    }
770}