Skip to main content

modde_core/installer/
execute.rs

1//! Stage files from a temporary extraction directory into a mod's store
2//! directory, according to an [`InstallPlan`].
3//!
4//! Execution is deliberately dumb: it copies files, records their paths,
5//! and returns the manifest. It does not touch the database — the caller
6//! wires the returned `Vec<StagedFile>` into `ModdeDb::record_install`.
7//!
8//! Variants that need user input (`Fomod` with no config, `Bain` with no
9//! selection) return [`InstallerError::RequiresUserInput`] so the UI can
10//! route to its wizard.
11
12use std::fs;
13use std::path::{Path, PathBuf};
14
15use super::fs::walk_files;
16use super::types::{InstallMethod, InstallPlan, InstallerError, InstallerResult, StagedFile};
17
18/// Execute `plan`, staging files from `extracted_dir` into
19/// `store_mod_dir`. On success, mutates `plan.staged_files` with the
20/// final manifest and returns it by value for the caller to persist.
21///
22/// `extracted_dir` is the temp directory the archive was unzipped into.
23/// `store_mod_dir` is the canonical per-mod directory in the store (the
24/// caller decides the naming, typically `{domain}_{mod_id}_{file_id}`).
25pub 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            // REDmod layout already ships under a top-level dir the game
124            // expects; we stage everything under `mods/<mod-name>/`. The
125            // store dir name is used as the mod name so the caller can
126            // pick a stable identifier.
127            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            // Stage under a flagged subdir; deploy decides the final
142            // destination using the game plugin's `executable_dir`.
143            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            // Parse the declarative config (TOML-serialized).
162            let decl: fomod_oxide::DeclarativeConfig = toml::from_str(config_str)
163                .map_err(|e| InstallerError::FomodError(format!("invalid config TOML: {e}")))?;
164
165            // Read and parse the ModuleConfig.xml.
166            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            // Create installer, apply selections, resolve file operations.
174            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            // Execute the FOMOD plan: copy selected files into the store.
180            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            // Execute the base method, then tag every staged file with
235            // the merge group. Actual merging is deferred until the
236            // script-merge feature lands.
237            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 like `BareExtract`: the alternate routing only
252            // matters at deploy time, where the deploy command reads
253            // back the install method and resolves the target id.
254            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
268/// Copy every file under `src` into `dest`, preserving subdirs. Returns
269/// the staged manifest with origin paths relative to `src` (optionally
270/// prefixed by `origin_prefix` so callers can reconstruct the archive
271/// path when the plan nests the source into a subdir).
272fn 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        // Prefer rename when src/dest are on the same filesystem; fall
294        // back to copy+remove otherwise.
295        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}