use std::fs;
use std::path::{Path, PathBuf};
use super::fs::walk_files;
use super::types::{InstallMethod, InstallPlan, InstallerError, InstallerResult, StagedFile};
pub fn execute(
plan: &mut InstallPlan,
extracted_dir: &Path,
store_mod_dir: &Path,
) -> InstallerResult<Vec<StagedFile>> {
let source_root = if let Some(ref strip) = plan.strip_prefix {
extracted_dir.join(strip)
} else {
extracted_dir.to_path_buf()
};
fs::create_dir_all(store_mod_dir)?;
let files = match &plan.method {
InstallMethod::BareExtract => stage_tree(&source_root, store_mod_dir, None)?,
InstallMethod::REDmod { manifest: _ } => {
let mod_name = store_mod_dir
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("redmod");
let nested = store_mod_dir.join("mods").join(mod_name);
fs::create_dir_all(&nested)?;
stage_tree(&source_root, &nested, Some(PathBuf::from("mods").join(mod_name)))?
}
InstallMethod::DllOverlay { .. } => {
let overlay_dir = store_mod_dir.join("__dll_overlay__");
fs::create_dir_all(&overlay_dir)?;
stage_tree(
&source_root,
&overlay_dir,
Some(PathBuf::from("__dll_overlay__")),
)?
}
InstallMethod::Fomod { module_config, config_toml } => {
let config_str = match config_toml {
Some(s) => s,
None => return Err(InstallerError::RequiresUserInput { method: "fomod" }),
};
let decl: fomod_oxide::DeclarativeConfig = toml::from_str(config_str)
.map_err(|e| InstallerError::FomodError(format!("invalid config TOML: {e}")))?;
let xml_path = source_root.join(module_config);
let xml = fs::read_to_string(&xml_path)
.map_err(|e| InstallerError::FomodError(format!(
"cannot read {}: {e}", xml_path.display()
)))?;
let module_cfg = fomod_oxide::ModuleConfig::parse(&xml)
.map_err(|e| InstallerError::FomodError(format!("FOMOD parse error: {e}")))?;
let mut installer = fomod_oxide::Installer::new(module_cfg);
decl.apply(&xml, &mut installer)
.map_err(|e| InstallerError::FomodError(format!("FOMOD apply error: {e}")))?;
let fomod_plan = installer.resolve();
let mut out = Vec::new();
for op in &fomod_plan.operations {
let src_path = source_root.join(&op.source);
if op.is_folder {
if src_path.is_dir() {
let dest_base = if op.destination.is_empty() {
store_mod_dir.join(&op.source)
} else {
store_mod_dir.join(&op.destination)
};
out.extend(stage_tree(&src_path, &dest_base, None)?);
}
} else if src_path.is_file() {
let dest = if op.destination.is_empty() {
store_mod_dir.join(&op.source)
} else {
store_mod_dir.join(&op.destination)
};
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)?;
}
if fs::rename(&src_path, &dest).is_err() {
fs::copy(&src_path, &dest)?;
let _ = fs::remove_file(&src_path);
}
let size = fs::metadata(&dest).map(|m| m.len()).unwrap_or(0);
out.push(StagedFile {
rel_path: dest_rel(store_mod_dir, &dest),
origin_rel_path: op.source.clone(),
size,
merge_group: None,
});
}
}
out
}
InstallMethod::Bain { selected_subdirs } => {
if selected_subdirs.is_empty() {
return Err(InstallerError::RequiresUserInput { method: "bain" });
}
let mut out = Vec::new();
for subdir in selected_subdirs {
let sub_src = source_root.join(subdir);
if !sub_src.exists() {
return Err(InstallerError::MissingFile(subdir.clone()));
}
let sub_dest = store_mod_dir;
out.extend(stage_tree(&sub_src, sub_dest, Some(PathBuf::from(subdir)))?);
}
out
}
InstallMethod::ScriptMerge { merge_group, base } => {
let mut inner_plan = InstallPlan {
method: (**base).clone(),
strip_prefix: plan.strip_prefix.clone(),
source_archive_hash: plan.source_archive_hash.clone(),
staged_files: Vec::new(),
};
let mut files = execute(&mut inner_plan, extracted_dir, store_mod_dir)?;
for f in &mut files {
f.merge_group = Some(merge_group.clone());
}
files
}
InstallMethod::Unknown { reason } => {
return Err(InstallerError::UnknownMethod {
reason: reason.clone(),
});
}
};
plan.staged_files = files.clone();
Ok(files)
}
fn stage_tree(
src: &Path,
dest: &Path,
origin_prefix: Option<PathBuf>,
) -> InstallerResult<Vec<StagedFile>> {
let mut out = Vec::new();
let files = walk_files(src)?;
for (abs, rel) in files {
let dest_path = dest.join(&rel);
if let Some(parent) = dest_path.parent() {
fs::create_dir_all(parent)?;
}
if fs::rename(&abs, &dest_path).is_err() {
fs::copy(&abs, &dest_path)?;
let _ = fs::remove_file(&abs);
}
let size = fs::metadata(&dest_path).map(|m| m.len()).unwrap_or(0);
let origin_rel_path = match &origin_prefix {
Some(p) => p.join(&rel).to_string_lossy().to_string(),
None => rel.to_string_lossy().to_string(),
};
out.push(StagedFile {
rel_path: dest_rel(dest, &dest_path),
origin_rel_path,
size,
merge_group: None,
});
}
Ok(out)
}
fn dest_rel(dest_root: &Path, dest_path: &Path) -> String {
dest_path
.strip_prefix(dest_root)
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| dest_path.to_string_lossy().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write as _;
fn touch(p: &Path, body: &[u8]) {
if let Some(parent) = p.parent() {
fs::create_dir_all(parent).unwrap();
}
let mut f = fs::File::create(p).unwrap();
f.write_all(body).unwrap();
}
#[test]
fn bare_extract_moves_files_into_store() {
let tmp = tempfile::tempdir().unwrap();
let staging = tmp.path().join("staging");
let store = tmp.path().join("store");
touch(&staging.join("Data").join("foo.esp"), b"hello");
touch(&staging.join("readme.md"), b"hi");
let mut plan = InstallPlan {
method: InstallMethod::BareExtract,
strip_prefix: None,
source_archive_hash: "h".into(),
staged_files: vec![],
};
let files = execute(&mut plan, &staging, &store).unwrap();
assert_eq!(files.len(), 2);
assert!(store.join("Data/foo.esp").exists());
assert!(store.join("readme.md").exists());
assert!(plan.staged_files.len() == 2);
}
#[test]
fn strip_prefix_is_applied() {
let tmp = tempfile::tempdir().unwrap();
let staging = tmp.path().join("staging");
let store = tmp.path().join("store");
touch(&staging.join("ModName-1.0/Data/foo.esp"), b"hello");
let mut plan = InstallPlan {
method: InstallMethod::BareExtract,
strip_prefix: Some(PathBuf::from("ModName-1.0")),
source_archive_hash: "h".into(),
staged_files: vec![],
};
let files = execute(&mut plan, &staging, &store).unwrap();
assert_eq!(files.len(), 1);
assert!(store.join("Data/foo.esp").exists());
assert_eq!(files[0].rel_path, "Data/foo.esp");
}
#[test]
fn fomod_without_config_requires_user_input() {
let tmp = tempfile::tempdir().unwrap();
let staging = tmp.path().join("staging");
let store = tmp.path().join("store");
touch(&staging.join("fomod/ModuleConfig.xml"), b"<config/>");
touch(&staging.join("Data/foo.esp"), b"hello");
let mut plan = InstallPlan {
method: InstallMethod::Fomod {
module_config: PathBuf::from("fomod/ModuleConfig.xml"),
config_toml: None,
},
strip_prefix: None,
source_archive_hash: "h".into(),
staged_files: vec![],
};
let err = execute(&mut plan, &staging, &store).unwrap_err();
assert!(matches!(err, InstallerError::RequiresUserInput { .. }));
}
#[test]
fn unknown_returns_unknown_method() {
let tmp = tempfile::tempdir().unwrap();
let staging = tmp.path().join("staging");
let store = tmp.path().join("store");
touch(&staging.join("blob.bin"), b"x");
let mut plan = InstallPlan {
method: InstallMethod::Unknown {
reason: "test".into(),
},
strip_prefix: None,
source_archive_hash: "h".into(),
staged_files: vec![],
};
let err = execute(&mut plan, &staging, &store).unwrap_err();
assert!(matches!(err, InstallerError::UnknownMethod { .. }));
}
#[test]
fn script_merge_tags_files() {
let tmp = tempfile::tempdir().unwrap();
let staging = tmp.path().join("staging");
let store = tmp.path().join("store");
touch(&staging.join("Data/foo.esp"), b"hello");
let mut plan = InstallPlan {
method: InstallMethod::ScriptMerge {
merge_group: "quest-scripts".into(),
base: Box::new(InstallMethod::BareExtract),
},
strip_prefix: None,
source_archive_hash: "h".into(),
staged_files: vec![],
};
let files = execute(&mut plan, &staging, &store).unwrap();
assert_eq!(files.len(), 1);
assert_eq!(files[0].merge_group.as_deref(), Some("quest-scripts"));
}
}