Skip to main content

fomod_oxide/
declarative.rs

1use std::collections::HashMap;
2
3use base64::Engine;
4use base64::engine::general_purpose::STANDARD as BASE64;
5use serde::{Deserialize, Serialize};
6use sha2::{Digest, Sha256};
7
8use crate::config::ModuleConfig;
9use crate::installer::Installer;
10
11/// Current schema version for `DeclarativeConfig`.
12///
13/// Bump this when the config format changes in incompatible ways.
14pub const SCHEMA_VERSION: u32 = 1;
15
16/// Declarative FOMOD installation configuration.
17///
18/// Instead of stepping through an interactive wizard, all selections are
19/// specified upfront by name. This makes FOMOD installations reproducible
20/// and compatible with declarative systems like NixOS.
21///
22/// Uses the nixpkgs `rev` + `hash` pattern: `rev` is a human-readable
23/// version identifier and `hash` is an SRI hash of the installer XML
24/// for integrity verification.
25///
26/// ## Structure
27///
28/// ```nix
29/// {
30///   schema_version = 1;
31///   rev = "1.2.0";
32///   hash = "sha256-xbenkdP6HEXuWl9K...";
33///   selections = {
34///     "Choose Version" = {
35///       "Platform" = [ "SSE" ];
36///     };
37///   };
38/// }
39/// ```
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct DeclarativeConfig {
42    /// Schema version of this config format. See [`SCHEMA_VERSION`].
43    pub schema_version: u32,
44
45    /// Human-readable revision of the FOMOD installer (e.g. mod version).
46    ///
47    /// Sourced from `info.xml` when available, otherwise the module name.
48    pub rev: String,
49
50    /// SRI hash of the source FOMOD XML (`sha256-<base64>`).
51    ///
52    /// Follows the nixpkgs convention. Used to detect when the upstream
53    /// installer has changed and this config may need to be regenerated.
54    pub hash: String,
55
56    /// Step name → group name → list of selected plugin names.
57    pub selections: HashMap<String, HashMap<String, Vec<String>>>,
58}
59
60/// Error from applying a declarative config.
61#[derive(Debug, Clone)]
62pub enum DeclarativeError {
63    /// Config schema version is newer than this library supports.
64    UnsupportedVersion { config: u32, supported: u32 },
65    /// The installer XML hash doesn't match. The installer has been updated.
66    HashMismatch { expected: String, actual: String },
67    /// Referenced step name not found in the FOMOD config.
68    StepNotFound(String),
69    /// Referenced group name not found within the given step.
70    GroupNotFound { step: String, group: String },
71    /// Referenced plugin name not found within the given group.
72    PluginNotFound {
73        step: String,
74        group: String,
75        plugin: String,
76    },
77    /// Selection violates the group's constraints.
78    ValidationFailed {
79        step: String,
80        group: String,
81        message: String,
82    },
83}
84
85impl std::fmt::Display for DeclarativeError {
86    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87        match self {
88            DeclarativeError::UnsupportedVersion { config, supported } => write!(
89                f,
90                "config schema version {config} is not supported (this library supports up to {supported})"
91            ),
92            DeclarativeError::HashMismatch { expected, actual } => write!(
93                f,
94                "hash mismatch: config expects {expected}, got {actual}"
95            ),
96            DeclarativeError::StepNotFound(name) => {
97                write!(f, "step not found: {name:?}")
98            }
99            DeclarativeError::GroupNotFound { step, group } => {
100                write!(f, "group {group:?} not found in step {step:?}")
101            }
102            DeclarativeError::PluginNotFound {
103                step,
104                group,
105                plugin,
106            } => write!(
107                f,
108                "plugin {plugin:?} not found in group {group:?} (step {step:?})"
109            ),
110            DeclarativeError::ValidationFailed {
111                step,
112                group,
113                message,
114            } => write!(
115                f,
116                "invalid selection for group {group:?} in step {step:?}: {message}"
117            ),
118        }
119    }
120}
121
122impl std::error::Error for DeclarativeError {}
123
124/// Compute the SRI hash of FOMOD XML content, returned as `sha256-<base64>`.
125///
126/// Follows the [Subresource Integrity](https://www.w3.org/TR/SRI/) format
127/// used by nixpkgs for content-addressed integrity verification.
128pub fn hash_xml(xml: &str) -> String {
129    let digest = Sha256::digest(xml.as_bytes());
130    format!("sha256-{}", BASE64.encode(digest))
131}
132
133impl DeclarativeConfig {
134    /// Create a template config from a FOMOD module with default selections.
135    ///
136    /// - `xml`: raw XML source used to compute the SRI hash
137    /// - `rev`: human-readable version identifier (e.g. from `info.xml`)
138    /// - `config`: parsed FOMOD module configuration
139    pub fn from_defaults(xml: &str, rev: impl Into<String>, config: &ModuleConfig) -> Self {
140        let selections = Self::build_selections(config, false);
141        Self {
142            schema_version: SCHEMA_VERSION,
143            rev: rev.into(),
144            hash: hash_xml(xml),
145            selections,
146        }
147    }
148
149    /// Create a template config with all available options listed.
150    ///
151    /// Every plugin in every group is included, making it easy to see what's
152    /// available and remove what you don't want.
153    pub fn from_all(xml: &str, rev: impl Into<String>, config: &ModuleConfig) -> Self {
154        let selections = Self::build_selections(config, true);
155        Self {
156            schema_version: SCHEMA_VERSION,
157            rev: rev.into(),
158            hash: hash_xml(xml),
159            selections,
160        }
161    }
162
163    fn build_selections(
164        config: &ModuleConfig,
165        include_all: bool,
166    ) -> HashMap<String, HashMap<String, Vec<String>>> {
167        let mut selections = HashMap::new();
168
169        if let Some(ref install_steps) = config.install_steps {
170            for step in &install_steps.steps {
171                let mut group_map = HashMap::new();
172
173                if let Some(ref groups) = step.optional_file_groups {
174                    for group in &groups.groups {
175                        let plugin_names = if include_all {
176                            group
177                                .plugins
178                                .plugins
179                                .iter()
180                                .map(|p| p.name.clone())
181                                .collect()
182                        } else {
183                            Installer::default_selections(group)
184                                .into_iter()
185                                .filter_map(|idx| {
186                                    group.plugins.plugins.get(idx).map(|p| p.name.clone())
187                                })
188                                .collect()
189                        };
190                        group_map.insert(group.name.clone(), plugin_names);
191                    }
192                }
193
194                selections.insert(step.name.clone(), group_map);
195            }
196        }
197
198        selections
199    }
200
201    /// Apply this declarative config to an installer, resolving names to indices.
202    ///
203    /// `xml` is the raw FOMOD XML source, used to verify the installer hash.
204    ///
205    /// Steps not present in `selections` use default selections. Groups not
206    /// present in a step's map also use defaults. This allows partial configs
207    /// where you only override what you care about.
208    pub fn apply(&self, xml: &str, installer: &mut Installer) -> Result<(), DeclarativeError> {
209        // Verify schema version
210        if self.schema_version > SCHEMA_VERSION {
211            return Err(DeclarativeError::UnsupportedVersion {
212                config: self.schema_version,
213                supported: SCHEMA_VERSION,
214            });
215        }
216
217        // Verify SRI hash
218        let actual_hash = hash_xml(xml);
219        if self.hash != actual_hash {
220            return Err(DeclarativeError::HashMismatch {
221                expected: self.hash.clone(),
222                actual: actual_hash,
223            });
224        }
225
226        // Phase 1: resolve all names to indices and validate (immutable borrow only)
227        let resolved = self.resolve_selections(installer.config())?;
228
229        // Phase 2: apply resolved selections (mutable borrow)
230        for (step_idx, group_idx, plugin_indices) in resolved {
231            installer.select(step_idx, group_idx, plugin_indices);
232        }
233
234        Ok(())
235    }
236
237    /// Resolve declarative name-based selections to index-based selections.
238    ///
239    /// Returns a list of `(step_idx, group_idx, plugin_indices)` tuples ready
240    /// to be passed to [`Installer::select`].
241    fn resolve_selections(
242        &self,
243        config: &ModuleConfig,
244    ) -> Result<Vec<(usize, usize, Vec<usize>)>, DeclarativeError> {
245        let steps = match config.install_steps {
246            Some(ref s) => &s.steps,
247            None => return Ok(vec![]),
248        };
249
250        // Validate that all referenced names exist
251        for (step_name, groups) in &self.selections {
252            let step = steps
253                .iter()
254                .find(|s| s.name == *step_name)
255                .ok_or_else(|| DeclarativeError::StepNotFound(step_name.clone()))?;
256
257            for (group_name, plugins) in groups {
258                let group = step
259                    .optional_file_groups
260                    .as_ref()
261                    .and_then(|gl| gl.groups.iter().find(|g| g.name == *group_name))
262                    .ok_or_else(|| DeclarativeError::GroupNotFound {
263                        step: step_name.clone(),
264                        group: group_name.clone(),
265                    })?;
266
267                for plugin_name in plugins {
268                    if !group.plugins.plugins.iter().any(|p| p.name == *plugin_name) {
269                        return Err(DeclarativeError::PluginNotFound {
270                            step: step_name.clone(),
271                            group: group_name.clone(),
272                            plugin: plugin_name.clone(),
273                        });
274                    }
275                }
276            }
277        }
278
279        // Resolve names to indices
280        let mut resolved = Vec::new();
281
282        for (step_idx, step) in steps.iter().enumerate() {
283            let step_selections = self.selections.get(&step.name);
284
285            if let Some(ref groups) = step.optional_file_groups {
286                for (group_idx, group) in groups.groups.iter().enumerate() {
287                    let plugin_indices = match step_selections.and_then(|s| s.get(&group.name)) {
288                        Some(plugin_names) => {
289                            let indices: Vec<usize> = plugin_names
290                                .iter()
291                                .map(|name| {
292                                    group
293                                        .plugins
294                                        .plugins
295                                        .iter()
296                                        .position(|p| p.name == *name)
297                                        .unwrap() // Already validated above
298                                })
299                                .collect();
300
301                            Installer::validate_selection(group, &indices).map_err(|e| {
302                                DeclarativeError::ValidationFailed {
303                                    step: step.name.clone(),
304                                    group: group.name.clone(),
305                                    message: e.to_string(),
306                                }
307                            })?;
308
309                            indices
310                        }
311                        None => Installer::default_selections(group),
312                    };
313
314                    resolved.push((step_idx, group_idx, plugin_indices));
315                }
316            }
317        }
318
319        Ok(resolved)
320    }
321
322    /// Load a declarative config from a JSON string.
323    #[cfg(feature = "json")]
324    pub fn from_json(s: &str) -> Result<Self, serde_json::Error> {
325        serde_json::from_str(s)
326    }
327
328    /// Serialize this config to a JSON string.
329    #[cfg(feature = "json")]
330    pub fn to_json(&self) -> Result<String, serde_json::Error> {
331        serde_json::to_string_pretty(self)
332    }
333
334    /// Load a declarative config from a RON string.
335    #[cfg(feature = "ron")]
336    pub fn from_ron(s: &str) -> Result<Self, ron::error::SpannedError> {
337        ron::from_str(s)
338    }
339
340    /// Serialize this config to a RON string.
341    #[cfg(feature = "ron")]
342    pub fn to_ron(&self) -> Result<String, ron::Error> {
343        ron::ser::to_string_pretty(self, ron::ser::PrettyConfig::default())
344    }
345
346    /// Serialize this config to a Nix expression string via ronix.
347    #[cfg(feature = "nix")]
348    pub fn to_nix(&self) -> Result<String, ronix::Error> {
349        ronix::to_nix(self)
350    }
351
352    /// Serialize this config as a NixOS module fragment under the given attribute path.
353    #[cfg(feature = "nix")]
354    pub fn to_nix_module(&self, attr_path: &str) -> Result<String, ronix::Error> {
355        ronix::to_nix_module(self, attr_path)
356    }
357
358    /// Generate a human-readable summary of all selections.
359    pub fn summary(&self) -> Vec<SelectionSummary> {
360        let mut result = Vec::new();
361        for (step_name, groups) in &self.selections {
362            for (group_name, plugins) in groups {
363                result.push(SelectionSummary {
364                    step: step_name.clone(),
365                    group: group_name.clone(),
366                    plugins: plugins.clone(),
367                });
368            }
369        }
370        result.sort_by(|a, b| (&a.step, &a.group).cmp(&(&b.step, &b.group)));
371        result
372    }
373
374    /// Compute the differences between this config and another.
375    pub fn diff(&self, other: &DeclarativeConfig) -> Vec<SelectionDiff> {
376        let mut diffs = Vec::new();
377
378        // Collect all step+group keys
379        let mut all_keys: Vec<(String, String)> = Vec::new();
380        for (step, groups) in &self.selections {
381            for group in groups.keys() {
382                all_keys.push((step.clone(), group.clone()));
383            }
384        }
385        for (step, groups) in &other.selections {
386            for group in groups.keys() {
387                let key = (step.clone(), group.clone());
388                if !all_keys.contains(&key) {
389                    all_keys.push(key);
390                }
391            }
392        }
393        all_keys.sort();
394
395        for (step, group) in all_keys {
396            let self_plugins = self
397                .selections
398                .get(&step)
399                .and_then(|g| g.get(&group))
400                .cloned()
401                .unwrap_or_default();
402            let other_plugins = other
403                .selections
404                .get(&step)
405                .and_then(|g| g.get(&group))
406                .cloned()
407                .unwrap_or_default();
408
409            if self_plugins != other_plugins {
410                diffs.push(SelectionDiff {
411                    step,
412                    group,
413                    left: self_plugins,
414                    right: other_plugins,
415                });
416            }
417        }
418
419        diffs
420    }
421}
422
423/// A single step/group selection entry for display.
424#[derive(Debug, Clone, PartialEq, Eq)]
425pub struct SelectionSummary {
426    pub step: String,
427    pub group: String,
428    pub plugins: Vec<String>,
429}
430
431impl std::fmt::Display for SelectionSummary {
432    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
433        write!(
434            f,
435            "{} > {}: [{}]",
436            self.step,
437            self.group,
438            self.plugins.join(", ")
439        )
440    }
441}
442
443/// A difference between two declarative configs for a specific group.
444#[derive(Debug, Clone, PartialEq, Eq)]
445pub struct SelectionDiff {
446    pub step: String,
447    pub group: String,
448    pub left: Vec<String>,
449    pub right: Vec<String>,
450}
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455
456    const SIMPLE_XML: &str = r#"
457        <config>
458            <moduleName>Test</moduleName>
459            <installSteps order="Explicit">
460                <installStep name="Step1">
461                    <optionalFileGroups>
462                        <group name="Group1" type="SelectExactlyOne">
463                            <plugins>
464                                <plugin name="PluginA">
465                                    <typeDescriptor><type name="Recommended"/></typeDescriptor>
466                                    <files><file source="a.esp" destination="Data"/></files>
467                                </plugin>
468                                <plugin name="PluginB">
469                                    <typeDescriptor><type name="Optional"/></typeDescriptor>
470                                    <files><file source="b.esp" destination="Data"/></files>
471                                </plugin>
472                            </plugins>
473                        </group>
474                    </optionalFileGroups>
475                </installStep>
476            </installSteps>
477        </config>
478    "#;
479
480    // ---- hash_xml ----
481
482    #[test]
483    fn hash_xml_starts_with_sha256() {
484        let h = hash_xml("hello");
485        assert!(h.starts_with("sha256-"));
486    }
487
488    #[test]
489    fn hash_xml_deterministic() {
490        assert_eq!(hash_xml("test"), hash_xml("test"));
491    }
492
493    #[test]
494    fn hash_xml_different_inputs() {
495        assert_ne!(hash_xml("a"), hash_xml("b"));
496    }
497
498    #[test]
499    fn hash_xml_empty_string() {
500        let h = hash_xml("");
501        assert!(h.starts_with("sha256-"));
502        assert!(h.len() > 7); // sha256- + base64
503    }
504
505    #[test]
506    fn hash_xml_whitespace_matters() {
507        assert_ne!(hash_xml("<config/>"), hash_xml("<config />"));
508    }
509
510    // ---- from_defaults ----
511
512    #[test]
513    fn from_defaults_has_schema_version() {
514        let config = ModuleConfig::parse(SIMPLE_XML).unwrap();
515        let decl = DeclarativeConfig::from_defaults(SIMPLE_XML, "1.0", &config);
516        assert_eq!(decl.schema_version, SCHEMA_VERSION);
517    }
518
519    #[test]
520    fn from_defaults_has_correct_hash() {
521        let config = ModuleConfig::parse(SIMPLE_XML).unwrap();
522        let decl = DeclarativeConfig::from_defaults(SIMPLE_XML, "1.0", &config);
523        assert_eq!(decl.hash, hash_xml(SIMPLE_XML));
524    }
525
526    #[test]
527    fn from_defaults_selects_recommended() {
528        let config = ModuleConfig::parse(SIMPLE_XML).unwrap();
529        let decl = DeclarativeConfig::from_defaults(SIMPLE_XML, "1.0", &config);
530        let plugins = &decl.selections["Step1"]["Group1"];
531        assert_eq!(plugins, &vec!["PluginA".to_string()]);
532    }
533
534    #[test]
535    fn from_defaults_rev_stored() {
536        let config = ModuleConfig::parse(SIMPLE_XML).unwrap();
537        let decl = DeclarativeConfig::from_defaults(SIMPLE_XML, "myrev", &config);
538        assert_eq!(decl.rev, "myrev");
539    }
540
541    // ---- from_all ----
542
543    #[test]
544    fn from_all_includes_every_plugin() {
545        let config = ModuleConfig::parse(SIMPLE_XML).unwrap();
546        let decl = DeclarativeConfig::from_all(SIMPLE_XML, "1.0", &config);
547        let plugins = &decl.selections["Step1"]["Group1"];
548        assert_eq!(
549            plugins,
550            &vec!["PluginA".to_string(), "PluginB".to_string()]
551        );
552    }
553
554    // ---- apply ----
555
556    #[test]
557    fn apply_rejects_future_version() {
558        let config = ModuleConfig::parse(SIMPLE_XML).unwrap();
559        let mut installer = Installer::new(config);
560        let decl = DeclarativeConfig {
561            schema_version: SCHEMA_VERSION + 1,
562            rev: "".into(),
563            hash: hash_xml(SIMPLE_XML),
564            selections: HashMap::new(),
565        };
566        let err = decl.apply(SIMPLE_XML, &mut installer).unwrap_err();
567        assert!(matches!(
568            err,
569            DeclarativeError::UnsupportedVersion { .. }
570        ));
571    }
572
573    #[test]
574    fn apply_allows_current_version() {
575        let config = ModuleConfig::parse(SIMPLE_XML).unwrap();
576        let mut installer = Installer::new(config);
577        let decl = DeclarativeConfig {
578            schema_version: SCHEMA_VERSION,
579            rev: "".into(),
580            hash: hash_xml(SIMPLE_XML),
581            selections: HashMap::from([(
582                "Step1".into(),
583                HashMap::from([("Group1".into(), vec!["PluginA".into()])]),
584            )]),
585        };
586        assert!(decl.apply(SIMPLE_XML, &mut installer).is_ok());
587    }
588
589    #[test]
590    fn apply_rejects_hash_mismatch() {
591        let config = ModuleConfig::parse(SIMPLE_XML).unwrap();
592        let mut installer = Installer::new(config);
593        let decl = DeclarativeConfig {
594            schema_version: SCHEMA_VERSION,
595            rev: "".into(),
596            hash: "sha256-WRONG".into(),
597            selections: HashMap::new(),
598        };
599        let err = decl.apply(SIMPLE_XML, &mut installer).unwrap_err();
600        assert!(matches!(err, DeclarativeError::HashMismatch { .. }));
601    }
602
603    #[test]
604    fn apply_step_not_found() {
605        let config = ModuleConfig::parse(SIMPLE_XML).unwrap();
606        let mut installer = Installer::new(config);
607        let decl = DeclarativeConfig {
608            schema_version: SCHEMA_VERSION,
609            rev: "".into(),
610            hash: hash_xml(SIMPLE_XML),
611            selections: HashMap::from([("NoSuchStep".into(), HashMap::new())]),
612        };
613        let err = decl.apply(SIMPLE_XML, &mut installer).unwrap_err();
614        assert!(matches!(err, DeclarativeError::StepNotFound(_)));
615    }
616
617    #[test]
618    fn apply_group_not_found() {
619        let config = ModuleConfig::parse(SIMPLE_XML).unwrap();
620        let mut installer = Installer::new(config);
621        let decl = DeclarativeConfig {
622            schema_version: SCHEMA_VERSION,
623            rev: "".into(),
624            hash: hash_xml(SIMPLE_XML),
625            selections: HashMap::from([(
626                "Step1".into(),
627                HashMap::from([("NoSuchGroup".into(), vec![])]),
628            )]),
629        };
630        let err = decl.apply(SIMPLE_XML, &mut installer).unwrap_err();
631        assert!(matches!(err, DeclarativeError::GroupNotFound { .. }));
632    }
633
634    #[test]
635    fn apply_plugin_not_found() {
636        let config = ModuleConfig::parse(SIMPLE_XML).unwrap();
637        let mut installer = Installer::new(config);
638        let decl = DeclarativeConfig {
639            schema_version: SCHEMA_VERSION,
640            rev: "".into(),
641            hash: hash_xml(SIMPLE_XML),
642            selections: HashMap::from([(
643                "Step1".into(),
644                HashMap::from([("Group1".into(), vec!["NoSuchPlugin".into()])]),
645            )]),
646        };
647        let err = decl.apply(SIMPLE_XML, &mut installer).unwrap_err();
648        assert!(matches!(err, DeclarativeError::PluginNotFound { .. }));
649    }
650
651    #[test]
652    fn apply_validation_fails_too_many() {
653        let config = ModuleConfig::parse(SIMPLE_XML).unwrap();
654        let mut installer = Installer::new(config);
655        let decl = DeclarativeConfig {
656            schema_version: SCHEMA_VERSION,
657            rev: "".into(),
658            hash: hash_xml(SIMPLE_XML),
659            selections: HashMap::from([(
660                "Step1".into(),
661                HashMap::from([(
662                    "Group1".into(),
663                    vec!["PluginA".into(), "PluginB".into()],
664                )]),
665            )]),
666        };
667        let err = decl.apply(SIMPLE_XML, &mut installer).unwrap_err();
668        assert!(matches!(err, DeclarativeError::ValidationFailed { .. }));
669    }
670
671    #[test]
672    fn apply_empty_selections_uses_defaults() {
673        let config = ModuleConfig::parse(SIMPLE_XML).unwrap();
674        let mut installer = Installer::new(config);
675        let decl = DeclarativeConfig {
676            schema_version: SCHEMA_VERSION,
677            rev: "".into(),
678            hash: hash_xml(SIMPLE_XML),
679            selections: HashMap::new(), // empty → defaults
680        };
681        decl.apply(SIMPLE_XML, &mut installer).unwrap();
682        let plan = installer.resolve();
683        // Default is PluginA (Recommended)
684        assert!(plan.operations.iter().any(|op| op.source == "a.esp"));
685    }
686
687    #[test]
688    fn apply_no_install_steps() {
689        let xml = r#"<config><moduleName>T</moduleName></config>"#;
690        let config = ModuleConfig::parse(xml).unwrap();
691        let mut installer = Installer::new(config);
692        let decl = DeclarativeConfig {
693            schema_version: SCHEMA_VERSION,
694            rev: "".into(),
695            hash: hash_xml(xml),
696            selections: HashMap::new(),
697        };
698        assert!(decl.apply(xml, &mut installer).is_ok());
699    }
700
701    // ---- DeclarativeError Display ----
702
703    #[test]
704    fn error_display_unsupported_version() {
705        let err = DeclarativeError::UnsupportedVersion {
706            config: 99,
707            supported: 1,
708        };
709        let s = err.to_string();
710        assert!(s.contains("99"));
711        assert!(s.contains("1"));
712    }
713
714    #[test]
715    fn error_display_hash_mismatch() {
716        let err = DeclarativeError::HashMismatch {
717            expected: "sha256-AAA".into(),
718            actual: "sha256-BBB".into(),
719        };
720        let s = err.to_string();
721        assert!(s.contains("sha256-AAA"));
722        assert!(s.contains("sha256-BBB"));
723    }
724
725    #[test]
726    fn error_display_step_not_found() {
727        let err = DeclarativeError::StepNotFound("MyStep".into());
728        assert!(err.to_string().contains("MyStep"));
729    }
730
731    #[test]
732    fn error_display_group_not_found() {
733        let err = DeclarativeError::GroupNotFound {
734            step: "S".into(),
735            group: "G".into(),
736        };
737        let s = err.to_string();
738        assert!(s.contains("S"));
739        assert!(s.contains("G"));
740    }
741
742    #[test]
743    fn error_display_plugin_not_found() {
744        let err = DeclarativeError::PluginNotFound {
745            step: "S".into(),
746            group: "G".into(),
747            plugin: "P".into(),
748        };
749        let s = err.to_string();
750        assert!(s.contains("P"));
751    }
752
753    #[test]
754    fn error_display_validation_failed() {
755        let err = DeclarativeError::ValidationFailed {
756            step: "S".into(),
757            group: "G".into(),
758            message: "too many".into(),
759        };
760        assert!(err.to_string().contains("too many"));
761    }
762}