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::REDmod { manifest: _ } => {
42            // REDmod layout already ships under a top-level dir the game
43            // expects; we stage everything under `mods/<mod-name>/`. The
44            // store dir name is used as the mod name so the caller can
45            // pick a stable identifier.
46            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            // Stage under a flagged subdir; deploy decides the final
57            // destination using the game plugin's `executable_dir`.
58            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            // Parse the declarative config (TOML-serialized).
74            let decl: fomod_oxide::DeclarativeConfig = toml::from_str(config_str)
75                .map_err(|e| InstallerError::FomodError(format!("invalid config TOML: {e}")))?;
76
77            // Read and parse the ModuleConfig.xml.
78            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            // Create installer, apply selections, resolve file operations.
87            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            // Execute the FOMOD plan: copy selected files into the store.
93            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            // Execute the base method, then tag every staged file with
148            // the merge group. Actual merging is deferred until the
149            // script-merge feature lands.
150            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
174/// Copy every file under `src` into `dest`, preserving subdirs. Returns
175/// the staged manifest with origin paths relative to `src` (optionally
176/// prefixed by `origin_prefix` so callers can reconstruct the archive
177/// path when the plan nests the source into a subdir).
178fn 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        // Prefer rename when src/dest are on the same filesystem; fall
191        // back to copy+remove otherwise.
192        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}