1use std::fs;
13use std::path::{Path, PathBuf};
14
15use super::fs::walk_files;
16use super::types::{InstallMethod, InstallPlan, InstallerError, InstallerResult, StagedFile};
17
18pub fn execute(
26 plan: &mut InstallPlan,
27 extracted_dir: &Path,
28 store_mod_dir: &Path,
29) -> InstallerResult<Vec<StagedFile>> {
30 let source_root = if let Some(ref strip) = plan.strip_prefix {
31 extracted_dir.join(strip)
32 } else {
33 extracted_dir.to_path_buf()
34 };
35
36 fs::create_dir_all(store_mod_dir)?;
37
38 let files = match &plan.method {
39 InstallMethod::BareExtract => stage_tree(&source_root, store_mod_dir, None)?,
40
41 InstallMethod::REDmod { manifest: _ } => {
42 let mod_name = store_mod_dir
47 .file_name()
48 .and_then(|n| n.to_str())
49 .unwrap_or("redmod");
50 let nested = store_mod_dir.join("mods").join(mod_name);
51 fs::create_dir_all(&nested)?;
52 stage_tree(&source_root, &nested, Some(PathBuf::from("mods").join(mod_name)))?
53 }
54
55 InstallMethod::DllOverlay { .. } => {
56 let overlay_dir = store_mod_dir.join("__dll_overlay__");
59 fs::create_dir_all(&overlay_dir)?;
60 stage_tree(
61 &source_root,
62 &overlay_dir,
63 Some(PathBuf::from("__dll_overlay__")),
64 )?
65 }
66
67 InstallMethod::Fomod { module_config, config_toml } => {
68 let config_str = match config_toml {
69 Some(s) => s,
70 None => return Err(InstallerError::RequiresUserInput { method: "fomod" }),
71 };
72
73 let decl: fomod_oxide::DeclarativeConfig = toml::from_str(config_str)
75 .map_err(|e| InstallerError::FomodError(format!("invalid config TOML: {e}")))?;
76
77 let xml_path = source_root.join(module_config);
79 let xml = fs::read_to_string(&xml_path)
80 .map_err(|e| InstallerError::FomodError(format!(
81 "cannot read {}: {e}", xml_path.display()
82 )))?;
83 let module_cfg = fomod_oxide::ModuleConfig::parse(&xml)
84 .map_err(|e| InstallerError::FomodError(format!("FOMOD parse error: {e}")))?;
85
86 let mut installer = fomod_oxide::Installer::new(module_cfg);
88 decl.apply(&xml, &mut installer)
89 .map_err(|e| InstallerError::FomodError(format!("FOMOD apply error: {e}")))?;
90 let fomod_plan = installer.resolve();
91
92 let mut out = Vec::new();
94 for op in &fomod_plan.operations {
95 let src_path = source_root.join(&op.source);
96 if op.is_folder {
97 if src_path.is_dir() {
98 let dest_base = if op.destination.is_empty() {
99 store_mod_dir.join(&op.source)
100 } else {
101 store_mod_dir.join(&op.destination)
102 };
103 out.extend(stage_tree(&src_path, &dest_base, None)?);
104 }
105 } else if src_path.is_file() {
106 let dest = if op.destination.is_empty() {
107 store_mod_dir.join(&op.source)
108 } else {
109 store_mod_dir.join(&op.destination)
110 };
111 if let Some(parent) = dest.parent() {
112 fs::create_dir_all(parent)?;
113 }
114 if fs::rename(&src_path, &dest).is_err() {
115 fs::copy(&src_path, &dest)?;
116 let _ = fs::remove_file(&src_path);
117 }
118 let size = fs::metadata(&dest).map(|m| m.len()).unwrap_or(0);
119 out.push(StagedFile {
120 rel_path: dest_rel(store_mod_dir, &dest),
121 origin_rel_path: op.source.clone(),
122 size,
123 merge_group: None,
124 });
125 }
126 }
127 out
128 }
129
130 InstallMethod::Bain { selected_subdirs } => {
131 if selected_subdirs.is_empty() {
132 return Err(InstallerError::RequiresUserInput { method: "bain" });
133 }
134 let mut out = Vec::new();
135 for subdir in selected_subdirs {
136 let sub_src = source_root.join(subdir);
137 if !sub_src.exists() {
138 return Err(InstallerError::MissingFile(subdir.clone()));
139 }
140 let sub_dest = store_mod_dir;
141 out.extend(stage_tree(&sub_src, sub_dest, Some(PathBuf::from(subdir)))?);
142 }
143 out
144 }
145
146 InstallMethod::ScriptMerge { merge_group, base } => {
147 let mut inner_plan = InstallPlan {
151 method: (**base).clone(),
152 strip_prefix: plan.strip_prefix.clone(),
153 source_archive_hash: plan.source_archive_hash.clone(),
154 staged_files: Vec::new(),
155 };
156 let mut files = execute(&mut inner_plan, extracted_dir, store_mod_dir)?;
157 for f in &mut files {
158 f.merge_group = Some(merge_group.clone());
159 }
160 files
161 }
162
163 InstallMethod::Unknown { reason } => {
164 return Err(InstallerError::UnknownMethod {
165 reason: reason.clone(),
166 });
167 }
168 };
169
170 plan.staged_files = files.clone();
171 Ok(files)
172}
173
174fn stage_tree(
179 src: &Path,
180 dest: &Path,
181 origin_prefix: Option<PathBuf>,
182) -> InstallerResult<Vec<StagedFile>> {
183 let mut out = Vec::new();
184 let files = walk_files(src)?;
185 for (abs, rel) in files {
186 let dest_path = dest.join(&rel);
187 if let Some(parent) = dest_path.parent() {
188 fs::create_dir_all(parent)?;
189 }
190 if fs::rename(&abs, &dest_path).is_err() {
193 fs::copy(&abs, &dest_path)?;
194 let _ = fs::remove_file(&abs);
195 }
196 let size = fs::metadata(&dest_path).map(|m| m.len()).unwrap_or(0);
197 let origin_rel_path = match &origin_prefix {
198 Some(p) => p.join(&rel).to_string_lossy().to_string(),
199 None => rel.to_string_lossy().to_string(),
200 };
201 out.push(StagedFile {
202 rel_path: dest_rel(dest, &dest_path),
203 origin_rel_path,
204 size,
205 merge_group: None,
206 });
207 }
208 Ok(out)
209}
210
211fn dest_rel(dest_root: &Path, dest_path: &Path) -> String {
212 dest_path
213 .strip_prefix(dest_root)
214 .map(|p| p.to_string_lossy().to_string())
215 .unwrap_or_else(|_| dest_path.to_string_lossy().to_string())
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221 use std::io::Write as _;
222
223 fn touch(p: &Path, body: &[u8]) {
224 if let Some(parent) = p.parent() {
225 fs::create_dir_all(parent).unwrap();
226 }
227 let mut f = fs::File::create(p).unwrap();
228 f.write_all(body).unwrap();
229 }
230
231 #[test]
232 fn bare_extract_moves_files_into_store() {
233 let tmp = tempfile::tempdir().unwrap();
234 let staging = tmp.path().join("staging");
235 let store = tmp.path().join("store");
236 touch(&staging.join("Data").join("foo.esp"), b"hello");
237 touch(&staging.join("readme.md"), b"hi");
238
239 let mut plan = InstallPlan {
240 method: InstallMethod::BareExtract,
241 strip_prefix: None,
242 source_archive_hash: "h".into(),
243 staged_files: vec![],
244 };
245 let files = execute(&mut plan, &staging, &store).unwrap();
246 assert_eq!(files.len(), 2);
247 assert!(store.join("Data/foo.esp").exists());
248 assert!(store.join("readme.md").exists());
249 assert!(plan.staged_files.len() == 2);
250 }
251
252 #[test]
253 fn strip_prefix_is_applied() {
254 let tmp = tempfile::tempdir().unwrap();
255 let staging = tmp.path().join("staging");
256 let store = tmp.path().join("store");
257 touch(&staging.join("ModName-1.0/Data/foo.esp"), b"hello");
258
259 let mut plan = InstallPlan {
260 method: InstallMethod::BareExtract,
261 strip_prefix: Some(PathBuf::from("ModName-1.0")),
262 source_archive_hash: "h".into(),
263 staged_files: vec![],
264 };
265 let files = execute(&mut plan, &staging, &store).unwrap();
266 assert_eq!(files.len(), 1);
267 assert!(store.join("Data/foo.esp").exists());
268 assert_eq!(files[0].rel_path, "Data/foo.esp");
269 }
270
271 #[test]
272 fn fomod_without_config_requires_user_input() {
273 let tmp = tempfile::tempdir().unwrap();
274 let staging = tmp.path().join("staging");
275 let store = tmp.path().join("store");
276 touch(&staging.join("fomod/ModuleConfig.xml"), b"<config/>");
277 touch(&staging.join("Data/foo.esp"), b"hello");
278
279 let mut plan = InstallPlan {
280 method: InstallMethod::Fomod {
281 module_config: PathBuf::from("fomod/ModuleConfig.xml"),
282 config_toml: None,
283 },
284 strip_prefix: None,
285 source_archive_hash: "h".into(),
286 staged_files: vec![],
287 };
288 let err = execute(&mut plan, &staging, &store).unwrap_err();
289 assert!(matches!(err, InstallerError::RequiresUserInput { .. }));
290 }
291
292 #[test]
293 fn unknown_returns_unknown_method() {
294 let tmp = tempfile::tempdir().unwrap();
295 let staging = tmp.path().join("staging");
296 let store = tmp.path().join("store");
297 touch(&staging.join("blob.bin"), b"x");
298
299 let mut plan = InstallPlan {
300 method: InstallMethod::Unknown {
301 reason: "test".into(),
302 },
303 strip_prefix: None,
304 source_archive_hash: "h".into(),
305 staged_files: vec![],
306 };
307 let err = execute(&mut plan, &staging, &store).unwrap_err();
308 assert!(matches!(err, InstallerError::UnknownMethod { .. }));
309 }
310
311 #[test]
312 fn script_merge_tags_files() {
313 let tmp = tempfile::tempdir().unwrap();
314 let staging = tmp.path().join("staging");
315 let store = tmp.path().join("store");
316 touch(&staging.join("Data/foo.esp"), b"hello");
317
318 let mut plan = InstallPlan {
319 method: InstallMethod::ScriptMerge {
320 merge_group: "quest-scripts".into(),
321 base: Box::new(InstallMethod::BareExtract),
322 },
323 strip_prefix: None,
324 source_archive_hash: "h".into(),
325 staged_files: vec![],
326 };
327 let files = execute(&mut plan, &staging, &store).unwrap();
328 assert_eq!(files.len(), 1);
329 assert_eq!(files[0].merge_group.as_deref(), Some("quest-scripts"));
330 }
331}