Skip to main content

modde_core/installer/
analyze.rs

1//! Archive structure detection.
2//!
3//! Given an extracted archive and a game-specific [`InstallProbe`], decide
4//! which [`InstallMethod`] describes this mod. The pipeline is intentionally
5//! ordered so game plugins can claim layouts authoritatively before the
6//! generic probes kick in.
7//!
8//! Detection order:
9//!
10//! 1. **Normalize**: if the extracted dir contains exactly one wrapper
11//!    directory and no files, recurse into that subdir and record the
12//!    `strip_prefix` on the resulting plan.
13//! 2. **Game plugin**: `probe.analyze(dir)` — plugin-specific rules (e.g.
14//!    `REDmod` for Cyberpunk).
15//! 3. **FOMOD**: presence of `fomod/ModuleConfig.xml`.
16//! 4. **BAIN**: numbered option subdirs (`00 Core`, `01 Option`, ...).
17//! 5. **DLL overlay**: top-level `.dll` with no nested asset dirs.
18//! 6. **Bare extract**: `probe.recognizes_bare(dir)`.
19//! 7. **Unknown**: fall through.
20//!
21//! On `Unknown`, the caller is expected to dump a dossier (see
22//! [`super::dossier`]) and let the skill path extend this enum.
23
24use std::fs;
25use std::path::{Path, PathBuf};
26
27use super::fs::find_fomod_config;
28use super::probe::InstallProbe;
29use super::types::{InstallMethod, InstallPlan, InstallerResult};
30
31/// Classify `extracted_dir` and return an [`InstallPlan`] with the
32/// detected method and `source_archive_hash` pre-populated.
33///
34/// `source_archive_hash` is the xxh64 hex digest of the original archive;
35/// the caller computes it during download (it cannot be derived from the
36/// extracted tree alone). The returned plan has an empty `staged_files`
37/// — call [`super::execute::execute`] to populate it.
38pub fn analyze(
39    extracted_dir: &Path,
40    probe: &InstallProbe,
41    source_archive_hash: String,
42) -> InstallerResult<InstallPlan> {
43    let (effective_dir, strip_prefix) = normalize(extracted_dir)?;
44    let target = if let Some(ref p) = strip_prefix {
45        extracted_dir.join(p)
46    } else {
47        extracted_dir.to_path_buf()
48    };
49    let _ = effective_dir; // `target` is the authoritative path
50
51    let method = detect_method(&target, probe);
52
53    Ok(InstallPlan {
54        method,
55        strip_prefix,
56        source_archive_hash,
57        staged_files: Vec::new(),
58    })
59}
60
61/// Follow single-directory wrappers (e.g. `ModName-1.0/<real contents>`)
62/// until we reach either (a) a directory with multiple entries, (b) a
63/// single non-directory entry, or (c) a directory whose only child is a
64/// recognized mod-content dir like `Data/` or `r6/`. Case (c) is the
65/// tricky one: `ModName-1.0/Data/mod.esp` looks like two nested
66/// single-child wrappers from the filesystem's perspective, but `Data/`
67/// is real content and should become the staging root.
68fn normalize(extracted_dir: &Path) -> InstallerResult<(PathBuf, Option<PathBuf>)> {
69    /// Lower-case names that, when seen as the ONLY child of a dir, mean
70    /// "stop unwrapping — this child is the real mod content". Union of
71    /// Bethesda and Cyberpunk content dirs plus generic installer
72    /// markers. Kept centralized so a new game only has to edit this
73    /// list to participate in prefix stripping.
74    const CONTENT_DIR_NAMES: &[&str] = &[
75        "data",
76        "meshes",
77        "textures",
78        "scripts",
79        "interface",
80        "sound",
81        "music",
82        "materials",
83        "seq",
84        "shadersfx",
85        "strings",
86        "r6",
87        "archive",
88        "archives",
89        "bin",
90        "engine",
91        "mods",
92        "red4ext",
93        "fomod",
94    ];
95
96    let mut current = extracted_dir.to_path_buf();
97    let mut strip: Option<PathBuf> = None;
98
99    loop {
100        let entries: Vec<_> = match fs::read_dir(&current) {
101            Ok(rd) => rd.flatten().collect(),
102            Err(_) => break,
103        };
104        if entries.len() != 1 {
105            break;
106        }
107        let only = &entries[0];
108        if !only.path().is_dir() {
109            break;
110        }
111        let name = only.file_name();
112        let name_lc = name.to_string_lossy().to_lowercase();
113        if CONTENT_DIR_NAMES.iter().any(|d| *d == name_lc) {
114            break;
115        }
116        current = only.path();
117        strip = Some(match strip {
118            Some(p) => p.join(&name),
119            None => PathBuf::from(&name),
120        });
121    }
122
123    Ok((current, strip))
124}
125
126fn detect_method(dir: &Path, probe: &InstallProbe) -> InstallMethod {
127    // 1. Game plugin gets first crack.
128    if let Some(method) = (probe.analyze)(dir) {
129        return method;
130    }
131
132    // 2. FOMOD.
133    if let Some(module_config) = find_fomod_config(dir) {
134        let rel = module_config
135            .strip_prefix(dir)
136            .unwrap_or(&module_config)
137            .to_path_buf();
138        return InstallMethod::Fomod {
139            module_config: rel,
140            config_toml: None,
141        };
142    }
143
144    // 3. BAIN — numbered option subdirs.
145    if looks_like_bain(dir) {
146        return InstallMethod::Bain {
147            selected_subdirs: Vec::new(),
148        };
149    }
150
151    // 4. DLL overlay — .dll at top with no nested content dirs.
152    if looks_like_dll_overlay(dir) {
153        return InstallMethod::DllOverlay {
154            target_dir_hint: "game root".to_string(),
155        };
156    }
157
158    // 5. Game plugin's bare-layout recognizer.
159    if (probe.recognizes_bare)(dir) {
160        return InstallMethod::BareExtract;
161    }
162
163    // 6. User-config overlay — last fallback before Unknown.
164    //
165    // The plugin had to advertise a `UserConfig` deploy target for
166    // this branch to fire (probe carries the id). We additionally
167    // require *every* file in the tree to look like a config file:
168    // an unrecognized layout that happens to ship one INI alongside
169    // a binary blob should still be `Unknown` and prompt a dossier
170    // dump rather than silently routing the binary into the user's
171    // config dir.
172    if let Some(target_id) = probe.user_config_target
173        && tree_is_only_config(dir)
174    {
175        return InstallMethod::UserConfigOverlay {
176            target_id: target_id.to_string(),
177        };
178    }
179
180    // 7. Fall through to Unknown.
181    InstallMethod::Unknown {
182        reason: "no matching install method — dossier should be dumped".to_string(),
183    }
184}
185
186/// Recognized config-file extensions for the `UserConfigOverlay` branch.
187///
188/// Kept conservative on purpose: an archive containing extensions
189/// outside this list (e.g. `.dll`, `.pak`, `.exe`) is *not* a config
190/// overlay even if it also contains an INI. Adding extensions here
191/// expands what's considered "user config payload" everywhere at once.
192const USER_CONFIG_EXTENSIONS: &[&str] =
193    &["ini", "cfg", "conf", "json", "toml", "yaml", "yml", "xml"];
194
195/// `true` when every regular file in `dir` (recursively) has an
196/// extension in [`USER_CONFIG_EXTENSIONS`]. Empty directories return
197/// `false` — there's no config payload to deploy.
198fn tree_is_only_config(dir: &Path) -> bool {
199    fn visit(dir: &Path, saw_any: &mut bool) -> bool {
200        let Ok(rd) = fs::read_dir(dir) else {
201            return false;
202        };
203        for entry in rd.flatten() {
204            let path = entry.path();
205            if path.is_dir() {
206                if !visit(&path, saw_any) {
207                    return false;
208                }
209                continue;
210            }
211            if !path.is_file() {
212                continue;
213            }
214            *saw_any = true;
215            let Some(ext) = path
216                .extension()
217                .and_then(|e| e.to_str())
218                .map(str::to_ascii_lowercase)
219            else {
220                return false;
221            };
222            if !USER_CONFIG_EXTENSIONS.iter().any(|e| *e == ext) {
223                return false;
224            }
225        }
226        true
227    }
228    let mut saw_any = false;
229    visit(dir, &mut saw_any) && saw_any
230}
231
232fn looks_like_bain(dir: &Path) -> bool {
233    let Ok(entries) = fs::read_dir(dir) else {
234        return false;
235    };
236    let mut numbered = 0;
237    let mut total = 0;
238    for entry in entries.flatten() {
239        if !entry.path().is_dir() {
240            continue;
241        }
242        total += 1;
243        let name = entry.file_name();
244        let name_str = name.to_string_lossy();
245        // BAIN convention: "00 Core", "01 Option A", ...
246        if name_str.len() >= 3
247            && name_str.as_bytes()[0].is_ascii_digit()
248            && name_str.as_bytes()[1].is_ascii_digit()
249            && (name_str.as_bytes()[2] == b' ' || name_str.as_bytes()[2] == b'_')
250        {
251            numbered += 1;
252        }
253    }
254    total >= 2 && numbered >= 2
255}
256
257fn looks_like_dll_overlay(dir: &Path) -> bool {
258    let Ok(entries) = fs::read_dir(dir) else {
259        return false;
260    };
261    let mut has_dll = false;
262    let mut has_asset_dir = false;
263    let asset_dirs = [
264        "data", "meshes", "textures", "scripts", "r6", "archive", "mods",
265    ];
266    for entry in entries.flatten() {
267        let path = entry.path();
268        if path.is_dir() {
269            let name = entry.file_name().to_string_lossy().to_lowercase();
270            if asset_dirs.iter().any(|d| *d == name) {
271                has_asset_dir = true;
272            }
273        } else if let Some(ext) = path.extension().and_then(|e| e.to_str())
274            && ext.eq_ignore_ascii_case("dll")
275        {
276            has_dll = true;
277        }
278    }
279    has_dll && !has_asset_dir
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use std::io::Write as _;
286
287    fn touch(p: &Path) {
288        if let Some(parent) = p.parent() {
289            fs::create_dir_all(parent).unwrap();
290        }
291        let mut f = fs::File::create(p).unwrap();
292        f.write_all(b"x").unwrap();
293    }
294
295    #[test]
296    fn normalize_strips_single_wrapper() {
297        let tmp = tempfile::tempdir().unwrap();
298        let wrapper = tmp.path().join("ModName-1.0");
299        touch(&wrapper.join("Data").join("mod.esp"));
300
301        let (effective, strip) = normalize(tmp.path()).unwrap();
302        assert_eq!(strip.as_deref(), Some(Path::new("ModName-1.0")));
303        assert_eq!(effective, wrapper);
304    }
305
306    #[test]
307    fn normalize_leaves_multi_entry_root_alone() {
308        let tmp = tempfile::tempdir().unwrap();
309        touch(&tmp.path().join("Data").join("a.esp"));
310        touch(&tmp.path().join("readme.txt"));
311
312        let (effective, strip) = normalize(tmp.path()).unwrap();
313        assert!(strip.is_none());
314        assert_eq!(effective, tmp.path());
315    }
316
317    #[test]
318    fn detects_fomod() {
319        let tmp = tempfile::tempdir().unwrap();
320        touch(&tmp.path().join("fomod").join("ModuleConfig.xml"));
321        touch(&tmp.path().join("Data").join("foo.esp"));
322
323        let probe = InstallProbe::noop();
324        let plan = analyze(tmp.path(), &probe, "deadbeef".to_string()).unwrap();
325        assert!(matches!(plan.method, InstallMethod::Fomod { .. }));
326    }
327
328    #[test]
329    fn detects_bain() {
330        let tmp = tempfile::tempdir().unwrap();
331        touch(&tmp.path().join("00 Core").join("foo.esp"));
332        touch(&tmp.path().join("01 Option A").join("foo.esp"));
333        touch(&tmp.path().join("02 Option B").join("foo.esp"));
334
335        let probe = InstallProbe::noop();
336        let plan = analyze(tmp.path(), &probe, "h".to_string()).unwrap();
337        assert!(matches!(plan.method, InstallMethod::Bain { .. }));
338    }
339
340    #[test]
341    fn detects_dll_overlay() {
342        let tmp = tempfile::tempdir().unwrap();
343        touch(&tmp.path().join("hook.dll"));
344        touch(&tmp.path().join("hook.ini"));
345
346        let probe = InstallProbe::noop();
347        let plan = analyze(tmp.path(), &probe, "h".to_string()).unwrap();
348        assert!(matches!(plan.method, InstallMethod::DllOverlay { .. }));
349    }
350
351    #[test]
352    fn plugin_analyze_wins() {
353        let tmp = tempfile::tempdir().unwrap();
354        // Looks like FOMOD...
355        touch(&tmp.path().join("fomod").join("ModuleConfig.xml"));
356        // ...but the plugin claims REDmod first.
357        let probe = InstallProbe::new(
358            |_: &Path| {
359                Some(InstallMethod::REDmod {
360                    manifest: PathBuf::from("info.json"),
361                })
362            },
363            |_: &Path| false,
364        );
365        let plan = analyze(tmp.path(), &probe, "h".to_string()).unwrap();
366        assert!(matches!(plan.method, InstallMethod::REDmod { .. }));
367    }
368
369    #[test]
370    fn bare_fallback_when_plugin_says_so() {
371        let tmp = tempfile::tempdir().unwrap();
372        touch(&tmp.path().join("Data").join("foo.esp"));
373
374        let probe = InstallProbe::new(|_: &Path| None, |_: &Path| true);
375        let plan = analyze(tmp.path(), &probe, "h".to_string()).unwrap();
376        assert!(matches!(plan.method, InstallMethod::BareExtract));
377    }
378
379    #[test]
380    fn unknown_is_last_resort() {
381        let tmp = tempfile::tempdir().unwrap();
382        touch(&tmp.path().join("mystery_blob.bin"));
383
384        let probe = InstallProbe::noop();
385        let plan = analyze(tmp.path(), &probe, "h".to_string()).unwrap();
386        assert!(matches!(plan.method, InstallMethod::Unknown { .. }));
387    }
388
389    #[test]
390    fn detects_user_config_overlay() {
391        let tmp = tempfile::tempdir().unwrap();
392        touch(&tmp.path().join("Engine.ini"));
393        touch(&tmp.path().join("GameUserSettings.ini"));
394
395        let probe = InstallProbe::noop().with_user_config_target("test-config");
396        let plan = analyze(tmp.path(), &probe, "h".to_string()).unwrap();
397        match plan.method {
398            InstallMethod::UserConfigOverlay { target_id } => assert_eq!(target_id, "test-config"),
399            other => panic!("expected UserConfigOverlay, got {other:?}"),
400        }
401    }
402
403    #[test]
404    fn user_config_overlay_requires_plugin_target() {
405        // Same archive, no plugin target advertised → must fall through
406        // to Unknown rather than route INIs nowhere.
407        let tmp = tempfile::tempdir().unwrap();
408        touch(&tmp.path().join("Engine.ini"));
409
410        let probe = InstallProbe::noop();
411        let plan = analyze(tmp.path(), &probe, "h".to_string()).unwrap();
412        assert!(matches!(plan.method, InstallMethod::Unknown { .. }));
413    }
414
415    #[test]
416    fn user_config_overlay_rejects_mixed_payloads() {
417        // INI alongside a binary blob is not a config overlay — we
418        // refuse to silently route an unknown binary into the user's
419        // config dir.
420        let tmp = tempfile::tempdir().unwrap();
421        touch(&tmp.path().join("Engine.ini"));
422        touch(&tmp.path().join("payload.bin"));
423
424        let probe = InstallProbe::noop().with_user_config_target("test-config");
425        let plan = analyze(tmp.path(), &probe, "h".to_string()).unwrap();
426        assert!(matches!(plan.method, InstallMethod::Unknown { .. }));
427    }
428}