use std::collections::{HashMap, HashSet};
use std::path::Path;
use anyhow::Result;
use tracing::{debug, warn};
use crate::fs::walk_files_relative;
use crate::resolver::{ConflictMap, ModId};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum FileOrigin {
Loose,
Archive { archive_rel: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum CollisionSeverity {
Cosmetic,
Config,
Dangerous,
Unknown,
}
impl std::fmt::Display for CollisionSeverity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Cosmetic => f.write_str("COSMETIC"),
Self::Config => f.write_str("CONFIG"),
Self::Dangerous => f.write_str("DANGEROUS"),
Self::Unknown => f.write_str("UNKNOWN"),
}
}
}
#[derive(Debug, Clone)]
pub struct FileCollision {
pub file_path: String,
pub severity: CollisionSeverity,
pub winner: ModId,
pub loser: ModId,
pub winner_origin: FileOrigin,
pub loser_origin: FileOrigin,
pub is_loser_hidden: bool,
}
#[derive(Debug, Clone)]
pub struct ModPairCollision {
pub loser: ModId,
pub winner: ModId,
pub files: Vec<FileCollision>,
pub max_severity: CollisionSeverity,
}
#[derive(Debug, Clone)]
pub struct ShadowedMod {
pub mod_id: ModId,
pub shadowed_by: Vec<ModId>,
pub file_count: usize,
}
#[derive(Debug, Clone, Default)]
pub struct CollisionReport {
pub pairs: Vec<ModPairCollision>,
pub redundant_files: Vec<(ModId, String)>,
pub shadowed_mods: Vec<ShadowedMod>,
pub loose_vs_archive: Vec<FileCollision>,
pub total_collisions: usize,
}
pub trait CollisionClassifier: Send + Sync {
fn index_archive(&self, archive_path: &Path) -> Result<Vec<(String, u64)>>;
fn classify_severity(&self, file_path: &str) -> CollisionSeverity;
fn archive_extensions(&self) -> &[&str];
}
pub type OriginMap = HashMap<String, HashMap<ModId, FileOrigin>>;
pub fn build_full_conflict_map(
store: &Path,
resolved_order: &[ModId],
classifier: &dyn CollisionClassifier,
) -> Result<(ConflictMap, OriginMap)> {
let archive_exts: HashSet<&str> = classifier.archive_extensions().iter().copied().collect();
let mut conflict_map = ConflictMap::default();
let mut origins: OriginMap = HashMap::new();
for mod_id in resolved_order {
let mod_dir = store.join(mod_id.as_str());
if !mod_dir.exists() {
warn!(%mod_id, "mod directory not found in store, skipping");
continue;
}
let files = walk_files_relative(&mod_dir)?;
for (rel_path, abs_path) in &files {
conflict_map.register(rel_path.clone(), mod_id.clone());
origins
.entry(rel_path.clone())
.or_default()
.insert(mod_id.clone(), FileOrigin::Loose);
let ext = abs_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
if archive_exts.contains(ext.as_str()) {
let archive_files = match classifier.index_archive(abs_path) {
Ok(files) => {
debug!(%mod_id, archive = rel_path, count = files.len(), "indexed archive");
files
}
Err(e) => {
warn!(%mod_id, archive = rel_path, error = %e, "failed to index archive");
Vec::new()
}
};
for (archive_file_path, _size) in archive_files {
conflict_map.register(
archive_file_path.clone(),
mod_id.clone(),
);
origins
.entry(archive_file_path)
.or_default()
.insert(
mod_id.clone(),
FileOrigin::Archive {
archive_rel: rel_path.clone(),
},
);
}
}
}
}
Ok((conflict_map, origins))
}
pub fn analyze_collisions(
conflict_map: &ConflictMap,
priority_order: &[ModId],
hidden: &HashSet<(String, String)>,
origins: &OriginMap,
classifier: &dyn CollisionClassifier,
) -> CollisionReport {
let priority_rank: HashMap<&ModId, usize> = priority_order
.iter()
.enumerate()
.map(|(i, m)| (m, i))
.collect();
let mut pair_map: HashMap<(ModId, ModId), Vec<FileCollision>> = HashMap::new();
let mut all_loose_vs_archive = Vec::new();
let mut total_collisions: usize = 0;
let mut mod_file_count: HashMap<ModId, usize> = HashMap::new();
let mut mod_overridden_count: HashMap<ModId, usize> = HashMap::new();
let mut mod_overridden_by: HashMap<ModId, HashSet<ModId>> = HashMap::new();
for (_, providers) in &conflict_map.files {
for mod_id in providers {
*mod_file_count.entry(mod_id.clone()).or_default() += 1;
}
}
for (file_path, providers) in &conflict_map.files {
if providers.len() < 2 {
continue;
}
total_collisions += providers.len() - 1;
let winner = conflict_map.winner_for(file_path, priority_order, hidden);
let severity = classifier.classify_severity(file_path);
let winner_id = match &winner {
Some(w) => w,
None => continue,
};
let winner_origin = origins
.get(file_path)
.and_then(|m| m.get(winner_id))
.cloned()
.unwrap_or(FileOrigin::Loose);
for loser_id in providers {
if loser_id == winner_id {
continue;
}
let loser_origin = origins
.get(file_path)
.and_then(|m| m.get(loser_id))
.cloned()
.unwrap_or(FileOrigin::Loose);
let is_hidden = hidden.contains(&(loser_id.0.clone(), file_path.clone()));
let collision = FileCollision {
file_path: file_path.clone(),
severity,
winner: winner_id.clone(),
loser: loser_id.clone(),
winner_origin: winner_origin.clone(),
loser_origin: loser_origin.clone(),
is_loser_hidden: is_hidden,
};
if collision.winner_origin != collision.loser_origin {
let is_loose_vs_archive = matches!(
(&collision.winner_origin, &collision.loser_origin),
(FileOrigin::Loose, FileOrigin::Archive { .. })
| (FileOrigin::Archive { .. }, FileOrigin::Loose)
);
if is_loose_vs_archive {
all_loose_vs_archive.push(collision.clone());
}
}
*mod_overridden_count.entry(loser_id.clone()).or_default() += 1;
mod_overridden_by
.entry(loser_id.clone())
.or_default()
.insert(winner_id.clone());
let key = order_pair(loser_id, winner_id, &priority_rank);
pair_map.entry(key).or_default().push(collision);
}
}
let mut pairs: Vec<ModPairCollision> = pair_map
.into_iter()
.map(|((loser, winner), files)| {
let max_severity = files
.iter()
.map(|f| f.severity)
.max()
.unwrap_or(CollisionSeverity::Unknown);
ModPairCollision {
loser,
winner,
files,
max_severity,
}
})
.collect();
pairs.sort_by(|a, b| {
b.max_severity
.cmp(&a.max_severity)
.then_with(|| b.files.len().cmp(&a.files.len()))
});
let mut redundant_files = Vec::new();
for (file_path, providers) in &conflict_map.files {
if providers.len() < 2 {
continue;
}
let winner = conflict_map.winner_for(file_path, priority_order, hidden);
for provider in providers {
if winner.as_ref() != Some(provider) {
redundant_files.push((provider.clone(), file_path.clone()));
}
}
}
let shadowed_mods: Vec<ShadowedMod> = mod_file_count
.iter()
.filter_map(|(mod_id, &total)| {
let overridden = mod_overridden_count.get(mod_id).copied().unwrap_or(0);
if overridden >= total && total > 0 {
let shadowed_by = mod_overridden_by
.get(mod_id)
.map(|s| s.iter().cloned().collect())
.unwrap_or_default();
Some(ShadowedMod {
mod_id: mod_id.clone(),
shadowed_by,
file_count: total,
})
} else {
None
}
})
.collect();
CollisionReport {
pairs,
redundant_files,
shadowed_mods,
loose_vs_archive: all_loose_vs_archive,
total_collisions,
}
}
fn order_pair(
a: &ModId,
b: &ModId,
priority_rank: &HashMap<&ModId, usize>,
) -> (ModId, ModId) {
let rank_a = priority_rank.get(a).copied().unwrap_or(0);
let rank_b = priority_rank.get(b).copied().unwrap_or(0);
if rank_a <= rank_b {
(a.clone(), b.clone())
} else {
(b.clone(), a.clone())
}
}
#[cfg(test)]
mod tests {
use super::*;
struct TestClassifier;
impl CollisionClassifier for TestClassifier {
fn index_archive(&self, _path: &Path) -> Result<Vec<(String, u64)>> {
Ok(Vec::new())
}
fn classify_severity(&self, file_path: &str) -> CollisionSeverity {
let ext = file_path.rsplit('.').next().unwrap_or("");
match ext {
"esp" | "esm" | "dll" => CollisionSeverity::Dangerous,
"ini" | "cfg" => CollisionSeverity::Config,
"dds" | "nif" => CollisionSeverity::Cosmetic,
_ => CollisionSeverity::Unknown,
}
}
fn archive_extensions(&self) -> &[&str] {
&["bsa"]
}
}
fn mod_id(s: &str) -> ModId {
ModId::from(s)
}
#[test]
fn no_collisions_produces_empty_report() {
let mut cm = ConflictMap::default();
cm.register("textures/sky.dds".into(), mod_id("mod_a"));
cm.register("textures/ground.dds".into(), mod_id("mod_b"));
let order = vec![mod_id("mod_a"), mod_id("mod_b")];
let hidden = HashSet::new();
let origins = HashMap::new();
let report = analyze_collisions(&cm, &order, &hidden, &origins, &TestClassifier);
assert!(report.pairs.is_empty());
assert_eq!(report.total_collisions, 0);
}
#[test]
fn simple_collision_detected() {
let mut cm = ConflictMap::default();
cm.register("textures/sky.dds".into(), mod_id("mod_a"));
cm.register("textures/sky.dds".into(), mod_id("mod_b"));
let order = vec![mod_id("mod_a"), mod_id("mod_b")];
let hidden = HashSet::new();
let origins = HashMap::new();
let report = analyze_collisions(&cm, &order, &hidden, &origins, &TestClassifier);
assert_eq!(report.pairs.len(), 1);
assert_eq!(report.total_collisions, 1);
assert_eq!(report.pairs[0].winner, mod_id("mod_b"));
assert_eq!(report.pairs[0].loser, mod_id("mod_a"));
assert_eq!(report.pairs[0].max_severity, CollisionSeverity::Cosmetic);
}
#[test]
fn dangerous_collision_severity() {
let mut cm = ConflictMap::default();
cm.register("scripts/combat.esp".into(), mod_id("mod_a"));
cm.register("scripts/combat.esp".into(), mod_id("mod_b"));
let order = vec![mod_id("mod_a"), mod_id("mod_b")];
let hidden = HashSet::new();
let origins = HashMap::new();
let report = analyze_collisions(&cm, &order, &hidden, &origins, &TestClassifier);
assert_eq!(report.pairs[0].max_severity, CollisionSeverity::Dangerous);
}
#[test]
fn shadowed_mod_detected() {
let mut cm = ConflictMap::default();
cm.register("textures/sky.dds".into(), mod_id("mod_a"));
cm.register("textures/sky.dds".into(), mod_id("mod_b"));
cm.register("textures/ground.dds".into(), mod_id("mod_a"));
cm.register("textures/ground.dds".into(), mod_id("mod_b"));
let order = vec![mod_id("mod_a"), mod_id("mod_b")];
let hidden = HashSet::new();
let origins = HashMap::new();
let report = analyze_collisions(&cm, &order, &hidden, &origins, &TestClassifier);
assert_eq!(report.shadowed_mods.len(), 1);
assert_eq!(report.shadowed_mods[0].mod_id, mod_id("mod_a"));
assert_eq!(report.shadowed_mods[0].file_count, 2);
}
#[test]
fn redundant_files_tracked() {
let mut cm = ConflictMap::default();
cm.register("textures/sky.dds".into(), mod_id("mod_a"));
cm.register("textures/sky.dds".into(), mod_id("mod_b"));
let order = vec![mod_id("mod_a"), mod_id("mod_b")];
let hidden = HashSet::new();
let origins = HashMap::new();
let report = analyze_collisions(&cm, &order, &hidden, &origins, &TestClassifier);
assert_eq!(report.redundant_files.len(), 1);
assert_eq!(report.redundant_files[0].0, mod_id("mod_a"));
}
#[test]
fn loose_vs_archive_detected() {
let mut cm = ConflictMap::default();
cm.register("textures/sky.dds".into(), mod_id("mod_a"));
cm.register("textures/sky.dds".into(), mod_id("mod_b"));
let order = vec![mod_id("mod_a"), mod_id("mod_b")];
let hidden = HashSet::new();
let mut origins: OriginMap = HashMap::new();
origins.entry("textures/sky.dds".into()).or_default().insert(
mod_id("mod_a"),
FileOrigin::Archive {
archive_rel: "mod_a.bsa".into(),
},
);
origins
.entry("textures/sky.dds".into())
.or_default()
.insert(mod_id("mod_b"), FileOrigin::Loose);
let report = analyze_collisions(&cm, &order, &hidden, &origins, &TestClassifier);
assert_eq!(report.loose_vs_archive.len(), 1);
}
#[test]
fn three_way_collision() {
let mut cm = ConflictMap::default();
cm.register("textures/sky.dds".into(), mod_id("mod_a"));
cm.register("textures/sky.dds".into(), mod_id("mod_b"));
cm.register("textures/sky.dds".into(), mod_id("mod_c"));
let order = vec![mod_id("mod_a"), mod_id("mod_b"), mod_id("mod_c")];
let hidden = HashSet::new();
let origins = HashMap::new();
let report = analyze_collisions(&cm, &order, &hidden, &origins, &TestClassifier);
assert_eq!(report.total_collisions, 2);
assert_eq!(report.pairs.len(), 2);
}
#[test]
fn hidden_file_excluded_from_winner() {
let mut cm = ConflictMap::default();
cm.register("textures/sky.dds".into(), mod_id("mod_a"));
cm.register("textures/sky.dds".into(), mod_id("mod_b"));
let order = vec![mod_id("mod_a"), mod_id("mod_b")];
let mut hidden = HashSet::new();
hidden.insert(("mod_b".to_string(), "textures/sky.dds".to_string()));
let origins = HashMap::new();
let report = analyze_collisions(&cm, &order, &hidden, &origins, &TestClassifier);
assert_eq!(report.pairs.len(), 1);
assert_eq!(report.pairs[0].files[0].winner, mod_id("mod_a"));
}
}