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
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
162pub 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 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 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
245pub 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 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 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 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 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 *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 let key = order_pair(loser_id, winner_id, &priority_rank);
347 pair_map.entry(key).or_default().push(collision);
348 }
349 }
350
351 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 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 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 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
420fn 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#[cfg(test)]
434mod tests {
435 use super::*;
436
437 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 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 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 assert_eq!(report.pairs.len(), 1);
667 assert_eq!(report.pairs[0].files[0].winner, mod_id("mod_a"));
668 }
669}