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::StripContentRoot { root } => {
let content_root = source_root.join(root);
if !content_root.is_dir() {
return Err(InstallerError::MissingFile(root.clone()));
}
stage_tree_into(
&content_root,
store_mod_dir,
store_mod_dir,
Some(PathBuf::from(root)),
)?
}
InstallMethod::DirectoryMod { directory_name } => {
let dir_name = directory_name
.clone()
.unwrap_or_else(|| store_dir_name(store_mod_dir));
let nested = store_mod_dir.join(&dir_name);
fs::create_dir_all(&nested)?;
stage_tree_into(&source_root, &nested, store_mod_dir, None)?
}
InstallMethod::DirectoryModFromXml {
marker,
id_attr,
fallback_name,
} => {
let marker_path = source_root.join(marker);
if !marker_path.is_file() {
return Err(InstallerError::MissingFile(
marker.to_string_lossy().to_string(),
));
}
let dir_name = read_xml_attr(&marker_path, id_attr)
.or_else(|| fallback_name.clone())
.unwrap_or_else(|| store_dir_name(store_mod_dir));
let nested = store_mod_dir.join(&dir_name);
fs::create_dir_all(&nested)?;
stage_tree_into(&source_root, &nested, store_mod_dir, None)?
}
InstallMethod::MultiRootOverlay { roots } => {
let mut out = Vec::new();
for root in roots {
let root_src = source_root.join(root);
if root_src.exists() {
out.extend(stage_tree_into(
&root_src,
&store_mod_dir.join(root),
store_mod_dir,
Some(PathBuf::from(root)),
)?);
}
}
out
}
InstallMethod::SingleFileSet => {
let mut out = Vec::new();
for entry in fs::read_dir(&source_root)? {
let entry = entry?;
let path = entry.path();
if !path.is_file() {
continue;
}
let dest = store_mod_dir.join(entry.file_name());
if fs::rename(&path, &dest).is_err() {
fs::copy(&path, &dest)?;
let _ = fs::remove_file(&path);
}
let size = fs::metadata(&dest).map_or(0, |m| m.len());
out.push(StagedFile {
rel_path: dest_rel(store_mod_dir, &dest),
origin_rel_path: entry.file_name().to_string_lossy().to_string(),
size,
merge_group: None,
});
}
out
}
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_or(0, |m| m.len());
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::UserConfigOverlay { .. } => {
stage_tree(&source_root, store_mod_dir, None)?
}
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>> {
stage_tree_into(src, dest, dest, origin_prefix)
}
fn stage_tree_into(
src: &Path,
dest: &Path,
manifest_root: &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_or(0, |m| m.len());
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(manifest_root, &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_or_else(
|_| dest_path.to_string_lossy().to_string(),
|p| p.to_string_lossy().to_string(),
)
}
fn store_dir_name(store_mod_dir: &Path) -> String {
store_mod_dir
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("mod")
.to_string()
}
fn read_xml_attr(path: &Path, attr: &str) -> Option<String> {
let content = fs::read_to_string(path).ok()?;
if let Some((element, attr)) = attr.split_once('.') {
let marker = format!("<{element}");
let start = content.find(&marker)?;
let rest = &content[start..];
return read_attr_from_str(rest, attr);
}
read_attr_from_str(&content, attr)
}
fn read_attr_from_str(content: &str, attr: &str) -> Option<String> {
let needle = format!("{attr}=\"");
let start = content.find(&needle)? + needle.len();
let rest = &content[start..];
let end = rest.find('"')?;
let value = rest[..end].trim();
(!value.is_empty()).then(|| value.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"));
}
}