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::StripContentRoot { root } => {
42 let content_root = source_root.join(root);
43 if !content_root.is_dir() {
44 return Err(InstallerError::MissingFile(root.clone()));
45 }
46 stage_tree_into(
47 &content_root,
48 store_mod_dir,
49 store_mod_dir,
50 Some(PathBuf::from(root)),
51 )?
52 }
53
54 InstallMethod::DirectoryMod { directory_name } => {
55 let dir_name = directory_name
56 .clone()
57 .unwrap_or_else(|| store_dir_name(store_mod_dir));
58 let nested = store_mod_dir.join(&dir_name);
59 fs::create_dir_all(&nested)?;
60 stage_tree_into(&source_root, &nested, store_mod_dir, None)?
61 }
62
63 InstallMethod::DirectoryModFromXml {
64 marker,
65 id_attr,
66 fallback_name,
67 } => {
68 let marker_path = source_root.join(marker);
69 if !marker_path.is_file() {
70 return Err(InstallerError::MissingFile(
71 marker.to_string_lossy().to_string(),
72 ));
73 }
74 let dir_name = read_xml_attr(&marker_path, id_attr)
75 .or_else(|| fallback_name.clone())
76 .unwrap_or_else(|| store_dir_name(store_mod_dir));
77 let nested = store_mod_dir.join(&dir_name);
78 fs::create_dir_all(&nested)?;
79 stage_tree_into(&source_root, &nested, store_mod_dir, None)?
80 }
81
82 InstallMethod::MultiRootOverlay { roots } => {
83 let mut out = Vec::new();
84 for root in roots {
85 let root_src = source_root.join(root);
86 if root_src.exists() {
87 out.extend(stage_tree_into(
88 &root_src,
89 &store_mod_dir.join(root),
90 store_mod_dir,
91 Some(PathBuf::from(root)),
92 )?);
93 }
94 }
95 out
96 }
97
98 InstallMethod::SingleFileSet => {
99 let mut out = Vec::new();
100 for entry in fs::read_dir(&source_root)? {
101 let entry = entry?;
102 let path = entry.path();
103 if !path.is_file() {
104 continue;
105 }
106 let dest = store_mod_dir.join(entry.file_name());
107 if fs::rename(&path, &dest).is_err() {
108 fs::copy(&path, &dest)?;
109 let _ = fs::remove_file(&path);
110 }
111 let size = fs::metadata(&dest).map_or(0, |m| m.len());
112 out.push(StagedFile {
113 rel_path: dest_rel(store_mod_dir, &dest),
114 origin_rel_path: entry.file_name().to_string_lossy().to_string(),
115 size,
116 merge_group: None,
117 });
118 }
119 out
120 }
121
122 InstallMethod::REDmod { manifest: _ } => {
123 let mod_name = store_mod_dir
128 .file_name()
129 .and_then(|n| n.to_str())
130 .unwrap_or("redmod");
131 let nested = store_mod_dir.join("mods").join(mod_name);
132 fs::create_dir_all(&nested)?;
133 stage_tree(
134 &source_root,
135 &nested,
136 Some(PathBuf::from("mods").join(mod_name)),
137 )?
138 }
139
140 InstallMethod::DllOverlay { .. } => {
141 let overlay_dir = store_mod_dir.join("__dll_overlay__");
144 fs::create_dir_all(&overlay_dir)?;
145 stage_tree(
146 &source_root,
147 &overlay_dir,
148 Some(PathBuf::from("__dll_overlay__")),
149 )?
150 }
151
152 InstallMethod::Fomod {
153 module_config,
154 config_toml,
155 } => {
156 let config_str = match config_toml {
157 Some(s) => s,
158 None => return Err(InstallerError::RequiresUserInput { method: "fomod" }),
159 };
160
161 let decl: fomod_oxide::DeclarativeConfig = toml::from_str(config_str)
163 .map_err(|e| InstallerError::FomodError(format!("invalid config TOML: {e}")))?;
164
165 let xml_path = source_root.join(module_config);
167 let xml = fs::read_to_string(&xml_path).map_err(|e| {
168 InstallerError::FomodError(format!("cannot read {}: {e}", xml_path.display()))
169 })?;
170 let module_cfg = fomod_oxide::ModuleConfig::parse(&xml)
171 .map_err(|e| InstallerError::FomodError(format!("FOMOD parse error: {e}")))?;
172
173 let mut installer = fomod_oxide::Installer::new(module_cfg);
175 decl.apply(&xml, &mut installer)
176 .map_err(|e| InstallerError::FomodError(format!("FOMOD apply error: {e}")))?;
177 let fomod_plan = installer.resolve();
178
179 let mut out = Vec::new();
181 for op in &fomod_plan.operations {
182 let src_path = source_root.join(&op.source);
183 if op.is_folder {
184 if src_path.is_dir() {
185 let dest_base = if op.destination.is_empty() {
186 store_mod_dir.join(&op.source)
187 } else {
188 store_mod_dir.join(&op.destination)
189 };
190 out.extend(stage_tree(&src_path, &dest_base, None)?);
191 }
192 } else if src_path.is_file() {
193 let dest = if op.destination.is_empty() {
194 store_mod_dir.join(&op.source)
195 } else {
196 store_mod_dir.join(&op.destination)
197 };
198 if let Some(parent) = dest.parent() {
199 fs::create_dir_all(parent)?;
200 }
201 if fs::rename(&src_path, &dest).is_err() {
202 fs::copy(&src_path, &dest)?;
203 let _ = fs::remove_file(&src_path);
204 }
205 let size = fs::metadata(&dest).map_or(0, |m| m.len());
206 out.push(StagedFile {
207 rel_path: dest_rel(store_mod_dir, &dest),
208 origin_rel_path: op.source.clone(),
209 size,
210 merge_group: None,
211 });
212 }
213 }
214 out
215 }
216
217 InstallMethod::Bain { selected_subdirs } => {
218 if selected_subdirs.is_empty() {
219 return Err(InstallerError::RequiresUserInput { method: "bain" });
220 }
221 let mut out = Vec::new();
222 for subdir in selected_subdirs {
223 let sub_src = source_root.join(subdir);
224 if !sub_src.exists() {
225 return Err(InstallerError::MissingFile(subdir.clone()));
226 }
227 let sub_dest = store_mod_dir;
228 out.extend(stage_tree(&sub_src, sub_dest, Some(PathBuf::from(subdir)))?);
229 }
230 out
231 }
232
233 InstallMethod::ScriptMerge { merge_group, base } => {
234 let mut inner_plan = InstallPlan {
238 method: (**base).clone(),
239 strip_prefix: plan.strip_prefix.clone(),
240 source_archive_hash: plan.source_archive_hash.clone(),
241 staged_files: Vec::new(),
242 };
243 let mut files = execute(&mut inner_plan, extracted_dir, store_mod_dir)?;
244 for f in &mut files {
245 f.merge_group = Some(merge_group.clone());
246 }
247 files
248 }
249
250 InstallMethod::UserConfigOverlay { .. } => {
251 stage_tree(&source_root, store_mod_dir, None)?
255 }
256
257 InstallMethod::Unknown { reason } => {
258 return Err(InstallerError::UnknownMethod {
259 reason: reason.clone(),
260 });
261 }
262 };
263
264 plan.staged_files = files.clone();
265 Ok(files)
266}
267
268fn stage_tree(
273 src: &Path,
274 dest: &Path,
275 origin_prefix: Option<PathBuf>,
276) -> InstallerResult<Vec<StagedFile>> {
277 stage_tree_into(src, dest, dest, origin_prefix)
278}
279
280fn stage_tree_into(
281 src: &Path,
282 dest: &Path,
283 manifest_root: &Path,
284 origin_prefix: Option<PathBuf>,
285) -> InstallerResult<Vec<StagedFile>> {
286 let mut out = Vec::new();
287 let files = walk_files(src)?;
288 for (abs, rel) in files {
289 let dest_path = dest.join(&rel);
290 if let Some(parent) = dest_path.parent() {
291 fs::create_dir_all(parent)?;
292 }
293 if fs::rename(&abs, &dest_path).is_err() {
296 fs::copy(&abs, &dest_path)?;
297 let _ = fs::remove_file(&abs);
298 }
299 let size = fs::metadata(&dest_path).map_or(0, |m| m.len());
300 let origin_rel_path = match &origin_prefix {
301 Some(p) => p.join(&rel).to_string_lossy().to_string(),
302 None => rel.to_string_lossy().to_string(),
303 };
304 out.push(StagedFile {
305 rel_path: dest_rel(manifest_root, &dest_path),
306 origin_rel_path,
307 size,
308 merge_group: None,
309 });
310 }
311 Ok(out)
312}
313
314fn dest_rel(dest_root: &Path, dest_path: &Path) -> String {
315 dest_path.strip_prefix(dest_root).map_or_else(
316 |_| dest_path.to_string_lossy().to_string(),
317 |p| p.to_string_lossy().to_string(),
318 )
319}
320
321fn store_dir_name(store_mod_dir: &Path) -> String {
322 store_mod_dir
323 .file_name()
324 .and_then(|name| name.to_str())
325 .unwrap_or("mod")
326 .to_string()
327}
328
329fn read_xml_attr(path: &Path, attr: &str) -> Option<String> {
330 let content = fs::read_to_string(path).ok()?;
331 if let Some((element, attr)) = attr.split_once('.') {
332 let marker = format!("<{element}");
333 let start = content.find(&marker)?;
334 let rest = &content[start..];
335 return read_attr_from_str(rest, attr);
336 }
337 read_attr_from_str(&content, attr)
338}
339
340fn read_attr_from_str(content: &str, attr: &str) -> Option<String> {
341 let needle = format!("{attr}=\"");
342 let start = content.find(&needle)? + needle.len();
343 let rest = &content[start..];
344 let end = rest.find('"')?;
345 let value = rest[..end].trim();
346 (!value.is_empty()).then(|| value.to_string())
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352 use std::io::Write as _;
353
354 fn touch(p: &Path, body: &[u8]) {
355 if let Some(parent) = p.parent() {
356 fs::create_dir_all(parent).unwrap();
357 }
358 let mut f = fs::File::create(p).unwrap();
359 f.write_all(body).unwrap();
360 }
361
362 #[test]
363 fn bare_extract_moves_files_into_store() {
364 let tmp = tempfile::tempdir().unwrap();
365 let staging = tmp.path().join("staging");
366 let store = tmp.path().join("store");
367 touch(&staging.join("Data").join("foo.esp"), b"hello");
368 touch(&staging.join("readme.md"), b"hi");
369
370 let mut plan = InstallPlan {
371 method: InstallMethod::BareExtract,
372 strip_prefix: None,
373 source_archive_hash: "h".into(),
374 staged_files: vec![],
375 };
376 let files = execute(&mut plan, &staging, &store).unwrap();
377 assert_eq!(files.len(), 2);
378 assert!(store.join("Data/foo.esp").exists());
379 assert!(store.join("readme.md").exists());
380 assert!(plan.staged_files.len() == 2);
381 }
382
383 #[test]
384 fn strip_prefix_is_applied() {
385 let tmp = tempfile::tempdir().unwrap();
386 let staging = tmp.path().join("staging");
387 let store = tmp.path().join("store");
388 touch(&staging.join("ModName-1.0/Data/foo.esp"), b"hello");
389
390 let mut plan = InstallPlan {
391 method: InstallMethod::BareExtract,
392 strip_prefix: Some(PathBuf::from("ModName-1.0")),
393 source_archive_hash: "h".into(),
394 staged_files: vec![],
395 };
396 let files = execute(&mut plan, &staging, &store).unwrap();
397 assert_eq!(files.len(), 1);
398 assert!(store.join("Data/foo.esp").exists());
399 assert_eq!(files[0].rel_path, "Data/foo.esp");
400 }
401
402 #[test]
403 fn fomod_without_config_requires_user_input() {
404 let tmp = tempfile::tempdir().unwrap();
405 let staging = tmp.path().join("staging");
406 let store = tmp.path().join("store");
407 touch(&staging.join("fomod/ModuleConfig.xml"), b"<config/>");
408 touch(&staging.join("Data/foo.esp"), b"hello");
409
410 let mut plan = InstallPlan {
411 method: InstallMethod::Fomod {
412 module_config: PathBuf::from("fomod/ModuleConfig.xml"),
413 config_toml: None,
414 },
415 strip_prefix: None,
416 source_archive_hash: "h".into(),
417 staged_files: vec![],
418 };
419 let err = execute(&mut plan, &staging, &store).unwrap_err();
420 assert!(matches!(err, InstallerError::RequiresUserInput { .. }));
421 }
422
423 #[test]
424 fn unknown_returns_unknown_method() {
425 let tmp = tempfile::tempdir().unwrap();
426 let staging = tmp.path().join("staging");
427 let store = tmp.path().join("store");
428 touch(&staging.join("blob.bin"), b"x");
429
430 let mut plan = InstallPlan {
431 method: InstallMethod::Unknown {
432 reason: "test".into(),
433 },
434 strip_prefix: None,
435 source_archive_hash: "h".into(),
436 staged_files: vec![],
437 };
438 let err = execute(&mut plan, &staging, &store).unwrap_err();
439 assert!(matches!(err, InstallerError::UnknownMethod { .. }));
440 }
441
442 #[test]
443 fn script_merge_tags_files() {
444 let tmp = tempfile::tempdir().unwrap();
445 let staging = tmp.path().join("staging");
446 let store = tmp.path().join("store");
447 touch(&staging.join("Data/foo.esp"), b"hello");
448
449 let mut plan = InstallPlan {
450 method: InstallMethod::ScriptMerge {
451 merge_group: "quest-scripts".into(),
452 base: Box::new(InstallMethod::BareExtract),
453 },
454 strip_prefix: None,
455 source_archive_hash: "h".into(),
456 staged_files: vec![],
457 };
458 let files = execute(&mut plan, &staging, &store).unwrap();
459 assert_eq!(files.len(), 1);
460 assert_eq!(files[0].merge_group.as_deref(), Some("quest-scripts"));
461 }
462}