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
11pub const SCHEMA_VERSION: u32 = 1;
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct DeclarativeConfig {
42 pub schema_version: u32,
44
45 pub rev: String,
49
50 pub hash: String,
55
56 pub selections: HashMap<String, HashMap<String, Vec<String>>>,
58}
59
60#[derive(Debug, Clone)]
62pub enum DeclarativeError {
63 UnsupportedVersion { config: u32, supported: u32 },
65 HashMismatch { expected: String, actual: String },
67 StepNotFound(String),
69 GroupNotFound { step: String, group: String },
71 PluginNotFound {
73 step: String,
74 group: String,
75 plugin: String,
76 },
77 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
124pub 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 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 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 pub fn apply(&self, xml: &str, installer: &mut Installer) -> Result<(), DeclarativeError> {
209 if self.schema_version > SCHEMA_VERSION {
211 return Err(DeclarativeError::UnsupportedVersion {
212 config: self.schema_version,
213 supported: SCHEMA_VERSION,
214 });
215 }
216
217 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 let resolved = self.resolve_selections(installer.config())?;
228
229 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 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 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 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() })
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 #[cfg(feature = "json")]
324 pub fn from_json(s: &str) -> Result<Self, serde_json::Error> {
325 serde_json::from_str(s)
326 }
327
328 #[cfg(feature = "json")]
330 pub fn to_json(&self) -> Result<String, serde_json::Error> {
331 serde_json::to_string_pretty(self)
332 }
333
334 #[cfg(feature = "ron")]
336 pub fn from_ron(s: &str) -> Result<Self, ron::error::SpannedError> {
337 ron::from_str(s)
338 }
339
340 #[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 #[cfg(feature = "nix")]
348 pub fn to_nix(&self) -> Result<String, ronix::Error> {
349 ronix::to_nix(self)
350 }
351
352 #[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 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 pub fn diff(&self, other: &DeclarativeConfig) -> Vec<SelectionDiff> {
376 let mut diffs = Vec::new();
377
378 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#[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#[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 #[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); }
504
505 #[test]
506 fn hash_xml_whitespace_matters() {
507 assert_ne!(hash_xml("<config/>"), hash_xml("<config />"));
508 }
509
510 #[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 #[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 #[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(), };
681 decl.apply(SIMPLE_XML, &mut installer).unwrap();
682 let plan = installer.resolve();
683 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 #[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}