1use std::collections::HashMap;
2use std::path::Path;
3use std::{fs, io};
4
5use crate::condition::{EvalContext, Evaluate};
6use crate::config::{
7 FileList, Group, GroupType, InstallStep, ModuleConfig, Plugin, PluginType, SortOrder,
8};
9
10#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct FileOperation {
13 pub source: String,
14 pub destination: String,
15 pub is_folder: bool,
16 pub priority: i32,
17}
18
19#[derive(Debug, Clone)]
21pub struct InstallPlan {
22 pub operations: Vec<FileOperation>,
24}
25
26impl InstallPlan {
27 pub fn execute(&self, source: &Path, destination: &Path) -> io::Result<()> {
36 for op in &self.operations {
37 let src = source.join(&op.source);
38 let dst_rel = if op.destination.is_empty() {
39 &op.source
40 } else {
41 &op.destination
42 };
43 let dst = destination.join(dst_rel);
44
45 if op.is_folder {
46 copy_dir_recursive(&src, &dst)?;
47 } else {
48 if let Some(parent) = dst.parent() {
49 fs::create_dir_all(parent)?;
50 }
51 fs::copy(&src, &dst)?;
52 }
53 }
54 Ok(())
55 }
56}
57
58fn copy_dir_recursive(src: &Path, dst: &Path) -> io::Result<()> {
60 fs::create_dir_all(dst)?;
61 for entry in fs::read_dir(src)? {
62 let entry = entry?;
63 let entry_dst = dst.join(entry.file_name());
64 if entry.file_type()?.is_dir() {
65 copy_dir_recursive(&entry.path(), &entry_dst)?;
66 } else {
67 fs::copy(entry.path(), &entry_dst)?;
68 }
69 }
70 Ok(())
71}
72
73fn apply_sort_order<T>(items: &mut [T], order: Option<SortOrder>, name_fn: impl Fn(&T) -> &str) {
75 match order {
76 Some(SortOrder::Ascending) => items.sort_by(|a, b| name_fn(a).cmp(name_fn(b))),
77 Some(SortOrder::Descending) => items.sort_by(|a, b| name_fn(b).cmp(name_fn(a))),
78 Some(SortOrder::Explicit) | None => {} }
80}
81
82fn apply_sort_orders(config: &mut ModuleConfig) {
84 if let Some(ref mut install_steps) = config.install_steps {
85 apply_sort_order(&mut install_steps.steps, install_steps.order, |s| &s.name);
86
87 for step in &mut install_steps.steps {
88 if let Some(ref mut groups) = step.optional_file_groups {
89 apply_sort_order(&mut groups.groups, groups.order, |g| &g.name);
90
91 for group in &mut groups.groups {
92 apply_sort_order(
93 &mut group.plugins.plugins,
94 group.plugins.order,
95 |p| &p.name,
96 );
97 }
98 }
99 }
100 }
101}
102
103pub struct Installer {
108 config: ModuleConfig,
109 ctx: EvalContext,
110 selections: HashMap<(usize, usize), Vec<usize>>,
112 history: Vec<SelectionSnapshot>,
114}
115
116impl Installer {
117 pub fn new(config: ModuleConfig) -> Self {
118 Self::with_context(config, EvalContext::new())
119 }
120
121 pub fn with_context(mut config: ModuleConfig, ctx: EvalContext) -> Self {
123 apply_sort_orders(&mut config);
124 Self {
125 config,
126 ctx,
127 selections: HashMap::new(),
128 history: Vec::new(),
129 }
130 }
131
132 pub fn context(&self) -> &EvalContext {
133 &self.ctx
134 }
135
136 pub fn context_mut(&mut self) -> &mut EvalContext {
137 &mut self.ctx
138 }
139
140 pub fn config(&self) -> &ModuleConfig {
141 &self.config
142 }
143
144 pub fn check_dependencies(&self) -> bool {
146 self.config
147 .module_dependencies
148 .as_ref()
149 .map(|d| d.evaluate(&self.ctx))
150 .unwrap_or(true)
151 }
152
153 pub fn visible_steps(&self) -> Vec<(usize, &InstallStep)> {
155 let steps = match self.config.install_steps {
156 Some(ref s) => &s.steps,
157 None => return vec![],
158 };
159
160 steps
161 .iter()
162 .enumerate()
163 .filter(|(_, step)| {
164 step.visible
165 .as_ref()
166 .map(|v| v.evaluate(&self.ctx))
167 .unwrap_or(true)
168 })
169 .collect()
170 }
171
172 pub fn select(&mut self, step_index: usize, group_index: usize, plugin_indices: Vec<usize>) {
176 self.selections
177 .insert((step_index, group_index), plugin_indices.clone());
178
179 let mut flags_to_clear: Vec<String> = Vec::new();
182 let mut flags_to_set: Vec<(String, String)> = Vec::new();
183
184 if let Some(group) = self.get_group(step_index, group_index) {
185 for plugin in &group.plugins.plugins {
187 if let Some(ref flags) = plugin.condition_flags {
188 for flag in &flags.flags {
189 flags_to_clear.push(flag.name.clone());
190 }
191 }
192 }
193 for &idx in &plugin_indices {
195 if let Some(plugin) = group.plugins.plugins.get(idx) {
196 if let Some(ref flags) = plugin.condition_flags {
197 for flag in &flags.flags {
198 flags_to_set.push((flag.name.clone(), flag.value.clone()));
199 }
200 }
201 }
202 }
203 }
204
205 for name in flags_to_clear {
206 self.ctx.flags.remove(&name);
207 }
208 for (name, value) in flags_to_set {
209 self.ctx.set_flag(name, value);
210 }
211 }
212
213 pub fn default_selections_in_context(group: &Group, ctx: &EvalContext) -> Vec<usize> {
218 compute_defaults(group, |p| p.plugin_type_in_context(ctx))
219 }
220
221 pub fn default_selections(group: &Group) -> Vec<usize> {
226 compute_defaults(group, |p| p.plugin_type())
227 }
228
229 pub fn validate_selection(group: &Group, selected: &[usize]) -> Result<(), SelectionError> {
231 let count = selected.len();
232 let max = group.plugins.plugins.len();
233
234 if selected.iter().any(|&i| i >= max) {
236 return Err(SelectionError::OutOfBounds);
237 }
238
239 match group.group_type {
240 GroupType::SelectExactlyOne if count != 1 => Err(SelectionError::InvalidCount {
241 expected: "exactly 1",
242 got: count,
243 }),
244 GroupType::SelectAtMostOne if count > 1 => Err(SelectionError::InvalidCount {
245 expected: "at most 1",
246 got: count,
247 }),
248 GroupType::SelectAtLeastOne if count < 1 => Err(SelectionError::InvalidCount {
249 expected: "at least 1",
250 got: count,
251 }),
252 GroupType::SelectAll if count != max => Err(SelectionError::InvalidCount {
253 expected: "all",
254 got: count,
255 }),
256 _ => Ok(()),
257 }
258 }
259
260 pub fn resolve(&self) -> InstallPlan {
262 InstallPlan {
263 operations: self.collect_operations(true),
264 }
265 }
266
267 pub fn step_name(&self, step: usize) -> Option<&str> {
271 self.config
272 .install_steps
273 .as_ref()?
274 .steps
275 .get(step)
276 .map(|s| s.name.as_str())
277 }
278
279 pub fn group_name(&self, step: usize, group: usize) -> Option<&str> {
281 self.get_group(step, group).map(|g| g.name.as_str())
282 }
283
284 pub fn plugin_description(&self, step: usize, group: usize, plugin: usize) -> Option<&str> {
286 self.get_plugin(step, group, plugin)
287 .and_then(|p| p.description.as_deref())
288 }
289
290 pub fn plugin_image_path(&self, step: usize, group: usize, plugin: usize) -> Option<&str> {
292 self.get_plugin(step, group, plugin)
293 .and_then(|p| p.image.as_ref())
294 .map(|img| img.path.as_str())
295 }
296
297 pub fn module_image_path(&self) -> Option<&str> {
299 self.config
300 .module_image
301 .as_ref()
302 .filter(|img| img.show_image)
303 .map(|img| img.path.as_str())
304 }
305
306 pub fn plugin_type_at(&self, step: usize, group: usize, plugin: usize) -> Option<PluginType> {
308 self.get_plugin(step, group, plugin)
309 .map(|p| p.plugin_type_in_context(&self.ctx))
310 }
311
312 pub fn group_type_at(&self, step: usize, group: usize) -> Option<GroupType> {
314 self.get_group(step, group).map(|g| g.group_type)
315 }
316
317 pub fn resolve_image(&self, base_path: &Path, image_path: &str) -> Option<std::path::PathBuf> {
324 resolve_path_case_insensitive(base_path, image_path)
325 }
326
327 pub fn preview_plugin(
331 &self,
332 step: usize,
333 group: usize,
334 plugin: usize,
335 ) -> Vec<FileOperation> {
336 self.get_plugin(step, group, plugin)
337 .and_then(|p| p.files.as_ref())
338 .map(|files| files_to_ops(files))
339 .unwrap_or_default()
340 }
341
342 pub fn preview_current(&self) -> InstallPlan {
347 InstallPlan {
348 operations: self.collect_operations(false),
349 }
350 }
351
352 pub fn completion_status(&self) -> CompletionStatus {
356 let steps = match self.config.install_steps {
357 Some(ref s) => &s.steps,
358 None => {
359 return CompletionStatus {
360 total_steps: 0,
361 visible_steps: 0,
362 total_groups: 0,
363 satisfied_groups: 0,
364 }
365 }
366 };
367
368 let visible = self.visible_steps();
369 let mut total_groups = 0;
370 let mut satisfied_groups = 0;
371
372 for &(step_idx, step) in &visible {
373 if let Some(ref groups) = step.optional_file_groups {
374 for (group_idx, group) in groups.groups.iter().enumerate() {
375 total_groups += 1;
376 let sel = self
377 .selections
378 .get(&(step_idx, group_idx))
379 .cloned()
380 .unwrap_or_default();
381 if Self::validate_selection(group, &sel).is_ok() {
382 satisfied_groups += 1;
383 }
384 }
385 }
386 }
387
388 CompletionStatus {
389 total_steps: steps.len(),
390 visible_steps: visible.len(),
391 total_groups,
392 satisfied_groups,
393 }
394 }
395
396 pub fn is_ready_to_install(&self) -> bool {
398 let status = self.completion_status();
399 status.total_groups > 0 && status.satisfied_groups == status.total_groups
400 }
401
402 pub fn missing_selections(&self) -> Vec<(usize, usize)> {
404 let mut missing = Vec::new();
405
406 for &(step_idx, step) in &self.visible_steps() {
407 if let Some(ref groups) = step.optional_file_groups {
408 for (group_idx, group) in groups.groups.iter().enumerate() {
409 let sel = self
410 .selections
411 .get(&(step_idx, group_idx))
412 .cloned()
413 .unwrap_or_default();
414 if Self::validate_selection(group, &sel).is_err() {
415 missing.push((step_idx, group_idx));
416 }
417 }
418 }
419 }
420
421 missing
422 }
423
424 pub fn validate_step(&self, step_index: usize) -> Vec<ValidationHint> {
428 let step = match self
429 .config
430 .install_steps
431 .as_ref()
432 .and_then(|s| s.steps.get(step_index))
433 {
434 Some(s) => s,
435 None => return vec![],
436 };
437
438 let mut hints = Vec::new();
439 if let Some(ref groups) = step.optional_file_groups {
440 for (group_idx, group) in groups.groups.iter().enumerate() {
441 let sel = self
442 .selections
443 .get(&(step_index, group_idx))
444 .cloned()
445 .unwrap_or_default();
446
447 let count = sel.len();
448 let max = group.plugins.plugins.len();
449
450 match group.group_type {
451 GroupType::SelectExactlyOne if count != 1 => {
452 hints.push(ValidationHint::NeedExactly {
453 group: group.name.clone(),
454 required: 1,
455 current: count,
456 });
457 }
458 GroupType::SelectAtMostOne if count > 1 => {
459 hints.push(ValidationHint::ExceedsMax {
460 group: group.name.clone(),
461 max: 1,
462 current: count,
463 });
464 }
465 GroupType::SelectAtLeastOne if count < 1 => {
466 hints.push(ValidationHint::NeedAtLeast {
467 group: group.name.clone(),
468 required: 1,
469 current: count,
470 });
471 }
472 GroupType::SelectAll if count != max => {
473 hints.push(ValidationHint::NeedExactly {
474 group: group.name.clone(),
475 required: max,
476 current: count,
477 });
478 }
479 _ => {}
480 }
481
482 for &idx in &sel {
484 if let Some(plugin) = group.plugins.plugins.get(idx) {
485 if plugin.plugin_type_in_context(&self.ctx) == PluginType::NotUsable {
486 hints.push(ValidationHint::NotUsableSelected {
487 group: group.name.clone(),
488 plugin: plugin.name.clone(),
489 });
490 }
491 }
492 }
493 }
494 }
495
496 hints
497 }
498
499 pub fn detect_conflicts(&self) -> Vec<FileConflict> {
503 let mut dest_map: HashMap<String, Vec<FileConflictSource>> = HashMap::new();
504
505 if let Some(ref files) = self.config.required_install_files {
507 for item in &files.items {
508 let r = item.file_ref();
509 let dest = normalize_dest(&r.source, &r.destination);
510 dest_map
511 .entry(dest)
512 .or_default()
513 .push(FileConflictSource::Required {
514 source: r.source.clone(),
515 });
516 }
517 }
518
519 if let Some(ref install_steps) = self.config.install_steps {
521 for (step_idx, step) in install_steps.steps.iter().enumerate() {
522 if let Some(ref groups) = step.optional_file_groups {
523 for (group_idx, group) in groups.groups.iter().enumerate() {
524 for (plugin_idx, plugin) in group.plugins.plugins.iter().enumerate() {
525 if let Some(ref files) = plugin.files {
526 for item in &files.items {
527 let r = item.file_ref();
528 let dest = normalize_dest(&r.source, &r.destination);
529 dest_map
530 .entry(dest)
531 .or_default()
532 .push(FileConflictSource::Plugin {
533 step: step_idx,
534 group: group_idx,
535 plugin: plugin_idx,
536 plugin_name: plugin.name.clone(),
537 source: r.source.clone(),
538 });
539 }
540 }
541 }
542 }
543 }
544 }
545 }
546
547 dest_map
548 .into_iter()
549 .filter(|(_, sources)| sources.len() > 1)
550 .map(|(destination, sources)| FileConflict {
551 destination,
552 sources,
553 })
554 .collect()
555 }
556
557 pub fn flag_impact_map(&self) -> Vec<FlagImpact> {
563 let steps = match self.config.install_steps {
564 Some(ref s) => &s.steps,
565 None => return vec![],
566 };
567
568 let mut flag_setters: Vec<(usize, usize, usize, String, String)> = Vec::new(); for (step_idx, step) in steps.iter().enumerate() {
572 if let Some(ref groups) = step.optional_file_groups {
573 for (group_idx, group) in groups.groups.iter().enumerate() {
574 for (plugin_idx, plugin) in group.plugins.plugins.iter().enumerate() {
575 if let Some(ref flags) = plugin.condition_flags {
576 for flag in &flags.flags {
577 flag_setters.push((
578 step_idx,
579 group_idx,
580 plugin_idx,
581 flag.name.clone(),
582 flag.value.clone(),
583 ));
584 }
585 }
586 }
587 }
588 }
589 }
590
591 let mut impacts = Vec::new();
593 for (step_idx, step) in steps.iter().enumerate() {
594 if let Some(ref vis) = step.visible {
595 let referenced_flags = collect_flag_names(vis);
596 for (src_step, src_group, src_plugin, flag_name, _) in &flag_setters {
597 if referenced_flags.contains(flag_name) {
598 impacts.push(FlagImpact {
599 source_step: *src_step,
600 source_group: *src_group,
601 source_plugin: *src_plugin,
602 flag_name: flag_name.clone(),
603 affected_step: step_idx,
604 affected_step_name: step.name.clone(),
605 });
606 }
607 }
608 }
609 }
610
611 impacts
612 }
613
614 pub fn checkpoint(&mut self) {
618 self.history.push(SelectionSnapshot {
619 selections: self.selections.clone(),
620 flags: self.ctx.flags.clone(),
621 });
622 }
623
624 pub fn rollback(&mut self) -> bool {
626 if let Some(snapshot) = self.history.pop() {
627 self.selections = snapshot.selections;
628 self.ctx.flags = snapshot.flags;
629 true
630 } else {
631 false
632 }
633 }
634
635 pub fn history_len(&self) -> usize {
637 self.history.len()
638 }
639
640 pub fn selections(&self) -> &HashMap<(usize, usize), Vec<usize>> {
642 &self.selections
643 }
644
645 fn collect_operations(&self, include_conditional: bool) -> Vec<FileOperation> {
648 let mut ops: Vec<FileOperation> = Vec::new();
649
650 if let Some(ref files) = self.config.required_install_files {
652 ops.extend(files_to_ops(files));
653 }
654
655 for (&(step_idx, group_idx), selected) in &self.selections {
657 if let Some(group) = self.get_group(step_idx, group_idx) {
658 for &plugin_idx in selected {
659 if let Some(plugin) = group.plugins.plugins.get(plugin_idx) {
660 if let Some(ref files) = plugin.files {
661 ops.extend(files_to_ops(files));
662 }
663 }
664 }
665 }
666 }
667
668 if include_conditional {
670 if let Some(ref cfi) = self.config.conditional_file_installs {
671 for pattern in &cfi.patterns.patterns {
672 if pattern.dependencies.evaluate(&self.ctx) {
673 ops.extend(files_to_ops(&pattern.files));
674 }
675 }
676 }
677 }
678
679 ops.sort_by_key(|op| op.priority);
680 ops
681 }
682
683 fn get_group(&self, step_index: usize, group_index: usize) -> Option<&Group> {
684 self.config
685 .install_steps
686 .as_ref()?
687 .steps
688 .get(step_index)?
689 .optional_file_groups
690 .as_ref()?
691 .groups
692 .get(group_index)
693 }
694
695 fn get_plugin(&self, step: usize, group: usize, plugin: usize) -> Option<&Plugin> {
696 self.get_group(step, group)
697 .and_then(|g| g.plugins.plugins.get(plugin))
698 }
699}
700
701#[derive(Debug, Clone)]
705struct SelectionSnapshot {
706 selections: HashMap<(usize, usize), Vec<usize>>,
707 flags: HashMap<String, String>,
708}
709
710#[derive(Debug, Clone, PartialEq, Eq)]
712pub struct CompletionStatus {
713 pub total_steps: usize,
714 pub visible_steps: usize,
715 pub total_groups: usize,
716 pub satisfied_groups: usize,
717}
718
719impl CompletionStatus {
720 pub fn fraction(&self) -> f32 {
722 if self.total_groups == 0 {
723 1.0
724 } else {
725 self.satisfied_groups as f32 / self.total_groups as f32
726 }
727 }
728}
729
730#[derive(Debug, Clone, PartialEq, Eq)]
732pub enum ValidationHint {
733 NeedExactly {
734 group: String,
735 required: usize,
736 current: usize,
737 },
738 NeedAtLeast {
739 group: String,
740 required: usize,
741 current: usize,
742 },
743 ExceedsMax {
744 group: String,
745 max: usize,
746 current: usize,
747 },
748 NotUsableSelected {
749 group: String,
750 plugin: String,
751 },
752}
753
754impl std::fmt::Display for ValidationHint {
755 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
756 match self {
757 ValidationHint::NeedExactly {
758 group,
759 required,
760 current,
761 } => write!(
762 f,
763 "{group}: need exactly {required}, have {current} selected"
764 ),
765 ValidationHint::NeedAtLeast {
766 group,
767 required,
768 current,
769 } => write!(
770 f,
771 "{group}: need at least {required}, have {current} selected"
772 ),
773 ValidationHint::ExceedsMax {
774 group,
775 max,
776 current,
777 } => write!(
778 f,
779 "{group}: at most {max} allowed, have {current} selected"
780 ),
781 ValidationHint::NotUsableSelected { group, plugin } => {
782 write!(f, "{group}: \"{plugin}\" is marked as not usable")
783 }
784 }
785 }
786}
787
788#[derive(Debug, Clone, PartialEq, Eq)]
790pub struct FileConflict {
791 pub destination: String,
792 pub sources: Vec<FileConflictSource>,
793}
794
795#[derive(Debug, Clone, PartialEq, Eq)]
797pub enum FileConflictSource {
798 Required {
799 source: String,
800 },
801 Plugin {
802 step: usize,
803 group: usize,
804 plugin: usize,
805 plugin_name: String,
806 source: String,
807 },
808}
809
810#[derive(Debug, Clone, PartialEq, Eq)]
812pub struct FlagImpact {
813 pub source_step: usize,
814 pub source_group: usize,
815 pub source_plugin: usize,
816 pub flag_name: String,
817 pub affected_step: usize,
818 pub affected_step_name: String,
819}
820
821#[derive(Debug, Clone, PartialEq, Eq)]
822pub enum SelectionError {
823 OutOfBounds,
824 InvalidCount { expected: &'static str, got: usize },
825}
826
827impl std::fmt::Display for SelectionError {
828 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
829 match self {
830 SelectionError::OutOfBounds => write!(f, "plugin index out of bounds"),
831 SelectionError::InvalidCount { expected, got } => {
832 write!(f, "expected {expected} selections, got {got}")
833 }
834 }
835 }
836}
837
838impl std::error::Error for SelectionError {}
839
840fn compute_defaults(group: &Group, type_fn: impl Fn(&Plugin) -> PluginType) -> Vec<usize> {
843 match group.group_type {
844 GroupType::SelectAll => (0..group.plugins.plugins.len()).collect(),
845 GroupType::SelectExactlyOne | GroupType::SelectAtMostOne => {
846 group
847 .plugins
848 .plugins
849 .iter()
850 .position(|p| {
851 matches!(
852 type_fn(p),
853 PluginType::Required | PluginType::Recommended
854 )
855 })
856 .map(|i| vec![i])
857 .unwrap_or_default()
858 }
859 GroupType::SelectAtLeastOne | GroupType::SelectAny => group
860 .plugins
861 .plugins
862 .iter()
863 .enumerate()
864 .filter(|(_, p)| {
865 matches!(
866 type_fn(p),
867 PluginType::Required | PluginType::Recommended
868 )
869 })
870 .map(|(i, _)| i)
871 .collect(),
872 }
873}
874
875fn files_to_ops(files: &FileList) -> Vec<FileOperation> {
876 files
877 .items
878 .iter()
879 .map(|item| {
880 let r = item.file_ref();
881 FileOperation {
882 source: r.source.clone(),
883 destination: r.destination.clone(),
884 is_folder: item.is_folder(),
885 priority: r.priority,
886 }
887 })
888 .collect()
889}
890
891fn normalize_dest(source: &str, destination: &str) -> String {
892 if destination.is_empty() {
893 source.to_lowercase()
894 } else {
895 destination.to_lowercase()
896 }
897}
898
899fn resolve_path_case_insensitive(base: &Path, relative: &str) -> Option<std::path::PathBuf> {
901 let parts: Vec<&str> = relative.split(['/', '\\']).filter(|s| !s.is_empty()).collect();
903 let mut current = base.to_path_buf();
904
905 for part in parts {
906 let entries = fs::read_dir(¤t).ok()?;
907 let mut found = false;
908 for entry in entries.flatten() {
909 if let Some(name) = entry.file_name().to_str() {
910 if name.eq_ignore_ascii_case(part) {
911 current = entry.path();
912 found = true;
913 break;
914 }
915 }
916 }
917 if !found {
918 return None;
919 }
920 }
921
922 Some(current)
923}
924
925fn collect_flag_names(dep: &crate::condition::CompositeDependency) -> Vec<String> {
927 let mut names: Vec<String> = dep.flag_deps.iter().map(|f| f.flag.clone()).collect();
928 for nested in &dep.nested {
929 names.extend(collect_flag_names(nested));
930 }
931 names
932}
933
934#[cfg(test)]
935mod tests {
936 use super::*;
937 use crate::config::{GroupType, ModuleConfig, PluginType};
938
939 #[test]
942 fn sort_order_ascending_sorts() {
943 let mut items = vec!["Zebra", "Apple", "Mango"];
944 apply_sort_order(&mut items, Some(SortOrder::Ascending), |s| s);
945 assert_eq!(items, vec!["Apple", "Mango", "Zebra"]);
946 }
947
948 #[test]
949 fn sort_order_descending_sorts() {
950 let mut items = vec!["Apple", "Mango", "Zebra"];
951 apply_sort_order(&mut items, Some(SortOrder::Descending), |s| s);
952 assert_eq!(items, vec!["Zebra", "Mango", "Apple"]);
953 }
954
955 #[test]
956 fn sort_order_explicit_preserves() {
957 let mut items = vec!["B", "A", "C"];
958 apply_sort_order(&mut items, Some(SortOrder::Explicit), |s| s);
959 assert_eq!(items, vec!["B", "A", "C"]);
960 }
961
962 #[test]
963 fn sort_order_none_preserves() {
964 let mut items = vec!["B", "A", "C"];
965 apply_sort_order(&mut items, None, |s| s);
966 assert_eq!(items, vec!["B", "A", "C"]);
967 }
968
969 fn make_group(gtype: GroupType, count: usize) -> Group {
972 let plugins: Vec<_> = (0..count)
973 .map(|i| crate::config::Plugin {
974 name: format!("P{i}"),
975 description: None,
976 image: None,
977 type_descriptor: None,
978 condition_flags: None,
979 files: None,
980 })
981 .collect();
982 Group {
983 name: "G".into(),
984 group_type: gtype,
985 plugins: crate::config::PluginList {
986 order: None,
987 plugins,
988 },
989 }
990 }
991
992 #[test]
993 fn validate_exactly_one() {
994 let g = make_group(GroupType::SelectExactlyOne, 3);
995 assert!(Installer::validate_selection(&g, &[0]).is_ok());
996 assert!(Installer::validate_selection(&g, &[]).is_err());
997 assert!(Installer::validate_selection(&g, &[0, 1]).is_err());
998 }
999
1000 #[test]
1001 fn validate_at_most_one() {
1002 let g = make_group(GroupType::SelectAtMostOne, 3);
1003 assert!(Installer::validate_selection(&g, &[]).is_ok());
1004 assert!(Installer::validate_selection(&g, &[1]).is_ok());
1005 assert!(Installer::validate_selection(&g, &[0, 1]).is_err());
1006 }
1007
1008 #[test]
1009 fn validate_at_least_one() {
1010 let g = make_group(GroupType::SelectAtLeastOne, 3);
1011 assert!(Installer::validate_selection(&g, &[]).is_err());
1012 assert!(Installer::validate_selection(&g, &[0]).is_ok());
1013 assert!(Installer::validate_selection(&g, &[0, 1, 2]).is_ok());
1014 }
1015
1016 #[test]
1017 fn validate_select_all() {
1018 let g = make_group(GroupType::SelectAll, 2);
1019 assert!(Installer::validate_selection(&g, &[0]).is_err());
1020 assert!(Installer::validate_selection(&g, &[0, 1]).is_ok());
1021 }
1022
1023 #[test]
1024 fn validate_select_any() {
1025 let g = make_group(GroupType::SelectAny, 3);
1026 assert!(Installer::validate_selection(&g, &[]).is_ok());
1027 assert!(Installer::validate_selection(&g, &[0, 1, 2]).is_ok());
1028 }
1029
1030 #[test]
1031 fn validate_out_of_bounds() {
1032 let g = make_group(GroupType::SelectAny, 2);
1033 assert_eq!(
1034 Installer::validate_selection(&g, &[2]),
1035 Err(SelectionError::OutOfBounds)
1036 );
1037 assert_eq!(
1038 Installer::validate_selection(&g, &[99]),
1039 Err(SelectionError::OutOfBounds)
1040 );
1041 }
1042
1043 fn make_group_typed(gtype: GroupType, types: Vec<PluginType>) -> Group {
1046 let plugins: Vec<_> = types
1047 .into_iter()
1048 .enumerate()
1049 .map(|(i, pt)| crate::config::Plugin {
1050 name: format!("P{i}"),
1051 description: None,
1052 image: None,
1053 type_descriptor: Some(crate::config::TypeDescriptor {
1054 simple_type: Some(crate::config::SimpleType { name: pt }),
1055 dependency_type: None,
1056 }),
1057 condition_flags: None,
1058 files: None,
1059 })
1060 .collect();
1061 Group {
1062 name: "G".into(),
1063 group_type: gtype,
1064 plugins: crate::config::PluginList {
1065 order: None,
1066 plugins,
1067 },
1068 }
1069 }
1070
1071 #[test]
1072 fn defaults_exactly_one_picks_first_required() {
1073 let g = make_group_typed(
1074 GroupType::SelectExactlyOne,
1075 vec![PluginType::Optional, PluginType::Required, PluginType::Required],
1076 );
1077 assert_eq!(Installer::default_selections(&g), vec![1]);
1078 }
1079
1080 #[test]
1081 fn defaults_exactly_one_picks_recommended() {
1082 let g = make_group_typed(
1083 GroupType::SelectExactlyOne,
1084 vec![PluginType::Optional, PluginType::Recommended],
1085 );
1086 assert_eq!(Installer::default_selections(&g), vec![1]);
1087 }
1088
1089 #[test]
1090 fn defaults_exactly_one_all_optional_empty() {
1091 let g = make_group_typed(
1092 GroupType::SelectExactlyOne,
1093 vec![PluginType::Optional, PluginType::Optional],
1094 );
1095 assert!(Installer::default_selections(&g).is_empty());
1096 }
1097
1098 #[test]
1099 fn defaults_select_all_returns_all() {
1100 let g = make_group_typed(
1101 GroupType::SelectAll,
1102 vec![PluginType::Optional, PluginType::Optional, PluginType::Optional],
1103 );
1104 assert_eq!(Installer::default_selections(&g), vec![0, 1, 2]);
1105 }
1106
1107 #[test]
1108 fn defaults_any_picks_required_and_recommended() {
1109 let g = make_group_typed(
1110 GroupType::SelectAny,
1111 vec![
1112 PluginType::Optional,
1113 PluginType::Required,
1114 PluginType::Optional,
1115 PluginType::Recommended,
1116 ],
1117 );
1118 assert_eq!(Installer::default_selections(&g), vec![1, 3]);
1119 }
1120
1121 #[test]
1124 fn select_clears_group_flags() {
1125 let xml = r#"
1126 <config><moduleName>T</moduleName>
1127 <installSteps><installStep name="S">
1128 <optionalFileGroups><group name="G" type="SelectExactlyOne">
1129 <plugins>
1130 <plugin name="A">
1131 <conditionFlags><flag name="choice">a</flag></conditionFlags>
1132 <typeDescriptor><type name="Optional"/></typeDescriptor>
1133 </plugin>
1134 <plugin name="B">
1135 <conditionFlags><flag name="choice">b</flag></conditionFlags>
1136 <typeDescriptor><type name="Optional"/></typeDescriptor>
1137 </plugin>
1138 </plugins>
1139 </group></optionalFileGroups>
1140 </installStep></installSteps></config>
1141 "#;
1142 let config = ModuleConfig::parse(xml).unwrap();
1143 let mut installer = Installer::new(config);
1144
1145 installer.select(0, 0, vec![0]);
1146 assert_eq!(installer.context().flags.get("choice"), Some(&"a".to_string()));
1147
1148 installer.select(0, 0, vec![1]);
1149 assert_eq!(installer.context().flags.get("choice"), Some(&"b".to_string()));
1150 }
1151
1152 #[test]
1155 fn resolve_empty_no_config() {
1156 let xml = r#"<config><moduleName>T</moduleName></config>"#;
1157 let config = ModuleConfig::parse(xml).unwrap();
1158 let installer = Installer::new(config);
1159 assert!(installer.resolve().operations.is_empty());
1160 }
1161
1162 #[test]
1163 fn resolve_priority_ordering() {
1164 let xml = r#"
1165 <config><moduleName>T</moduleName>
1166 <requiredInstallFiles>
1167 <file source="low.esp" destination="Data" priority="-10"/>
1168 <file source="high.esp" destination="Data" priority="100"/>
1169 <file source="mid.esp" destination="Data" priority="50"/>
1170 </requiredInstallFiles></config>
1171 "#;
1172 let config = ModuleConfig::parse(xml).unwrap();
1173 let installer = Installer::new(config);
1174 let plan = installer.resolve();
1175 let sources: Vec<&str> = plan.operations.iter().map(|op| op.source.as_str()).collect();
1176 assert_eq!(sources, vec!["low.esp", "mid.esp", "high.esp"]);
1177 }
1178
1179 #[test]
1180 fn resolve_skips_invalid_selection() {
1181 let xml = r#"
1182 <config><moduleName>T</moduleName>
1183 <installSteps><installStep name="S">
1184 <optionalFileGroups><group name="G" type="SelectAny">
1185 <plugins><plugin name="A">
1186 <typeDescriptor><type name="Optional"/></typeDescriptor>
1187 <files><file source="a.esp" destination="Data"/></files>
1188 </plugin></plugins>
1189 </group></optionalFileGroups>
1190 </installStep></installSteps></config>
1191 "#;
1192 let config = ModuleConfig::parse(xml).unwrap();
1193 let mut installer = Installer::new(config);
1194 installer.select(0, 0, vec![99]);
1195 assert!(installer.resolve().operations.is_empty());
1196 }
1197
1198 #[test]
1201 fn check_deps_none_means_ok() {
1202 let xml = r#"<config><moduleName>T</moduleName></config>"#;
1203 let config = ModuleConfig::parse(xml).unwrap();
1204 assert!(Installer::new(config).check_dependencies());
1205 }
1206
1207 #[test]
1210 fn visible_steps_empty_when_no_steps() {
1211 let xml = r#"<config><moduleName>T</moduleName></config>"#;
1212 let config = ModuleConfig::parse(xml).unwrap();
1213 assert!(Installer::new(config).visible_steps().is_empty());
1214 }
1215
1216 #[test]
1219 fn selection_error_display() {
1220 assert_eq!(
1221 SelectionError::OutOfBounds.to_string(),
1222 "plugin index out of bounds"
1223 );
1224 assert_eq!(
1225 SelectionError::InvalidCount { expected: "exactly 1", got: 3 }.to_string(),
1226 "expected exactly 1 selections, got 3"
1227 );
1228 }
1229
1230 #[test]
1233 fn with_context_preserves() {
1234 let xml = r#"<config><moduleName>T</moduleName></config>"#;
1235 let config = ModuleConfig::parse(xml).unwrap();
1236 let mut ctx = EvalContext::new();
1237 ctx.set_flag("pre", "val");
1238 ctx.game_version = Some("1.5".into());
1239
1240 let installer = Installer::with_context(config, ctx);
1241 assert_eq!(installer.context().flags.get("pre"), Some(&"val".to_string()));
1242 assert_eq!(installer.context().game_version, Some("1.5".to_string()));
1243 }
1244}