use std::collections::{HashMap, HashSet};
use crate::manifest::wabbajack::{
ArchiveEntry, ArchiveState, InstallDirective, WabbajackManifest, compute_manifest_hash,
};
use crate::nexus_id::{NexusFileId, NexusModId};
use crate::profile::{EnabledMod, LoadOrderLock, LockReason, Profile};
#[must_use]
pub fn archive_mod_id(archive: &ArchiveEntry) -> String {
if let Some(ArchiveState::NexusDownloader {
game_name,
mod_id,
file_id,
}) = archive.state.as_ref()
{
format!("nexus_{game_name}_{mod_id}_{file_id}")
} else {
format!("wj_{}", archive.hash)
}
}
pub struct ManifestMatch {
pub mod_id: String,
pub display_name: String,
pub archive_name: String,
pub archive_hash: u64,
pub total_files: usize,
pub present_files: usize,
pub confidence: f32,
pub nexus_mod_id: Option<NexusModId>,
pub nexus_file_id: Option<NexusFileId>,
pub nexus_game_domain: Option<String>,
pub covered_paths: Vec<String>,
}
#[must_use]
pub fn match_wabbajack_manifest(
manifest: &WabbajackManifest,
on_disk_files: &HashSet<String>,
threshold: f32,
) -> Vec<ManifestMatch> {
let directives = manifest.install_directives();
let mut archive_files: HashMap<u64, Vec<String>> = HashMap::new();
let mut archive_mod_names: HashMap<u64, String> = HashMap::new();
for d in &directives {
match d {
InstallDirective::FromArchive {
archive_hash, to, ..
}
| InstallDirective::PatchedFromArchive {
archive_hash, to, ..
} => {
let normalized = to.replace('\\', "/");
if archive_mod_names.get(archive_hash).is_none()
&& let Some(name) = extract_mo2_mod_name(&normalized)
{
archive_mod_names.insert(*archive_hash, name);
}
let game_relative = strip_mo2_prefix(&normalized.to_lowercase());
archive_files
.entry(*archive_hash)
.or_default()
.push(game_relative);
}
_ => {}
}
}
let archive_map: HashMap<u64, &crate::manifest::wabbajack::ArchiveEntry> =
manifest.archives.iter().map(|a| (a.hash, a)).collect();
let mut results = Vec::new();
for (hash, files) in &archive_files {
let total = files.len();
if total == 0 {
continue;
}
let present_paths: Vec<String> = files
.iter()
.filter(|path| on_disk_files.contains(path.as_str()))
.cloned()
.collect();
let present = present_paths.len();
let fraction = present as f32 / total as f32;
if fraction < threshold {
continue;
}
let archive = archive_map.get(hash);
let archive_name = archive.map_or_else(|| format!("unknown_{hash}"), |a| a.name.clone());
let display_name = clean_archive_name(&archive_name);
let (nexus_mod_id, nexus_file_id, nexus_game_domain) = archive
.and_then(|a| a.state.as_ref())
.map_or((None, None, None), |state| match state {
ArchiveState::NexusDownloader {
game_name,
mod_id,
file_id,
} => (Some(*mod_id), Some(*file_id), Some(game_name.clone())),
_ => (None, None, None),
});
let mod_id = match archive {
Some(a) => archive_mod_id(a),
None => format!("wj_{hash}"),
};
results.push(ManifestMatch {
mod_id,
display_name,
archive_name,
archive_hash: *hash,
total_files: total,
present_files: present,
confidence: fraction,
nexus_mod_id,
nexus_file_id,
nexus_game_domain,
covered_paths: present_paths,
});
}
results.sort_by(|a, b| {
a.display_name
.to_lowercase()
.cmp(&b.display_name.to_lowercase())
});
results
}
#[must_use]
pub fn manifest_match_to_enabled(m: &ManifestMatch) -> EnabledMod {
EnabledMod {
mod_id: m.mod_id.clone(),
display_name: Some(m.display_name.clone()),
enabled: true,
version: None,
fomod_config: None,
nexus_mod_id: m.nexus_mod_id,
nexus_file_id: m.nexus_file_id,
nexus_game_domain: m.nexus_game_domain.clone(),
installed_timestamp: Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64,
),
..Default::default()
}
}
fn extract_mo2_mod_name(path: &str) -> Option<String> {
let rest = path.strip_prefix("mods/")?;
let end = rest.find('/')?;
let name = &rest[..end];
if name.is_empty() {
return None;
}
Some(name.to_string())
}
fn strip_mo2_prefix(path: &str) -> String {
if let Some(rest) = path.strip_prefix("mods/")
&& let Some(idx) = rest.find('/')
{
return rest[idx + 1..].to_string();
}
path.to_string()
}
fn clean_archive_name(name: &str) -> String {
let stem = name.rsplit_once('.').map_or(name, |(s, _)| s);
if let Some(idx) = stem
.find('-')
.filter(|&i| stem[i + 1..].starts_with(|c: char| c.is_ascii_digit()))
{
stem[..idx].replace('_', " ")
} else {
stem.replace('_', " ")
}
}
#[must_use]
pub fn manifest_directive_order(manifest: &WabbajackManifest) -> Vec<String> {
let archive_by_hash: HashMap<u64, &ArchiveEntry> =
manifest.archives.iter().map(|a| (a.hash, a)).collect();
let mut seen: HashSet<u64> = HashSet::new();
let mut order: Vec<String> = Vec::new();
for d in manifest.install_directives() {
let hash = match d {
InstallDirective::FromArchive { archive_hash, .. }
| InstallDirective::PatchedFromArchive { archive_hash, .. } => archive_hash,
_ => continue,
};
if !seen.insert(hash) {
continue;
}
if let Some(archive) = archive_by_hash.get(&hash) {
order.push(archive_mod_id(archive));
}
}
order
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WabbajackLockApplied {
pub manifest_hash: String,
pub matched: usize,
pub unmatched: usize,
pub replaced_existing_lock: bool,
}
pub fn apply_wabbajack_lock(
profile: &mut Profile,
manifest: &WabbajackManifest,
) -> WabbajackLockApplied {
let manifest_order = manifest_directive_order(manifest);
let manifest_rank: HashMap<String, usize> = manifest_order
.iter()
.enumerate()
.map(|(i, mid)| (mid.clone(), i))
.collect();
let (mut matched, unmatched): (Vec<EnabledMod>, Vec<EnabledMod>) =
std::mem::take(&mut profile.mods)
.into_iter()
.partition(|m| manifest_rank.contains_key(&m.mod_id));
matched.sort_by_key(|m| manifest_rank.get(&m.mod_id).copied().unwrap_or(usize::MAX));
let matched_count = matched.len();
let unmatched_count = unmatched.len();
profile.mods = matched;
profile.mods.extend(unmatched);
let manifest_hash = compute_manifest_hash(manifest);
let replaced_existing_lock = profile.load_order_lock.is_some();
profile.load_order_lock = Some(LoadOrderLock::now(LockReason::Wabbajack {
manifest_hash: manifest_hash.clone(),
}));
WabbajackLockApplied {
manifest_hash,
matched: matched_count,
unmatched: unmatched_count,
replaced_existing_lock,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ModFootprint {
Directory(String),
File(String),
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DuplicateReport {
pub leaked: Vec<String>,
pub genuine: Vec<String>,
}
pub fn detect_stale_duplicates<F>(
profile: &Profile,
manifest: &WabbajackManifest,
mod_id_to_footprint: F,
) -> DuplicateReport
where
F: Fn(&str) -> Option<ModFootprint>,
{
let mut covered_files: HashSet<String> = HashSet::new();
for d in manifest.install_directives() {
let to = match d {
InstallDirective::FromArchive { to, .. }
| InstallDirective::PatchedFromArchive { to, .. } => to,
_ => continue,
};
let normalized = to.replace('\\', "/").to_lowercase();
covered_files.insert(strip_mo2_prefix(&normalized));
}
let mut covered_dirs: HashSet<String> = HashSet::new();
for f in &covered_files {
let mut cur = f.as_str();
while let Some(idx) = cur.rfind('/') {
cur = &cur[..idx];
covered_dirs.insert(format!("{cur}/"));
}
}
let mut report = DuplicateReport::default();
for m in &profile.mods {
let footprint = match mod_id_to_footprint(&m.mod_id) {
Some(fp) => fp,
None => continue, };
let covered = match &footprint {
ModFootprint::Directory(d) => covered_dirs.contains(d),
ModFootprint::File(f) => covered_files.contains(f),
};
if covered {
report.leaked.push(m.mod_id.clone());
} else {
report.genuine.push(m.mod_id.clone());
}
}
report
}
pub fn discovered_to_enabled(
mod_id: &str,
display_name: &str,
version: Option<&str>,
_confidence: f32,
) -> EnabledMod {
EnabledMod {
mod_id: mod_id.to_string(),
display_name: Some(display_name.to_string()),
enabled: true,
version: version.map(String::from),
..Default::default()
}
}