1use 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
31pub 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; 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
61fn normalize(extracted_dir: &Path) -> InstallerResult<(PathBuf, Option<PathBuf>)> {
69 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(¤t) {
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 if let Some(method) = (probe.analyze)(dir) {
129 return method;
130 }
131
132 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 if looks_like_bain(dir) {
146 return InstallMethod::Bain {
147 selected_subdirs: Vec::new(),
148 };
149 }
150
151 if looks_like_dll_overlay(dir) {
153 return InstallMethod::DllOverlay {
154 target_dir_hint: "game root".to_string(),
155 };
156 }
157
158 if (probe.recognizes_bare)(dir) {
160 return InstallMethod::BareExtract;
161 }
162
163 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 InstallMethod::Unknown {
182 reason: "no matching install method — dossier should be dumped".to_string(),
183 }
184}
185
186const USER_CONFIG_EXTENSIONS: &[&str] =
193 &["ini", "cfg", "conf", "json", "toml", "yaml", "yml", "xml"];
194
195fn 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 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 touch(&tmp.path().join("fomod").join("ModuleConfig.xml"));
356 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 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 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}