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
125const MISSING_STORE_DIR_SAMPLE_LIMIT: usize = 10;
126
127#[derive(Debug, PartialEq, Eq)]
128pub struct MissingStoreDirsSummary {
129    pub missing_mod_count: usize,
130    pub missing_mod_sample: Vec<String>,
131    pub omitted_missing_mod_count: usize,
132}
133
134#[derive(Debug)]
135pub struct FullConflictMap {
136    pub conflict_map: ConflictMap,
137    pub origins: OriginMap,
138    pub missing_mods: Vec<ModId>,
139}
140
141pub fn summarize_missing_store_dirs(missing_mods: &[ModId]) -> Option<MissingStoreDirsSummary> {
142    if missing_mods.is_empty() {
143        return None;
144    }
145
146    let missing_mod_sample = missing_mods
147        .iter()
148        .take(MISSING_STORE_DIR_SAMPLE_LIMIT)
149        .map(ToString::to_string)
150        .collect::<Vec<_>>();
151    let missing_mod_count = missing_mods.len();
152    let omitted_missing_mod_count =
153        missing_mod_count.saturating_sub(MISSING_STORE_DIR_SAMPLE_LIMIT);
154
155    Some(MissingStoreDirsSummary {
156        missing_mod_count,
157        missing_mod_sample,
158        omitted_missing_mod_count,
159    })
160}
161
162/// Build a [`ConflictMap`] that includes both loose files and archive contents.
163///
164/// For each mod in `resolved_order`, walks its store directory for loose files,
165/// then indexes any archives via `classifier`.
166///
167/// Returns the conflict map and a parallel map tracking how each mod provides
168/// each file ([`FileOrigin`]).
169pub fn build_full_conflict_map(
170    store: &Path,
171    resolved_order: &[ModId],
172    classifier: &dyn CollisionClassifier,
173) -> Result<FullConflictMap> {
174    let archive_exts: HashSet<&str> = classifier.archive_extensions().iter().copied().collect();
175    let mut conflict_map = ConflictMap::default();
176    let mut origins: OriginMap = HashMap::new();
177    let mut missing_mods = Vec::new();
178
179    for mod_id in resolved_order {
180        let mod_dir = store.join(mod_id.as_str());
181        if !mod_dir.exists() {
182            missing_mods.push(mod_id.clone());
183            continue;
184        }
185
186        let files = walk_files_relative(&mod_dir)?;
187
188        for (rel_path, abs_path) in &files {
189            // Register the loose file.
190            conflict_map.register(rel_path.clone(), mod_id.clone());
191            origins
192                .entry(rel_path.clone())
193                .or_default()
194                .insert(mod_id.clone(), FileOrigin::Loose);
195
196            // If this file is an archive, index its contents.
197            let ext = abs_path
198                .extension()
199                .and_then(|e| e.to_str())
200                .unwrap_or("")
201                .to_lowercase();
202
203            if archive_exts.contains(ext.as_str()) {
204                let archive_files = match classifier.index_archive(abs_path) {
205                    Ok(files) => {
206                        debug!(%mod_id, archive = rel_path, count = files.len(), "indexed archive");
207                        files
208                    }
209                    Err(e) => {
210                        warn!(%mod_id, archive = rel_path, error = %e, "failed to index archive");
211                        Vec::new()
212                    }
213                };
214
215                for (archive_file_path, _size) in archive_files {
216                    conflict_map.register(archive_file_path.clone(), mod_id.clone());
217                    origins.entry(archive_file_path).or_default().insert(
218                        mod_id.clone(),
219                        FileOrigin::Archive {
220                            archive_rel: rel_path.clone(),
221                        },
222                    );
223                }
224            }
225        }
226    }
227
228    if let Some(summary) = summarize_missing_store_dirs(&missing_mods) {
229        debug!(
230            store = %store.display(),
231            missing_mod_count = summary.missing_mod_count,
232            missing_mod_sample = ?summary.missing_mod_sample,
233            omitted_missing_mod_count = summary.omitted_missing_mod_count,
234            "mod directories not found in store, skipping"
235        );
236    }
237
238    Ok(FullConflictMap {
239        conflict_map,
240        origins,
241        missing_mods,
242    })
243}
244
245// ── Collision analyser ──────────────────────────────────────────────
246
247/// Analyse a conflict map to produce a full collision report.
248///
249/// `priority_order` lists mods from lowest to highest priority (same as
250/// [`crate::resolver::ResolvedLoadOrder::order`]). `hidden` contains `(mod_id, rel_path)`
251/// pairs that have been hidden by the user.
252pub fn analyze_collisions(
253    conflict_map: &ConflictMap,
254    priority_order: &[ModId],
255    hidden: &HashSet<(String, String)>,
256    origins: &OriginMap,
257    classifier: &dyn CollisionClassifier,
258) -> CollisionReport {
259    let priority_rank: HashMap<&ModId, usize> = priority_order
260        .iter()
261        .enumerate()
262        .map(|(i, m)| (m, i))
263        .collect();
264
265    // Collect all file-level collisions.
266    let mut pair_map: HashMap<(ModId, ModId), Vec<FileCollision>> = HashMap::new();
267    let mut all_loose_vs_archive = Vec::new();
268    let mut total_collisions: usize = 0;
269
270    // Track how many files each mod provides and how many are overridden.
271    let mut mod_file_count: HashMap<ModId, usize> = HashMap::new();
272    let mut mod_overridden_count: HashMap<ModId, usize> = HashMap::new();
273    let mut mod_overridden_by: HashMap<ModId, HashSet<ModId>> = HashMap::new();
274
275    // Count total files per mod (including non-conflicting).
276    for providers in conflict_map.files.values() {
277        for mod_id in providers {
278            *mod_file_count.entry(mod_id.clone()).or_default() += 1;
279        }
280    }
281
282    for (file_path, providers) in &conflict_map.files {
283        if providers.len() < 2 {
284            continue;
285        }
286
287        total_collisions += providers.len() - 1;
288
289        let winner = conflict_map.winner_for(file_path, priority_order, hidden);
290        let severity = classifier.classify_severity(file_path);
291
292        let winner_id = match &winner {
293            Some(w) => w,
294            None => continue,
295        };
296
297        let winner_origin = origins
298            .get(file_path)
299            .and_then(|m| m.get(winner_id))
300            .cloned()
301            .unwrap_or(FileOrigin::Loose);
302
303        for loser_id in providers {
304            if loser_id == winner_id {
305                continue;
306            }
307
308            let loser_origin = origins
309                .get(file_path)
310                .and_then(|m| m.get(loser_id))
311                .cloned()
312                .unwrap_or(FileOrigin::Loose);
313
314            let is_hidden = hidden.contains(&(loser_id.0.clone(), file_path.clone()));
315
316            let collision = FileCollision {
317                file_path: file_path.clone(),
318                severity,
319                winner: winner_id.clone(),
320                loser: loser_id.clone(),
321                winner_origin: winner_origin.clone(),
322                loser_origin: loser_origin.clone(),
323                is_loser_hidden: is_hidden,
324            };
325
326            // Detect loose vs archive conflicts.
327            if collision.winner_origin != collision.loser_origin {
328                let is_loose_vs_archive = matches!(
329                    (&collision.winner_origin, &collision.loser_origin),
330                    (FileOrigin::Loose, FileOrigin::Archive { .. })
331                        | (FileOrigin::Archive { .. }, FileOrigin::Loose)
332                );
333                if is_loose_vs_archive {
334                    all_loose_vs_archive.push(collision.clone());
335                }
336            }
337
338            // Track overridden files for shadowed mod detection.
339            *mod_overridden_count.entry(loser_id.clone()).or_default() += 1;
340            mod_overridden_by
341                .entry(loser_id.clone())
342                .or_default()
343                .insert(winner_id.clone());
344
345            // Group by (loser, winner) pair, ordered by priority.
346            let key = order_pair(loser_id, winner_id, &priority_rank);
347            pair_map.entry(key).or_default().push(collision);
348        }
349    }
350
351    // Build pair summaries.
352    let mut pairs: Vec<ModPairCollision> = pair_map
353        .into_iter()
354        .map(|((loser, winner), files)| {
355            let max_severity = files
356                .iter()
357                .map(|f| f.severity)
358                .max()
359                .unwrap_or(CollisionSeverity::Unknown);
360            ModPairCollision {
361                loser,
362                winner,
363                files,
364                max_severity,
365            }
366        })
367        .collect();
368
369    // Sort pairs: most severe first, then by file count descending.
370    pairs.sort_by(|a, b| {
371        b.max_severity
372            .cmp(&a.max_severity)
373            .then_with(|| b.files.len().cmp(&a.files.len()))
374    });
375
376    // Identify redundant files: files provided by a mod that always lose.
377    let mut redundant_files = Vec::new();
378    for (file_path, providers) in &conflict_map.files {
379        if providers.len() < 2 {
380            continue;
381        }
382        let winner = conflict_map.winner_for(file_path, priority_order, hidden);
383        for provider in providers {
384            if winner.as_ref() != Some(provider) {
385                redundant_files.push((provider.clone(), file_path.clone()));
386            }
387        }
388    }
389
390    // Identify fully shadowed mods.
391    let shadowed_mods: Vec<ShadowedMod> = mod_file_count
392        .iter()
393        .filter_map(|(mod_id, &total)| {
394            let overridden = mod_overridden_count.get(mod_id).copied().unwrap_or(0);
395            if overridden >= total && total > 0 {
396                let shadowed_by = mod_overridden_by
397                    .get(mod_id)
398                    .map(|s| s.iter().cloned().collect())
399                    .unwrap_or_default();
400                Some(ShadowedMod {
401                    mod_id: mod_id.clone(),
402                    shadowed_by,
403                    file_count: total,
404                })
405            } else {
406                None
407            }
408        })
409        .collect();
410
411    CollisionReport {
412        pairs,
413        redundant_files,
414        shadowed_mods,
415        loose_vs_archive: all_loose_vs_archive,
416        total_collisions,
417    }
418}
419
420/// Order a (`mod_a`, `mod_b`) pair so the lower-priority mod is first.
421fn order_pair(a: &ModId, b: &ModId, priority_rank: &HashMap<&ModId, usize>) -> (ModId, ModId) {
422    let rank_a = priority_rank.get(a).copied().unwrap_or(0);
423    let rank_b = priority_rank.get(b).copied().unwrap_or(0);
424    if rank_a <= rank_b {
425        (a.clone(), b.clone())
426    } else {
427        (b.clone(), a.clone())
428    }
429}
430
431// ── Tests ───────────────────────────────────────────────────────────
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436
437    /// A test classifier that classifies by extension.
438    struct TestClassifier;
439
440    impl CollisionClassifier for TestClassifier {
441        fn index_archive(&self, _path: &Path) -> Result<Vec<(String, u64)>> {
442            Ok(Vec::new())
443        }
444
445        fn classify_severity(&self, file_path: &str) -> CollisionSeverity {
446            let ext = file_path.rsplit('.').next().unwrap_or("");
447            match ext {
448                "esp" | "esm" | "dll" => CollisionSeverity::Dangerous,
449                "ini" | "cfg" => CollisionSeverity::Config,
450                "dds" | "nif" => CollisionSeverity::Cosmetic,
451                _ => CollisionSeverity::Unknown,
452            }
453        }
454
455        fn archive_extensions(&self) -> &[&str] {
456            &["bsa"]
457        }
458    }
459
460    fn mod_id(s: &str) -> ModId {
461        ModId::from(s)
462    }
463
464    #[test]
465    fn missing_store_dirs_summary_is_none_for_empty_input() {
466        assert_eq!(summarize_missing_store_dirs(&[]), None);
467    }
468
469    #[test]
470    fn missing_store_dirs_summary_reports_all_entries_within_sample_limit() {
471        let missing_mods = vec![mod_id("mod_a"), mod_id("mod_b")];
472
473        assert_eq!(
474            summarize_missing_store_dirs(&missing_mods),
475            Some(MissingStoreDirsSummary {
476                missing_mod_count: 2,
477                missing_mod_sample: vec!["mod_a".to_string(), "mod_b".to_string()],
478                omitted_missing_mod_count: 0,
479            })
480        );
481    }
482
483    #[test]
484    fn missing_store_dirs_summary_bounds_sample_and_counts_omitted_entries() {
485        let missing_mods = (0..12)
486            .map(|idx| mod_id(&format!("mod_{idx}")))
487            .collect::<Vec<_>>();
488
489        assert_eq!(
490            summarize_missing_store_dirs(&missing_mods),
491            Some(MissingStoreDirsSummary {
492                missing_mod_count: 12,
493                missing_mod_sample: (0..10).map(|idx| format!("mod_{idx}")).collect(),
494                omitted_missing_mod_count: 2,
495            })
496        );
497    }
498
499    #[test]
500    fn build_full_conflict_map_skips_missing_store_dirs() {
501        let store = tempfile::tempdir().unwrap();
502        let present = store.path().join("mod_present");
503        std::fs::create_dir_all(present.join("textures")).unwrap();
504        std::fs::write(present.join("textures/sky.dds"), b"sky").unwrap();
505
506        let order = vec![mod_id("mod_missing"), mod_id("mod_present")];
507        let result = build_full_conflict_map(store.path(), &order, &TestClassifier).unwrap();
508
509        assert_eq!(result.missing_mods, vec![mod_id("mod_missing")]);
510        assert_eq!(result.conflict_map.files.len(), 1);
511        assert!(result.conflict_map.files.contains_key("textures/sky.dds"));
512        assert!(
513            !result
514                .conflict_map
515                .files
516                .values()
517                .any(|mods| { mods.iter().any(|mod_id| mod_id.as_str() == "mod_missing") })
518        );
519        assert!(
520            !result
521                .origins
522                .values()
523                .any(|mods| { mods.keys().any(|mod_id| mod_id.as_str() == "mod_missing") })
524        );
525    }
526
527    #[test]
528    fn no_collisions_produces_empty_report() {
529        let mut cm = ConflictMap::default();
530        cm.register("textures/sky.dds".into(), mod_id("mod_a"));
531        cm.register("textures/ground.dds".into(), mod_id("mod_b"));
532
533        let order = vec![mod_id("mod_a"), mod_id("mod_b")];
534        let hidden = HashSet::new();
535        let origins = HashMap::new();
536
537        let report = analyze_collisions(&cm, &order, &hidden, &origins, &TestClassifier);
538        assert!(report.pairs.is_empty());
539        assert_eq!(report.total_collisions, 0);
540    }
541
542    #[test]
543    fn simple_collision_detected() {
544        let mut cm = ConflictMap::default();
545        cm.register("textures/sky.dds".into(), mod_id("mod_a"));
546        cm.register("textures/sky.dds".into(), mod_id("mod_b"));
547
548        let order = vec![mod_id("mod_a"), mod_id("mod_b")];
549        let hidden = HashSet::new();
550        let origins = HashMap::new();
551
552        let report = analyze_collisions(&cm, &order, &hidden, &origins, &TestClassifier);
553        assert_eq!(report.pairs.len(), 1);
554        assert_eq!(report.total_collisions, 1);
555        assert_eq!(report.pairs[0].winner, mod_id("mod_b"));
556        assert_eq!(report.pairs[0].loser, mod_id("mod_a"));
557        assert_eq!(report.pairs[0].max_severity, CollisionSeverity::Cosmetic);
558    }
559
560    #[test]
561    fn dangerous_collision_severity() {
562        let mut cm = ConflictMap::default();
563        cm.register("scripts/combat.esp".into(), mod_id("mod_a"));
564        cm.register("scripts/combat.esp".into(), mod_id("mod_b"));
565
566        let order = vec![mod_id("mod_a"), mod_id("mod_b")];
567        let hidden = HashSet::new();
568        let origins = HashMap::new();
569
570        let report = analyze_collisions(&cm, &order, &hidden, &origins, &TestClassifier);
571        assert_eq!(report.pairs[0].max_severity, CollisionSeverity::Dangerous);
572    }
573
574    #[test]
575    fn shadowed_mod_detected() {
576        let mut cm = ConflictMap::default();
577        // mod_a provides two files, both also provided by mod_b
578        cm.register("textures/sky.dds".into(), mod_id("mod_a"));
579        cm.register("textures/sky.dds".into(), mod_id("mod_b"));
580        cm.register("textures/ground.dds".into(), mod_id("mod_a"));
581        cm.register("textures/ground.dds".into(), mod_id("mod_b"));
582
583        let order = vec![mod_id("mod_a"), mod_id("mod_b")];
584        let hidden = HashSet::new();
585        let origins = HashMap::new();
586
587        let report = analyze_collisions(&cm, &order, &hidden, &origins, &TestClassifier);
588        assert_eq!(report.shadowed_mods.len(), 1);
589        assert_eq!(report.shadowed_mods[0].mod_id, mod_id("mod_a"));
590        assert_eq!(report.shadowed_mods[0].file_count, 2);
591    }
592
593    #[test]
594    fn redundant_files_tracked() {
595        let mut cm = ConflictMap::default();
596        cm.register("textures/sky.dds".into(), mod_id("mod_a"));
597        cm.register("textures/sky.dds".into(), mod_id("mod_b"));
598
599        let order = vec![mod_id("mod_a"), mod_id("mod_b")];
600        let hidden = HashSet::new();
601        let origins = HashMap::new();
602
603        let report = analyze_collisions(&cm, &order, &hidden, &origins, &TestClassifier);
604        assert_eq!(report.redundant_files.len(), 1);
605        assert_eq!(report.redundant_files[0].0, mod_id("mod_a"));
606    }
607
608    #[test]
609    fn loose_vs_archive_detected() {
610        let mut cm = ConflictMap::default();
611        cm.register("textures/sky.dds".into(), mod_id("mod_a"));
612        cm.register("textures/sky.dds".into(), mod_id("mod_b"));
613
614        let order = vec![mod_id("mod_a"), mod_id("mod_b")];
615        let hidden = HashSet::new();
616
617        let mut origins: OriginMap = HashMap::new();
618        origins
619            .entry("textures/sky.dds".into())
620            .or_default()
621            .insert(
622                mod_id("mod_a"),
623                FileOrigin::Archive {
624                    archive_rel: "mod_a.bsa".into(),
625                },
626            );
627        origins
628            .entry("textures/sky.dds".into())
629            .or_default()
630            .insert(mod_id("mod_b"), FileOrigin::Loose);
631
632        let report = analyze_collisions(&cm, &order, &hidden, &origins, &TestClassifier);
633        assert_eq!(report.loose_vs_archive.len(), 1);
634    }
635
636    #[test]
637    fn three_way_collision() {
638        let mut cm = ConflictMap::default();
639        cm.register("textures/sky.dds".into(), mod_id("mod_a"));
640        cm.register("textures/sky.dds".into(), mod_id("mod_b"));
641        cm.register("textures/sky.dds".into(), mod_id("mod_c"));
642
643        let order = vec![mod_id("mod_a"), mod_id("mod_b"), mod_id("mod_c")];
644        let hidden = HashSet::new();
645        let origins = HashMap::new();
646
647        let report = analyze_collisions(&cm, &order, &hidden, &origins, &TestClassifier);
648        // mod_c wins, mod_a and mod_b both lose → 2 collisions, 2 pairs
649        assert_eq!(report.total_collisions, 2);
650        assert_eq!(report.pairs.len(), 2);
651    }
652
653    #[test]
654    fn hidden_file_excluded_from_winner() {
655        let mut cm = ConflictMap::default();
656        cm.register("textures/sky.dds".into(), mod_id("mod_a"));
657        cm.register("textures/sky.dds".into(), mod_id("mod_b"));
658
659        let order = vec![mod_id("mod_a"), mod_id("mod_b")];
660        let mut hidden = HashSet::new();
661        hidden.insert(("mod_b".to_string(), "textures/sky.dds".to_string()));
662        let origins = HashMap::new();
663
664        let report = analyze_collisions(&cm, &order, &hidden, &origins, &TestClassifier);
665        // mod_b is hidden, so mod_a wins — still a collision pair though
666        assert_eq!(report.pairs.len(), 1);
667        assert_eq!(report.pairs[0].files[0].winner, mod_id("mod_a"));
668    }
669}