Skip to main content

modde_core/
collision.rs

1//! Mod collision detection and analysis.
2//!
3//! Builds on the existing [`ConflictMap`] to provide:
4//! - Archive-aware conflict detection (BSA/BA2 contents, not just loose files)
5//! - Collision severity classification (cosmetic vs dangerous)
6//! - Per-mod-pair collision grouping
7//! - Pre-deploy optimisation: shadowed mods, redundant files
8
9use std::collections::{HashMap, HashSet};
10use std::path::Path;
11
12use anyhow::Result;
13use tracing::{debug, warn};
14
15use crate::fs::walk_files_relative;
16use crate::resolver::{ConflictMap, ModId};
17
18// ── Types ───────────────────────────────────────────────────────────
19
20/// How a file is provided by a mod.
21#[derive(Debug, Clone, PartialEq, Eq, Hash)]
22pub enum FileOrigin {
23    /// A loose file in the mod's staging directory.
24    Loose,
25    /// A file listed inside an archive.
26    Archive { archive_rel: String },
27}
28
29/// Risk level of a file collision.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
31pub enum CollisionSeverity {
32    /// Texture/mesh/sound — cosmetic, low risk.
33    Cosmetic,
34    /// INI/config — medium risk, may change behaviour.
35    Config,
36    /// Script/plugin/DLL — high risk, potential crashes or save corruption.
37    Dangerous,
38    /// Cannot classify automatically.
39    Unknown,
40}
41
42impl std::fmt::Display for CollisionSeverity {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        match self {
45            Self::Cosmetic => f.write_str("COSMETIC"),
46            Self::Config => f.write_str("CONFIG"),
47            Self::Dangerous => f.write_str("DANGEROUS"),
48            Self::Unknown => f.write_str("UNKNOWN"),
49        }
50    }
51}
52
53/// Detail about a single file that collides between two mods.
54#[derive(Debug, Clone)]
55pub struct FileCollision {
56    pub file_path: String,
57    pub severity: CollisionSeverity,
58    pub winner: ModId,
59    pub loser: ModId,
60    pub winner_origin: FileOrigin,
61    pub loser_origin: FileOrigin,
62    pub is_loser_hidden: bool,
63}
64
65/// Aggregated collision info between a specific pair of mods.
66#[derive(Debug, Clone)]
67pub struct ModPairCollision {
68    /// The lower-priority mod (the one that loses files).
69    pub loser: ModId,
70    /// The higher-priority mod (the one that wins files).
71    pub winner: ModId,
72    /// Individual file collisions in this pair.
73    pub files: Vec<FileCollision>,
74    /// Worst severity among all collisions in this pair.
75    pub max_severity: CollisionSeverity,
76}
77
78/// A mod whose files are all overridden by higher-priority mods.
79#[derive(Debug, Clone)]
80pub struct ShadowedMod {
81    pub mod_id: ModId,
82    /// Which mods override this one's files.
83    pub shadowed_by: Vec<ModId>,
84    /// Total file count that is overridden.
85    pub file_count: usize,
86}
87
88/// Full collision analysis report for a profile.
89#[derive(Debug, Clone, Default)]
90pub struct CollisionReport {
91    /// Collision details grouped by (loser, winner) mod pair.
92    pub pairs: Vec<ModPairCollision>,
93    /// Files that are provided by a mod but always overridden (mod_id, file_path).
94    pub redundant_files: Vec<(ModId, String)>,
95    /// Mods whose files are all overridden.
96    pub shadowed_mods: Vec<ShadowedMod>,
97    /// Loose files that override files inside archives.
98    pub loose_vs_archive: Vec<FileCollision>,
99    /// Total number of file-level collisions.
100    pub total_collisions: usize,
101}
102
103// ── Classifier trait ────────────────────────────────────────────────
104
105/// Game-specific behaviour for collision detection.
106///
107/// Each game provides archive indexing and file severity classification.
108pub trait CollisionClassifier: Send + Sync {
109    /// List files inside an archive at `archive_path`.
110    /// Returns `(normalised_relative_path, size)` pairs.
111    fn index_archive(&self, archive_path: &Path) -> Result<Vec<(String, u64)>>;
112
113    /// Classify the collision severity of a file based on its path.
114    fn classify_severity(&self, file_path: &str) -> CollisionSeverity;
115
116    /// File extensions (lowercase, no dot) that are archives for this game.
117    fn archive_extensions(&self) -> &[&str];
118}
119
120// ── Conflict map builder ────────────────────────────────────────────
121
122/// Tracks per-file origin information alongside the conflict map.
123pub type OriginMap = HashMap<String, HashMap<ModId, FileOrigin>>;
124
125/// Build a [`ConflictMap`] that includes both loose files and archive contents.
126///
127/// For each mod in `resolved_order`, walks its store directory for loose files,
128/// then indexes any archives via `classifier`.
129///
130/// Returns the conflict map and a parallel map tracking how each mod provides
131/// each file ([`FileOrigin`]).
132pub fn build_full_conflict_map(
133    store: &Path,
134    resolved_order: &[ModId],
135    classifier: &dyn CollisionClassifier,
136) -> Result<(ConflictMap, OriginMap)> {
137    let archive_exts: HashSet<&str> = classifier.archive_extensions().iter().copied().collect();
138    let mut conflict_map = ConflictMap::default();
139    let mut origins: OriginMap = HashMap::new();
140
141    for mod_id in resolved_order {
142        let mod_dir = store.join(mod_id.as_str());
143        if !mod_dir.exists() {
144            warn!(%mod_id, "mod directory not found in store, skipping");
145            continue;
146        }
147
148        let files = walk_files_relative(&mod_dir)?;
149
150        for (rel_path, abs_path) in &files {
151            // Register the loose file.
152            conflict_map.register(rel_path.clone(), mod_id.clone());
153            origins
154                .entry(rel_path.clone())
155                .or_default()
156                .insert(mod_id.clone(), FileOrigin::Loose);
157
158            // If this file is an archive, index its contents.
159            let ext = abs_path
160                .extension()
161                .and_then(|e| e.to_str())
162                .unwrap_or("")
163                .to_lowercase();
164
165            if archive_exts.contains(ext.as_str()) {
166                let archive_files = match classifier.index_archive(abs_path) {
167                    Ok(files) => {
168                        debug!(%mod_id, archive = rel_path, count = files.len(), "indexed archive");
169                        files
170                    }
171                    Err(e) => {
172                        warn!(%mod_id, archive = rel_path, error = %e, "failed to index archive");
173                        Vec::new()
174                    }
175                };
176
177                for (archive_file_path, _size) in archive_files {
178                    conflict_map.register(
179                        archive_file_path.clone(),
180                        mod_id.clone(),
181                    );
182                    origins
183                        .entry(archive_file_path)
184                        .or_default()
185                        .insert(
186                            mod_id.clone(),
187                            FileOrigin::Archive {
188                                archive_rel: rel_path.clone(),
189                            },
190                        );
191                }
192            }
193        }
194    }
195
196    Ok((conflict_map, origins))
197}
198
199// ── Collision analyser ──────────────────────────────────────────────
200
201/// Analyse a conflict map to produce a full collision report.
202///
203/// `priority_order` lists mods from lowest to highest priority (same as
204/// [`ResolvedLoadOrder::order`]). `hidden` contains `(mod_id, rel_path)`
205/// pairs that have been hidden by the user.
206pub fn analyze_collisions(
207    conflict_map: &ConflictMap,
208    priority_order: &[ModId],
209    hidden: &HashSet<(String, String)>,
210    origins: &OriginMap,
211    classifier: &dyn CollisionClassifier,
212) -> CollisionReport {
213    let priority_rank: HashMap<&ModId, usize> = priority_order
214        .iter()
215        .enumerate()
216        .map(|(i, m)| (m, i))
217        .collect();
218
219    // Collect all file-level collisions.
220    let mut pair_map: HashMap<(ModId, ModId), Vec<FileCollision>> = HashMap::new();
221    let mut all_loose_vs_archive = Vec::new();
222    let mut total_collisions: usize = 0;
223
224    // Track how many files each mod provides and how many are overridden.
225    let mut mod_file_count: HashMap<ModId, usize> = HashMap::new();
226    let mut mod_overridden_count: HashMap<ModId, usize> = HashMap::new();
227    let mut mod_overridden_by: HashMap<ModId, HashSet<ModId>> = HashMap::new();
228
229    // Count total files per mod (including non-conflicting).
230    for (_, providers) in &conflict_map.files {
231        for mod_id in providers {
232            *mod_file_count.entry(mod_id.clone()).or_default() += 1;
233        }
234    }
235
236    for (file_path, providers) in &conflict_map.files {
237        if providers.len() < 2 {
238            continue;
239        }
240
241        total_collisions += providers.len() - 1;
242
243        let winner = conflict_map.winner_for(file_path, priority_order, hidden);
244        let severity = classifier.classify_severity(file_path);
245
246        let winner_id = match &winner {
247            Some(w) => w,
248            None => continue,
249        };
250
251        let winner_origin = origins
252            .get(file_path)
253            .and_then(|m| m.get(winner_id))
254            .cloned()
255            .unwrap_or(FileOrigin::Loose);
256
257        for loser_id in providers {
258            if loser_id == winner_id {
259                continue;
260            }
261
262            let loser_origin = origins
263                .get(file_path)
264                .and_then(|m| m.get(loser_id))
265                .cloned()
266                .unwrap_or(FileOrigin::Loose);
267
268            let is_hidden = hidden.contains(&(loser_id.0.clone(), file_path.clone()));
269
270            let collision = FileCollision {
271                file_path: file_path.clone(),
272                severity,
273                winner: winner_id.clone(),
274                loser: loser_id.clone(),
275                winner_origin: winner_origin.clone(),
276                loser_origin: loser_origin.clone(),
277                is_loser_hidden: is_hidden,
278            };
279
280            // Detect loose vs archive conflicts.
281            if collision.winner_origin != collision.loser_origin {
282                let is_loose_vs_archive = matches!(
283                    (&collision.winner_origin, &collision.loser_origin),
284                    (FileOrigin::Loose, FileOrigin::Archive { .. })
285                        | (FileOrigin::Archive { .. }, FileOrigin::Loose)
286                );
287                if is_loose_vs_archive {
288                    all_loose_vs_archive.push(collision.clone());
289                }
290            }
291
292            // Track overridden files for shadowed mod detection.
293            *mod_overridden_count.entry(loser_id.clone()).or_default() += 1;
294            mod_overridden_by
295                .entry(loser_id.clone())
296                .or_default()
297                .insert(winner_id.clone());
298
299            // Group by (loser, winner) pair, ordered by priority.
300            let key = order_pair(loser_id, winner_id, &priority_rank);
301            pair_map.entry(key).or_default().push(collision);
302        }
303    }
304
305    // Build pair summaries.
306    let mut pairs: Vec<ModPairCollision> = pair_map
307        .into_iter()
308        .map(|((loser, winner), files)| {
309            let max_severity = files
310                .iter()
311                .map(|f| f.severity)
312                .max()
313                .unwrap_or(CollisionSeverity::Unknown);
314            ModPairCollision {
315                loser,
316                winner,
317                files,
318                max_severity,
319            }
320        })
321        .collect();
322
323    // Sort pairs: most severe first, then by file count descending.
324    pairs.sort_by(|a, b| {
325        b.max_severity
326            .cmp(&a.max_severity)
327            .then_with(|| b.files.len().cmp(&a.files.len()))
328    });
329
330    // Identify redundant files: files provided by a mod that always lose.
331    let mut redundant_files = Vec::new();
332    for (file_path, providers) in &conflict_map.files {
333        if providers.len() < 2 {
334            continue;
335        }
336        let winner = conflict_map.winner_for(file_path, priority_order, hidden);
337        for provider in providers {
338            if winner.as_ref() != Some(provider) {
339                redundant_files.push((provider.clone(), file_path.clone()));
340            }
341        }
342    }
343
344    // Identify fully shadowed mods.
345    let shadowed_mods: Vec<ShadowedMod> = mod_file_count
346        .iter()
347        .filter_map(|(mod_id, &total)| {
348            let overridden = mod_overridden_count.get(mod_id).copied().unwrap_or(0);
349            if overridden >= total && total > 0 {
350                let shadowed_by = mod_overridden_by
351                    .get(mod_id)
352                    .map(|s| s.iter().cloned().collect())
353                    .unwrap_or_default();
354                Some(ShadowedMod {
355                    mod_id: mod_id.clone(),
356                    shadowed_by,
357                    file_count: total,
358                })
359            } else {
360                None
361            }
362        })
363        .collect();
364
365    CollisionReport {
366        pairs,
367        redundant_files,
368        shadowed_mods,
369        loose_vs_archive: all_loose_vs_archive,
370        total_collisions,
371    }
372}
373
374/// Order a (mod_a, mod_b) pair so the lower-priority mod is first.
375fn order_pair(
376    a: &ModId,
377    b: &ModId,
378    priority_rank: &HashMap<&ModId, usize>,
379) -> (ModId, ModId) {
380    let rank_a = priority_rank.get(a).copied().unwrap_or(0);
381    let rank_b = priority_rank.get(b).copied().unwrap_or(0);
382    if rank_a <= rank_b {
383        (a.clone(), b.clone())
384    } else {
385        (b.clone(), a.clone())
386    }
387}
388
389// ── Tests ───────────────────────────────────────────────────────────
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394
395    /// A test classifier that classifies by extension.
396    struct TestClassifier;
397
398    impl CollisionClassifier for TestClassifier {
399        fn index_archive(&self, _path: &Path) -> Result<Vec<(String, u64)>> {
400            Ok(Vec::new())
401        }
402
403        fn classify_severity(&self, file_path: &str) -> CollisionSeverity {
404            let ext = file_path.rsplit('.').next().unwrap_or("");
405            match ext {
406                "esp" | "esm" | "dll" => CollisionSeverity::Dangerous,
407                "ini" | "cfg" => CollisionSeverity::Config,
408                "dds" | "nif" => CollisionSeverity::Cosmetic,
409                _ => CollisionSeverity::Unknown,
410            }
411        }
412
413        fn archive_extensions(&self) -> &[&str] {
414            &["bsa"]
415        }
416    }
417
418    fn mod_id(s: &str) -> ModId {
419        ModId::from(s)
420    }
421
422    #[test]
423    fn no_collisions_produces_empty_report() {
424        let mut cm = ConflictMap::default();
425        cm.register("textures/sky.dds".into(), mod_id("mod_a"));
426        cm.register("textures/ground.dds".into(), mod_id("mod_b"));
427
428        let order = vec![mod_id("mod_a"), mod_id("mod_b")];
429        let hidden = HashSet::new();
430        let origins = HashMap::new();
431
432        let report = analyze_collisions(&cm, &order, &hidden, &origins, &TestClassifier);
433        assert!(report.pairs.is_empty());
434        assert_eq!(report.total_collisions, 0);
435    }
436
437    #[test]
438    fn simple_collision_detected() {
439        let mut cm = ConflictMap::default();
440        cm.register("textures/sky.dds".into(), mod_id("mod_a"));
441        cm.register("textures/sky.dds".into(), mod_id("mod_b"));
442
443        let order = vec![mod_id("mod_a"), mod_id("mod_b")];
444        let hidden = HashSet::new();
445        let origins = HashMap::new();
446
447        let report = analyze_collisions(&cm, &order, &hidden, &origins, &TestClassifier);
448        assert_eq!(report.pairs.len(), 1);
449        assert_eq!(report.total_collisions, 1);
450        assert_eq!(report.pairs[0].winner, mod_id("mod_b"));
451        assert_eq!(report.pairs[0].loser, mod_id("mod_a"));
452        assert_eq!(report.pairs[0].max_severity, CollisionSeverity::Cosmetic);
453    }
454
455    #[test]
456    fn dangerous_collision_severity() {
457        let mut cm = ConflictMap::default();
458        cm.register("scripts/combat.esp".into(), mod_id("mod_a"));
459        cm.register("scripts/combat.esp".into(), mod_id("mod_b"));
460
461        let order = vec![mod_id("mod_a"), mod_id("mod_b")];
462        let hidden = HashSet::new();
463        let origins = HashMap::new();
464
465        let report = analyze_collisions(&cm, &order, &hidden, &origins, &TestClassifier);
466        assert_eq!(report.pairs[0].max_severity, CollisionSeverity::Dangerous);
467    }
468
469    #[test]
470    fn shadowed_mod_detected() {
471        let mut cm = ConflictMap::default();
472        // mod_a provides two files, both also provided by mod_b
473        cm.register("textures/sky.dds".into(), mod_id("mod_a"));
474        cm.register("textures/sky.dds".into(), mod_id("mod_b"));
475        cm.register("textures/ground.dds".into(), mod_id("mod_a"));
476        cm.register("textures/ground.dds".into(), mod_id("mod_b"));
477
478        let order = vec![mod_id("mod_a"), mod_id("mod_b")];
479        let hidden = HashSet::new();
480        let origins = HashMap::new();
481
482        let report = analyze_collisions(&cm, &order, &hidden, &origins, &TestClassifier);
483        assert_eq!(report.shadowed_mods.len(), 1);
484        assert_eq!(report.shadowed_mods[0].mod_id, mod_id("mod_a"));
485        assert_eq!(report.shadowed_mods[0].file_count, 2);
486    }
487
488    #[test]
489    fn redundant_files_tracked() {
490        let mut cm = ConflictMap::default();
491        cm.register("textures/sky.dds".into(), mod_id("mod_a"));
492        cm.register("textures/sky.dds".into(), mod_id("mod_b"));
493
494        let order = vec![mod_id("mod_a"), mod_id("mod_b")];
495        let hidden = HashSet::new();
496        let origins = HashMap::new();
497
498        let report = analyze_collisions(&cm, &order, &hidden, &origins, &TestClassifier);
499        assert_eq!(report.redundant_files.len(), 1);
500        assert_eq!(report.redundant_files[0].0, mod_id("mod_a"));
501    }
502
503    #[test]
504    fn loose_vs_archive_detected() {
505        let mut cm = ConflictMap::default();
506        cm.register("textures/sky.dds".into(), mod_id("mod_a"));
507        cm.register("textures/sky.dds".into(), mod_id("mod_b"));
508
509        let order = vec![mod_id("mod_a"), mod_id("mod_b")];
510        let hidden = HashSet::new();
511
512        let mut origins: OriginMap = HashMap::new();
513        origins.entry("textures/sky.dds".into()).or_default().insert(
514            mod_id("mod_a"),
515            FileOrigin::Archive {
516                archive_rel: "mod_a.bsa".into(),
517            },
518        );
519        origins
520            .entry("textures/sky.dds".into())
521            .or_default()
522            .insert(mod_id("mod_b"), FileOrigin::Loose);
523
524        let report = analyze_collisions(&cm, &order, &hidden, &origins, &TestClassifier);
525        assert_eq!(report.loose_vs_archive.len(), 1);
526    }
527
528    #[test]
529    fn three_way_collision() {
530        let mut cm = ConflictMap::default();
531        cm.register("textures/sky.dds".into(), mod_id("mod_a"));
532        cm.register("textures/sky.dds".into(), mod_id("mod_b"));
533        cm.register("textures/sky.dds".into(), mod_id("mod_c"));
534
535        let order = vec![mod_id("mod_a"), mod_id("mod_b"), mod_id("mod_c")];
536        let hidden = HashSet::new();
537        let origins = HashMap::new();
538
539        let report = analyze_collisions(&cm, &order, &hidden, &origins, &TestClassifier);
540        // mod_c wins, mod_a and mod_b both lose → 2 collisions, 2 pairs
541        assert_eq!(report.total_collisions, 2);
542        assert_eq!(report.pairs.len(), 2);
543    }
544
545    #[test]
546    fn hidden_file_excluded_from_winner() {
547        let mut cm = ConflictMap::default();
548        cm.register("textures/sky.dds".into(), mod_id("mod_a"));
549        cm.register("textures/sky.dds".into(), mod_id("mod_b"));
550
551        let order = vec![mod_id("mod_a"), mod_id("mod_b")];
552        let mut hidden = HashSet::new();
553        hidden.insert(("mod_b".to_string(), "textures/sky.dds".to_string()));
554        let origins = HashMap::new();
555
556        let report = analyze_collisions(&cm, &order, &hidden, &origins, &TestClassifier);
557        // mod_b is hidden, so mod_a wins — still a collision pair though
558        assert_eq!(report.pairs.len(), 1);
559        assert_eq!(report.pairs[0].files[0].winner, mod_id("mod_a"));
560    }
561}