use std::fs;
use std::path::{Path, PathBuf};
use super::fs::find_fomod_config;
use super::probe::InstallProbe;
use super::types::{InstallMethod, InstallPlan, InstallerResult};
pub fn analyze(
extracted_dir: &Path,
probe: &InstallProbe,
source_archive_hash: String,
) -> InstallerResult<InstallPlan> {
let (effective_dir, strip_prefix) = normalize(extracted_dir)?;
let target = if let Some(ref p) = strip_prefix {
extracted_dir.join(p)
} else {
extracted_dir.to_path_buf()
};
let _ = effective_dir;
let method = detect_method(&target, probe);
Ok(InstallPlan {
method,
strip_prefix,
source_archive_hash,
staged_files: Vec::new(),
})
}
fn normalize(extracted_dir: &Path) -> InstallerResult<(PathBuf, Option<PathBuf>)> {
const CONTENT_DIR_NAMES: &[&str] = &[
"data",
"meshes",
"textures",
"scripts",
"interface",
"sound",
"music",
"materials",
"seq",
"shadersfx",
"strings",
"r6",
"archive",
"archives",
"bin",
"engine",
"mods",
"red4ext",
"fomod",
];
let mut current = extracted_dir.to_path_buf();
let mut strip: Option<PathBuf> = None;
loop {
let entries: Vec<_> = match fs::read_dir(¤t) {
Ok(rd) => rd.flatten().collect(),
Err(_) => break,
};
if entries.len() != 1 {
break;
}
let only = &entries[0];
if !only.path().is_dir() {
break;
}
let name = only.file_name();
let name_lc = name.to_string_lossy().to_lowercase();
if CONTENT_DIR_NAMES.iter().any(|d| *d == name_lc) {
break;
}
current = only.path();
strip = Some(match strip {
Some(p) => p.join(&name),
None => PathBuf::from(&name),
});
}
Ok((current, strip))
}
fn detect_method(dir: &Path, probe: &InstallProbe) -> InstallMethod {
if let Some(method) = (probe.analyze)(dir) {
return method;
}
if let Some(module_config) = find_fomod_config(dir) {
let rel = module_config
.strip_prefix(dir)
.unwrap_or(&module_config)
.to_path_buf();
return InstallMethod::Fomod {
module_config: rel,
config_toml: None,
};
}
if looks_like_bain(dir) {
return InstallMethod::Bain {
selected_subdirs: Vec::new(),
};
}
if looks_like_dll_overlay(dir) {
return InstallMethod::DllOverlay {
target_dir_hint: "game root".to_string(),
};
}
if (probe.recognizes_bare)(dir) {
return InstallMethod::BareExtract;
}
if let Some(target_id) = probe.user_config_target
&& tree_is_only_config(dir)
{
return InstallMethod::UserConfigOverlay {
target_id: target_id.to_string(),
};
}
InstallMethod::Unknown {
reason: "no matching install method — dossier should be dumped".to_string(),
}
}
const USER_CONFIG_EXTENSIONS: &[&str] =
&["ini", "cfg", "conf", "json", "toml", "yaml", "yml", "xml"];
fn tree_is_only_config(dir: &Path) -> bool {
fn visit(dir: &Path, saw_any: &mut bool) -> bool {
let Ok(rd) = fs::read_dir(dir) else {
return false;
};
for entry in rd.flatten() {
let path = entry.path();
if path.is_dir() {
if !visit(&path, saw_any) {
return false;
}
continue;
}
if !path.is_file() {
continue;
}
*saw_any = true;
let Some(ext) = path
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase)
else {
return false;
};
if !USER_CONFIG_EXTENSIONS.iter().any(|e| *e == ext) {
return false;
}
}
true
}
let mut saw_any = false;
visit(dir, &mut saw_any) && saw_any
}
fn looks_like_bain(dir: &Path) -> bool {
let Ok(entries) = fs::read_dir(dir) else {
return false;
};
let mut numbered = 0;
let mut total = 0;
for entry in entries.flatten() {
if !entry.path().is_dir() {
continue;
}
total += 1;
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.len() >= 3
&& name_str.as_bytes()[0].is_ascii_digit()
&& name_str.as_bytes()[1].is_ascii_digit()
&& (name_str.as_bytes()[2] == b' ' || name_str.as_bytes()[2] == b'_')
{
numbered += 1;
}
}
total >= 2 && numbered >= 2
}
fn looks_like_dll_overlay(dir: &Path) -> bool {
let Ok(entries) = fs::read_dir(dir) else {
return false;
};
let mut has_dll = false;
let mut has_asset_dir = false;
let asset_dirs = [
"data", "meshes", "textures", "scripts", "r6", "archive", "mods",
];
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let name = entry.file_name().to_string_lossy().to_lowercase();
if asset_dirs.iter().any(|d| *d == name) {
has_asset_dir = true;
}
} else if let Some(ext) = path.extension().and_then(|e| e.to_str())
&& ext.eq_ignore_ascii_case("dll")
{
has_dll = true;
}
}
has_dll && !has_asset_dir
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write as _;
fn touch(p: &Path) {
if let Some(parent) = p.parent() {
fs::create_dir_all(parent).unwrap();
}
let mut f = fs::File::create(p).unwrap();
f.write_all(b"x").unwrap();
}
#[test]
fn normalize_strips_single_wrapper() {
let tmp = tempfile::tempdir().unwrap();
let wrapper = tmp.path().join("ModName-1.0");
touch(&wrapper.join("Data").join("mod.esp"));
let (effective, strip) = normalize(tmp.path()).unwrap();
assert_eq!(strip.as_deref(), Some(Path::new("ModName-1.0")));
assert_eq!(effective, wrapper);
}
#[test]
fn normalize_leaves_multi_entry_root_alone() {
let tmp = tempfile::tempdir().unwrap();
touch(&tmp.path().join("Data").join("a.esp"));
touch(&tmp.path().join("readme.txt"));
let (effective, strip) = normalize(tmp.path()).unwrap();
assert!(strip.is_none());
assert_eq!(effective, tmp.path());
}
#[test]
fn detects_fomod() {
let tmp = tempfile::tempdir().unwrap();
touch(&tmp.path().join("fomod").join("ModuleConfig.xml"));
touch(&tmp.path().join("Data").join("foo.esp"));
let probe = InstallProbe::noop();
let plan = analyze(tmp.path(), &probe, "deadbeef".to_string()).unwrap();
assert!(matches!(plan.method, InstallMethod::Fomod { .. }));
}
#[test]
fn detects_bain() {
let tmp = tempfile::tempdir().unwrap();
touch(&tmp.path().join("00 Core").join("foo.esp"));
touch(&tmp.path().join("01 Option A").join("foo.esp"));
touch(&tmp.path().join("02 Option B").join("foo.esp"));
let probe = InstallProbe::noop();
let plan = analyze(tmp.path(), &probe, "h".to_string()).unwrap();
assert!(matches!(plan.method, InstallMethod::Bain { .. }));
}
#[test]
fn detects_dll_overlay() {
let tmp = tempfile::tempdir().unwrap();
touch(&tmp.path().join("hook.dll"));
touch(&tmp.path().join("hook.ini"));
let probe = InstallProbe::noop();
let plan = analyze(tmp.path(), &probe, "h".to_string()).unwrap();
assert!(matches!(plan.method, InstallMethod::DllOverlay { .. }));
}
#[test]
fn plugin_analyze_wins() {
let tmp = tempfile::tempdir().unwrap();
touch(&tmp.path().join("fomod").join("ModuleConfig.xml"));
let probe = InstallProbe::new(
|_: &Path| {
Some(InstallMethod::REDmod {
manifest: PathBuf::from("info.json"),
})
},
|_: &Path| false,
);
let plan = analyze(tmp.path(), &probe, "h".to_string()).unwrap();
assert!(matches!(plan.method, InstallMethod::REDmod { .. }));
}
#[test]
fn bare_fallback_when_plugin_says_so() {
let tmp = tempfile::tempdir().unwrap();
touch(&tmp.path().join("Data").join("foo.esp"));
let probe = InstallProbe::new(|_: &Path| None, |_: &Path| true);
let plan = analyze(tmp.path(), &probe, "h".to_string()).unwrap();
assert!(matches!(plan.method, InstallMethod::BareExtract));
}
#[test]
fn unknown_is_last_resort() {
let tmp = tempfile::tempdir().unwrap();
touch(&tmp.path().join("mystery_blob.bin"));
let probe = InstallProbe::noop();
let plan = analyze(tmp.path(), &probe, "h".to_string()).unwrap();
assert!(matches!(plan.method, InstallMethod::Unknown { .. }));
}
#[test]
fn detects_user_config_overlay() {
let tmp = tempfile::tempdir().unwrap();
touch(&tmp.path().join("Engine.ini"));
touch(&tmp.path().join("GameUserSettings.ini"));
let probe = InstallProbe::noop().with_user_config_target("test-config");
let plan = analyze(tmp.path(), &probe, "h".to_string()).unwrap();
match plan.method {
InstallMethod::UserConfigOverlay { target_id } => assert_eq!(target_id, "test-config"),
other => panic!("expected UserConfigOverlay, got {other:?}"),
}
}
#[test]
fn user_config_overlay_requires_plugin_target() {
let tmp = tempfile::tempdir().unwrap();
touch(&tmp.path().join("Engine.ini"));
let probe = InstallProbe::noop();
let plan = analyze(tmp.path(), &probe, "h".to_string()).unwrap();
assert!(matches!(plan.method, InstallMethod::Unknown { .. }));
}
#[test]
fn user_config_overlay_rejects_mixed_payloads() {
let tmp = tempfile::tempdir().unwrap();
touch(&tmp.path().join("Engine.ini"));
touch(&tmp.path().join("payload.bin"));
let probe = InstallProbe::noop().with_user_config_target("test-config");
let plan = analyze(tmp.path(), &probe, "h".to_string()).unwrap();
assert!(matches!(plan.method, InstallMethod::Unknown { .. }));
}
}