1use 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#[derive(Debug, Clone, PartialEq, Eq, Hash)]
22pub enum FileOrigin {
23 Loose,
25 Archive { archive_rel: String },
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
31pub enum CollisionSeverity {
32 Cosmetic,
34 Config,
36 Dangerous,
38 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#[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#[derive(Debug, Clone)]
67pub struct ModPairCollision {
68 pub loser: ModId,
70 pub winner: ModId,
72 pub files: Vec<FileCollision>,
74 pub max_severity: CollisionSeverity,
76}
77
78#[derive(Debug, Clone)]
80pub struct ShadowedMod {
81 pub mod_id: ModId,
82 pub shadowed_by: Vec<ModId>,
84 pub file_count: usize,
86}
87
88#[derive(Debug, Clone, Default)]
90pub struct CollisionReport {
91 pub pairs: Vec<ModPairCollision>,
93 pub redundant_files: Vec<(ModId, String)>,
95 pub shadowed_mods: Vec<ShadowedMod>,
97 pub loose_vs_archive: Vec<FileCollision>,
99 pub total_collisions: usize,
101}
102
103pub trait CollisionClassifier: Send + Sync {
109 fn index_archive(&self, archive_path: &Path) -> Result<Vec<(String, u64)>>;
112
113 fn classify_severity(&self, file_path: &str) -> CollisionSeverity;
115
116 fn archive_extensions(&self) -> &[&str];
118}
119
120pub type OriginMap = HashMap<String, HashMap<ModId, FileOrigin>>;
124
125pub 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 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 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
199pub 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 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 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 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 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 *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 let key = order_pair(loser_id, winner_id, &priority_rank);
301 pair_map.entry(key).or_default().push(collision);
302 }
303 }
304
305 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 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 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 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
374fn 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#[cfg(test)]
392mod tests {
393 use super::*;
394
395 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 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 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 assert_eq!(report.pairs.len(), 1);
559 assert_eq!(report.pairs[0].files[0].winner, mod_id("mod_a"));
560 }
561}