Skip to main content

fomod_oxide/
installer.rs

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/// A planned file operation: copy source to destination with priority.
11#[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/// The resolved installation plan after all user selections.
20#[derive(Debug, Clone)]
21pub struct InstallPlan {
22    /// File operations sorted by priority (lower first, higher overwrites).
23    pub operations: Vec<FileOperation>,
24}
25
26impl InstallPlan {
27    /// Execute this plan by copying files from `source` to `destination`.
28    ///
29    /// `source` is the root of the unpacked mod archive. `destination` is the
30    /// game's data directory (or staging folder). Operations are applied in
31    /// priority order — higher-priority files overwrite lower.
32    ///
33    /// Intermediate directories are created as needed. An empty operation
34    /// destination means the file keeps its source-relative path.
35    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
58/// Recursively copy a directory tree from `src` to `dst`.
59fn 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
73/// Apply `SortOrder` to items by name.
74fn 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 => {} // document order
79    }
80}
81
82/// Sort all steps, groups, and plugins in-place according to their `@order` attributes.
83fn 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
103/// Drives the FOMOD installation process.
104///
105/// Tracks user selections and condition flags, then resolves the final
106/// set of file operations.
107pub struct Installer {
108    config: ModuleConfig,
109    ctx: EvalContext,
110    /// Maps (step_index, group_index) -> set of selected plugin indices.
111    selections: HashMap<(usize, usize), Vec<usize>>,
112    /// Undo history: snapshots of (selections, flags).
113    history: Vec<SelectionSnapshot>,
114}
115
116impl Installer {
117    pub fn new(config: ModuleConfig) -> Self {
118        Self::with_context(config, EvalContext::new())
119    }
120
121    /// Create an installer with pre-populated game environment context.
122    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    /// Check module-level dependencies. Returns `true` if satisfied.
145    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    /// Get visible install steps (steps whose visibility conditions are met).
154    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    /// Record user selections for a group within a step.
173    ///
174    /// `plugin_indices` are indices into the group's plugin list.
175    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        // Collect flag updates from group plugins, then apply them.
180        // Two-phase approach avoids borrowing self.config and self.ctx simultaneously.
181        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            // Gather all flag names from every plugin in this group (to clear)
186            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            // Gather flags from selected plugins (to set)
194            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    /// Get the default selections for a group based on context-aware plugin types.
214    ///
215    /// Evaluates `dependencyType` patterns against the provided context to
216    /// determine actual plugin types at runtime.
217    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    /// Get the default selections for a group based on static plugin types.
222    ///
223    /// For `dependencyType` descriptors, uses the default type without
224    /// evaluating conditions. Suitable for template generation.
225    pub fn default_selections(group: &Group) -> Vec<usize> {
226        compute_defaults(group, |p| p.plugin_type())
227    }
228
229    /// Validate selections against group type constraints.
230    pub fn validate_selection(group: &Group, selected: &[usize]) -> Result<(), SelectionError> {
231        let count = selected.len();
232        let max = group.plugins.plugins.len();
233
234        // Check bounds
235        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    /// Resolve the final installation plan from all selections.
261    pub fn resolve(&self) -> InstallPlan {
262        InstallPlan {
263            operations: self.collect_operations(true),
264        }
265    }
266
267    // ─── Metadata Accessors ─────────────────────────────────────
268
269    /// Get the name of a step by index.
270    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    /// Get the name of a group within a step.
280    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    /// Get a plugin's description text.
285    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    /// Get a plugin's image path (relative to the mod archive root).
291    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    /// Get the module header image path.
298    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    /// Get the resolved plugin type in the current context.
307    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    /// Get the group type for a specific group.
313    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    // ─── Image Resolution ────────────────────────────────────────
318
319    /// Resolve an image path from the FOMOD XML against the mod archive root.
320    ///
321    /// Performs case-insensitive file lookup since FOMOD is Windows-centric
322    /// and paths may not match the actual filesystem case on Linux.
323    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    // ─── Install Plan Preview ────────────────────────────────────
328
329    /// Preview the file operations a specific plugin would contribute.
330    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    /// Preview the install plan based on current selections (without conditional file installs).
343    ///
344    /// This gives a "what you've chosen so far" view, useful for showing
345    /// running totals during the wizard.
346    pub fn preview_current(&self) -> InstallPlan {
347        InstallPlan {
348            operations: self.collect_operations(false),
349        }
350    }
351
352    // ─── Progress & Completion ───────────────────────────────────
353
354    /// Get overall completion status of the wizard.
355    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    /// Check if all required groups have valid selections and installation can proceed.
397    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    /// Return `(step_idx, group_idx)` pairs for groups that still need user input.
403    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    // ─── Rich Validation ─────────────────────────────────────────
425
426    /// Validate all groups in a step and return detailed hints for the UI.
427    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                // Flag NotUsable plugins that are selected
483                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    // ─── File Conflict Detection ─────────────────────────────────
500
501    /// Detect file conflicts: plugins that install to the same destination path.
502    pub fn detect_conflicts(&self) -> Vec<FileConflict> {
503        let mut dest_map: HashMap<String, Vec<FileConflictSource>> = HashMap::new();
504
505        // Check required files
506        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        // Check plugin files
520        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    // ─── Flag Impact Map ─────────────────────────────────────────
558
559    /// Build a map of which plugins set flags that affect other steps' visibility.
560    ///
561    /// Returns a list of impacts: "selecting plugin X may show/hide step Y".
562    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        // Collect all flags set by plugins
569        let mut flag_setters: Vec<(usize, usize, usize, String, String)> = Vec::new(); // step, group, plugin, flag_name, flag_value
570
571        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        // Check which steps have visibility conditions that reference these flags
592        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    // ─── Selection History / Undo ────────────────────────────────
615
616    /// Save a snapshot of current selections and context for later rollback.
617    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    /// Rollback to the most recent checkpoint. Returns `false` if no history.
625    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    /// Number of available undo checkpoints.
636    pub fn history_len(&self) -> usize {
637        self.history.len()
638    }
639
640    /// Get current selections (read-only).
641    pub fn selections(&self) -> &HashMap<(usize, usize), Vec<usize>> {
642        &self.selections
643    }
644
645    // ─── Private Helpers ─────────────────────────────────────────
646
647    fn collect_operations(&self, include_conditional: bool) -> Vec<FileOperation> {
648        let mut ops: Vec<FileOperation> = Vec::new();
649
650        // 1. Required install files (always installed)
651        if let Some(ref files) = self.config.required_install_files {
652            ops.extend(files_to_ops(files));
653        }
654
655        // 2. Files from selected plugins
656        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        // 3. Conditional file installs (patterns evaluated against final flags)
669        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// ─── Supporting Types ────────────────────────────────────────────
702
703/// Snapshot of installer state for undo support.
704#[derive(Debug, Clone)]
705struct SelectionSnapshot {
706    selections: HashMap<(usize, usize), Vec<usize>>,
707    flags: HashMap<String, String>,
708}
709
710/// Overall wizard completion status.
711#[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    /// Completion as a fraction from 0.0 to 1.0.
721    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/// Detailed validation hint for UI display.
731#[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/// A file destination that multiple sources want to write to.
789#[derive(Debug, Clone, PartialEq, Eq)]
790pub struct FileConflict {
791    pub destination: String,
792    pub sources: Vec<FileConflictSource>,
793}
794
795/// Where a conflicting file comes from.
796#[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/// A plugin's flag-setting that affects another step's visibility.
811#[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
840// ─── Free Helper Functions ───────────────────────────────────────
841
842fn 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
899/// Resolve a relative path case-insensitively against a base directory.
900fn resolve_path_case_insensitive(base: &Path, relative: &str) -> Option<std::path::PathBuf> {
901    // Normalize separators
902    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(&current).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
925/// Collect all flag names referenced by a composite dependency (recursively).
926fn 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    // ---- apply_sort_order ----
940
941    #[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    // ---- validate_selection ----
970
971    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    // ---- default_selections ----
1044
1045    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    // ---- Flag clearing ----
1122
1123    #[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    // ---- resolve ----
1153
1154    #[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    // ---- check_dependencies ----
1199
1200    #[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    // ---- visible_steps ----
1208
1209    #[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    // ---- SelectionError Display ----
1217
1218    #[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    // ---- with_context ----
1231
1232    #[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}