loadorder/
game_settings.rs

1/*
2 * This file is part of libloadorder
3 *
4 * Copyright (C) 2017 Oliver Hamlet
5 *
6 * libloadorder is free software: you can redistribute it and/or modify
7 * it under the terms of the GNU General Public License as published by
8 * the Free Software Foundation, either version 3 of the License, or
9 * (at your option) any later version.
10 *
11 * libloadorder is distributed in the hope that it will be useful,
12 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14 * GNU General Public License for more details.
15 *
16 * You should have received a copy of the GNU General Public License
17 * along with libloadorder. If not, see <http://www.gnu.org/licenses/>.
18 */
19
20use std::cmp::Ordering;
21use std::fs::{read_dir, DirEntry, File, FileType};
22use std::io::{BufRead, BufReader};
23use std::iter::once;
24use std::path::Path;
25use std::path::PathBuf;
26use std::time::SystemTime;
27
28use crate::enums::{Error, GameId, LoadOrderMethod};
29use crate::ini::{test_files, use_my_games_directory};
30use crate::is_enderal;
31use crate::load_order::{
32    AsteriskBasedLoadOrder, OpenMWLoadOrder, TextfileBasedLoadOrder, TimestampBasedLoadOrder,
33    WritableLoadOrder,
34};
35use crate::openmw_config;
36use crate::plugin::{has_plugin_extension, Plugin};
37
38#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
39pub struct GameSettings {
40    id: GameId,
41    game_path: PathBuf,
42    plugins_directory: PathBuf,
43    plugins_file_path: PathBuf,
44    my_games_path: PathBuf,
45    load_order_path: Option<PathBuf>,
46    implicitly_active_plugins: Vec<String>,
47    early_loading_plugins: Vec<String>,
48    additional_plugins_directories: Vec<PathBuf>,
49}
50
51const SKYRIM_HARDCODED_PLUGINS: &[&str] = &["Skyrim.esm"];
52
53const SKYRIM_SE_HARDCODED_PLUGINS: &[&str] = &[
54    "Skyrim.esm",
55    "Update.esm",
56    "Dawnguard.esm",
57    "HearthFires.esm",
58    "Dragonborn.esm",
59];
60
61const SKYRIM_VR_HARDCODED_PLUGINS: &[&str] = &[
62    "Skyrim.esm",
63    "Update.esm",
64    "Dawnguard.esm",
65    "HearthFires.esm",
66    "Dragonborn.esm",
67    "SkyrimVR.esm",
68];
69
70const FALLOUT4_HARDCODED_PLUGINS: &[&str] = &[
71    "Fallout4.esm",
72    "DLCRobot.esm",
73    "DLCworkshop01.esm",
74    "DLCCoast.esm",
75    "DLCworkshop02.esm",
76    "DLCworkshop03.esm",
77    "DLCNukaWorld.esm",
78    "DLCUltraHighResolution.esm",
79];
80
81const FALLOUT4VR_HARDCODED_PLUGINS: &[&str] = &["Fallout4.esm", "Fallout4_VR.esm"];
82
83pub(crate) const STARFIELD_HARDCODED_PLUGINS: &[&str] = &[
84    "Starfield.esm",
85    "Constellation.esm",
86    "OldMars.esm",
87    "ShatteredSpace.esm",
88    "SFBGS003.esm",
89    "SFBGS004.esm",
90    "SFBGS006.esm",
91    "SFBGS007.esm",
92    "SFBGS008.esm",
93];
94
95const OPENMW_HARDCODED_PLUGINS: &[&str] = &["builtin.omwscripts"];
96
97// It's safe to use relative paths like this because the Microsoft Store
98// version of Fallout 4 won't launch if a DLC is installed and its install
99// path changed (e.g. by renaming a directory), so the DLC plugins must be
100// in their default locations.
101const MS_FO4_FAR_HARBOR_PATH: &str = "../../Fallout 4- Far Harbor (PC)/Content/Data";
102const MS_FO4_NUKA_WORLD_PATH: &str = "../../Fallout 4- Nuka-World (PC)/Content/Data";
103const MS_FO4_AUTOMATRON_PATH: &str = "../../Fallout 4- Automatron (PC)/Content/Data";
104const MS_FO4_TEXTURE_PACK_PATH: &str = "../../Fallout 4- High Resolution Texture Pack/Content/Data";
105const MS_FO4_WASTELAND_PATH: &str = "../../Fallout 4- Wasteland Workshop (PC)/Content/Data";
106const MS_FO4_CONTRAPTIONS_PATH: &str = "../../Fallout 4- Contraptions Workshop (PC)/Content/Data";
107const MS_FO4_VAULT_TEC_PATH: &str = "../../Fallout 4- Vault-Tec Workshop (PC)/Content/Data";
108
109const PLUGINS_TXT: &str = "Plugins.txt";
110
111const OBLIVION_REMASTERED_RELATIVE_DATA_PATH: &str = "OblivionRemastered/Content/Dev/ObvData/Data";
112
113impl GameSettings {
114    pub fn new(game_id: GameId, game_path: &Path) -> Result<GameSettings, Error> {
115        let local_path = local_path(game_id, game_path)?.unwrap_or_default();
116        GameSettings::with_local_path(game_id, game_path, &local_path)
117    }
118
119    pub fn with_local_path(
120        game_id: GameId,
121        game_path: &Path,
122        local_path: &Path,
123    ) -> Result<GameSettings, Error> {
124        let my_games_path = my_games_path(game_id, game_path, local_path)?.unwrap_or_default();
125
126        GameSettings::with_local_and_my_games_paths(game_id, game_path, local_path, my_games_path)
127    }
128
129    pub(crate) fn with_local_and_my_games_paths(
130        game_id: GameId,
131        game_path: &Path,
132        local_path: &Path,
133        my_games_path: PathBuf,
134    ) -> Result<GameSettings, Error> {
135        let plugins_file_path = plugins_file_path(game_id, game_path, local_path)?;
136        let load_order_path = load_order_path(game_id, local_path, &plugins_file_path);
137        let plugins_directory = plugins_directory(game_id, game_path, local_path)?;
138        let additional_plugins_directories =
139            additional_plugins_directories(game_id, game_path, &my_games_path)?;
140
141        let (early_loading_plugins, implicitly_active_plugins) =
142            GameSettings::load_implicitly_active_plugins(
143                game_id,
144                game_path,
145                &my_games_path,
146                &plugins_directory,
147                &additional_plugins_directories,
148            )?;
149
150        Ok(GameSettings {
151            id: game_id,
152            game_path: game_path.to_path_buf(),
153            plugins_directory,
154            plugins_file_path,
155            load_order_path,
156            my_games_path,
157            implicitly_active_plugins,
158            early_loading_plugins,
159            additional_plugins_directories,
160        })
161    }
162
163    pub fn id(&self) -> GameId {
164        self.id
165    }
166
167    pub fn load_order_method(&self) -> LoadOrderMethod {
168        match self.id {
169            GameId::OpenMW => LoadOrderMethod::OpenMW,
170            GameId::Morrowind | GameId::Oblivion | GameId::Fallout3 | GameId::FalloutNV => {
171                LoadOrderMethod::Timestamp
172            }
173            GameId::Skyrim | GameId::OblivionRemastered => LoadOrderMethod::Textfile,
174            GameId::SkyrimSE
175            | GameId::SkyrimVR
176            | GameId::Fallout4
177            | GameId::Fallout4VR
178            | GameId::Starfield => LoadOrderMethod::Asterisk,
179        }
180    }
181
182    pub fn into_load_order(self) -> Box<dyn WritableLoadOrder + Send + Sync + 'static> {
183        match self.load_order_method() {
184            LoadOrderMethod::Asterisk => Box::new(AsteriskBasedLoadOrder::new(self)),
185            LoadOrderMethod::Textfile => Box::new(TextfileBasedLoadOrder::new(self)),
186            LoadOrderMethod::Timestamp => Box::new(TimestampBasedLoadOrder::new(self)),
187            LoadOrderMethod::OpenMW => Box::new(OpenMWLoadOrder::new(self)),
188        }
189    }
190
191    #[deprecated = "The master file is not necessarily of any significance: you should probably use early_loading_plugins() instead."]
192    pub fn master_file(&self) -> &'static str {
193        match self.id {
194            GameId::Morrowind | GameId::OpenMW => "Morrowind.esm",
195            GameId::Oblivion | GameId::OblivionRemastered => "Oblivion.esm",
196            GameId::Skyrim | GameId::SkyrimSE | GameId::SkyrimVR => "Skyrim.esm",
197            GameId::Fallout3 => "Fallout3.esm",
198            GameId::FalloutNV => "FalloutNV.esm",
199            GameId::Fallout4 | GameId::Fallout4VR => "Fallout4.esm",
200            GameId::Starfield => "Starfield.esm",
201        }
202    }
203
204    pub fn implicitly_active_plugins(&self) -> &[String] {
205        &self.implicitly_active_plugins
206    }
207
208    pub fn is_implicitly_active(&self, plugin: &str) -> bool {
209        use unicase::eq;
210        self.implicitly_active_plugins()
211            .iter()
212            .any(|p| eq(p.as_str(), plugin))
213    }
214
215    pub fn early_loading_plugins(&self) -> &[String] {
216        &self.early_loading_plugins
217    }
218
219    pub fn loads_early(&self, plugin: &str) -> bool {
220        use unicase::eq;
221        self.early_loading_plugins()
222            .iter()
223            .any(|p| eq(p.as_str(), plugin))
224    }
225
226    pub fn plugins_directory(&self) -> PathBuf {
227        self.plugins_directory.clone()
228    }
229
230    pub fn active_plugins_file(&self) -> &PathBuf {
231        &self.plugins_file_path
232    }
233
234    pub fn load_order_file(&self) -> Option<&PathBuf> {
235        self.load_order_path.as_ref()
236    }
237
238    pub fn additional_plugins_directories(&self) -> &[PathBuf] {
239        &self.additional_plugins_directories
240    }
241
242    pub fn set_additional_plugins_directories(&mut self, paths: Vec<PathBuf>) {
243        self.additional_plugins_directories = paths;
244    }
245
246    /// Find installed plugins and return them in their "inactive load order",
247    /// which is generally the order in which the game launcher would display
248    /// them if they were all inactive, ignoring rules like master files
249    /// loading before others and about early-loading plugins.
250    pub(crate) fn find_plugins(&self) -> Vec<PathBuf> {
251        let main_dir_iter = once(&self.plugins_directory);
252        let other_directories_iter = self.additional_plugins_directories.iter();
253
254        // For most games, plugins in the additional directories override any of
255        // the same names that appear in the main plugins directory, so check
256        // for the additional paths first. For OpenMW the main directory is
257        // listed first.
258        if self.id == GameId::OpenMW {
259            find_plugins_in_directories(main_dir_iter.chain(other_directories_iter), self.id)
260        } else {
261            find_plugins_in_directories(other_directories_iter.chain(main_dir_iter), self.id)
262        }
263    }
264
265    pub(crate) fn game_path(&self) -> &Path {
266        &self.game_path
267    }
268
269    pub fn plugin_path(&self, plugin_name: &str) -> PathBuf {
270        plugin_path(
271            self.id,
272            plugin_name,
273            &self.plugins_directory,
274            &self.additional_plugins_directories,
275        )
276    }
277
278    pub fn refresh_implicitly_active_plugins(&mut self) -> Result<(), Error> {
279        let (early_loading_plugins, implicitly_active_plugins) =
280            GameSettings::load_implicitly_active_plugins(
281                self.id,
282                &self.game_path,
283                &self.my_games_path,
284                &self.plugins_directory,
285                &self.additional_plugins_directories,
286            )?;
287
288        self.early_loading_plugins = early_loading_plugins;
289        self.implicitly_active_plugins = implicitly_active_plugins;
290
291        Ok(())
292    }
293
294    fn load_implicitly_active_plugins(
295        game_id: GameId,
296        game_path: &Path,
297        my_games_path: &Path,
298        plugins_directory: &Path,
299        additional_plugins_directories: &[PathBuf],
300    ) -> Result<(Vec<String>, Vec<String>), Error> {
301        let mut test_files = test_files(game_id, game_path, my_games_path)?;
302
303        if matches!(
304            game_id,
305            GameId::Fallout4 | GameId::Fallout4VR | GameId::Starfield
306        ) {
307            // Fallout 4 and Starfield ignore plugins.txt and Fallout4.ccc if there are valid
308            // plugins listed as test files, so filter out invalid values.
309            test_files.retain(|f| {
310                let path = plugin_path(
311                    game_id,
312                    f,
313                    plugins_directory,
314                    additional_plugins_directories,
315                );
316                Plugin::with_path(&path, game_id, false).is_ok()
317            });
318        }
319
320        let early_loading_plugins =
321            early_loading_plugins(game_id, game_path, my_games_path, !test_files.is_empty())?;
322
323        let implicitly_active_plugins =
324            implicitly_active_plugins(game_id, game_path, &early_loading_plugins, &test_files)?;
325
326        Ok((early_loading_plugins, implicitly_active_plugins))
327    }
328}
329
330#[cfg(windows)]
331fn local_path(game_id: GameId, game_path: &Path) -> Result<Option<PathBuf>, Error> {
332    if game_id == GameId::OpenMW {
333        return openmw_config::user_config_dir(game_path).map(Some);
334    }
335
336    let Some(local_app_data_path) = dirs::data_local_dir() else {
337        return Err(Error::NoLocalAppData);
338    };
339
340    match appdata_folder_name(game_id, game_path) {
341        Some(x) => Ok(Some(local_app_data_path.join(x))),
342        None => Ok(None),
343    }
344}
345
346#[cfg(not(windows))]
347fn local_path(game_id: GameId, game_path: &Path) -> Result<Option<PathBuf>, Error> {
348    if game_id == GameId::OpenMW {
349        openmw_config::user_config_dir(game_path).map(Some)
350    } else if appdata_folder_name(game_id, game_path).is_none() {
351        // There is no local path, the value doesn't matter.
352        Ok(None)
353    } else {
354        // A local app data path is needed, but there's no way to get it.
355        Err(Error::NoLocalAppData)
356    }
357}
358
359// The local path can vary depending on where the game was bought from.
360fn appdata_folder_name(game_id: GameId, game_path: &Path) -> Option<&'static str> {
361    match game_id {
362        GameId::Morrowind | GameId::OpenMW | GameId::OblivionRemastered => None,
363        GameId::Oblivion => Some("Oblivion"),
364        GameId::Skyrim => Some(skyrim_appdata_folder_name(game_path)),
365        GameId::SkyrimSE => Some(skyrim_se_appdata_folder_name(game_path)),
366        GameId::SkyrimVR => Some("Skyrim VR"),
367        GameId::Fallout3 => Some("Fallout3"),
368        GameId::FalloutNV => Some(falloutnv_appdata_folder_name(game_path)),
369        GameId::Fallout4 => Some(fallout4_appdata_folder_name(game_path)),
370        GameId::Fallout4VR => Some("Fallout4VR"),
371        GameId::Starfield => Some("Starfield"),
372    }
373}
374
375fn skyrim_appdata_folder_name(game_path: &Path) -> &'static str {
376    if is_enderal(game_path) {
377        // It's not actually Skyrim, it's Enderal.
378        "enderal"
379    } else {
380        "Skyrim"
381    }
382}
383
384fn skyrim_se_appdata_folder_name(game_path: &Path) -> &'static str {
385    let is_gog_install = game_path.join("Galaxy64.dll").exists();
386
387    if is_enderal(game_path) {
388        if is_gog_install {
389            "Enderal Special Edition GOG"
390        } else {
391            "Enderal Special Edition"
392        }
393    } else if is_gog_install {
394        // Galaxy64.dll is only installed by GOG's installer.
395        "Skyrim Special Edition GOG"
396    } else if game_path.join("EOSSDK-Win64-Shipping.dll").exists() {
397        // EOSSDK-Win64-Shipping.dll is only installed by Epic.
398        "Skyrim Special Edition EPIC"
399    } else if is_microsoft_store_install(GameId::SkyrimSE, game_path) {
400        "Skyrim Special Edition MS"
401    } else {
402        // If neither file is present it's probably the Steam distribution.
403        "Skyrim Special Edition"
404    }
405}
406
407fn falloutnv_appdata_folder_name(game_path: &Path) -> &'static str {
408    if game_path.join("EOSSDK-Win32-Shipping.dll").exists() {
409        // EOSSDK-Win32-Shipping.dll is only installed by Epic.
410        "FalloutNV_Epic"
411    } else {
412        "FalloutNV"
413    }
414}
415
416fn fallout4_appdata_folder_name(game_path: &Path) -> &'static str {
417    if is_microsoft_store_install(GameId::Fallout4, game_path) {
418        "Fallout4 MS"
419    } else if game_path.join("EOSSDK-Win64-Shipping.dll").exists() {
420        // EOSSDK-Win64-Shipping.dll is only installed by Epic.
421        "Fallout4 EPIC"
422    } else {
423        "Fallout4"
424    }
425}
426
427fn my_games_path(
428    game_id: GameId,
429    game_path: &Path,
430    local_path: &Path,
431) -> Result<Option<PathBuf>, Error> {
432    if game_id == GameId::OpenMW {
433        // Use the local path as the my games path, so that both refer to the
434        // user config path for OpenMW.
435        return Ok(Some(local_path.to_path_buf()));
436    }
437
438    my_games_folder_name(game_id, game_path)
439        .map(|folder| {
440            documents_path(local_path)
441                .map(|d| d.join("My Games").join(folder))
442                .ok_or_else(|| Error::NoDocumentsPath)
443        })
444        .transpose()
445}
446
447fn my_games_folder_name(game_id: GameId, game_path: &Path) -> Option<&'static str> {
448    match game_id {
449        GameId::OpenMW => Some("OpenMW"),
450        GameId::OblivionRemastered => Some("Oblivion Remastered"),
451        GameId::Skyrim => Some(skyrim_my_games_folder_name(game_path)),
452        // For all other games the name is the same as the AppData\Local folder name.
453        _ => appdata_folder_name(game_id, game_path),
454    }
455}
456
457fn skyrim_my_games_folder_name(game_path: &Path) -> &'static str {
458    if is_enderal(game_path) {
459        "Enderal"
460    } else {
461        "Skyrim"
462    }
463}
464
465fn is_microsoft_store_install(game_id: GameId, game_path: &Path) -> bool {
466    const APPX_MANIFEST: &str = "appxmanifest.xml";
467
468    match game_id {
469        GameId::Morrowind | GameId::Oblivion | GameId::Fallout3 | GameId::FalloutNV => game_path
470            .parent()
471            .is_some_and(|parent| parent.join(APPX_MANIFEST).exists()),
472        GameId::SkyrimSE | GameId::Fallout4 | GameId::Starfield | GameId::OblivionRemastered => {
473            game_path.join(APPX_MANIFEST).exists()
474        }
475        _ => false,
476    }
477}
478
479#[cfg(windows)]
480fn documents_path(_local_path: &Path) -> Option<PathBuf> {
481    dirs::document_dir()
482}
483
484#[cfg(not(windows))]
485fn documents_path(local_path: &Path) -> Option<PathBuf> {
486    // Get the documents path relative to the game's local path, which should end in
487    // AppData/Local/<Game>.
488    local_path
489        .parent()
490        .and_then(Path::parent)
491        .and_then(Path::parent)
492        .map(|p| p.join("Documents"))
493        .or_else(|| {
494            // Fall back to creating a path that navigates up parent directories. This may give a
495            // different result if local_path involves symlinks, and requires local_path to exist.
496            Some(local_path.join("../../../Documents"))
497        })
498}
499
500fn plugins_directory(
501    game_id: GameId,
502    game_path: &Path,
503    local_path: &Path,
504) -> Result<PathBuf, Error> {
505    match game_id {
506        GameId::OpenMW => openmw_config::resources_vfs_path(game_path, local_path),
507        GameId::Morrowind => Ok(game_path.join("Data Files")),
508        GameId::OblivionRemastered => Ok(game_path.join(OBLIVION_REMASTERED_RELATIVE_DATA_PATH)),
509        _ => Ok(game_path.join("Data")),
510    }
511}
512
513fn additional_plugins_directories(
514    game_id: GameId,
515    game_path: &Path,
516    my_games_path: &Path,
517) -> Result<Vec<PathBuf>, Error> {
518    if game_id == GameId::Fallout4 && is_microsoft_store_install(game_id, game_path) {
519        Ok(vec![
520            game_path.join(MS_FO4_AUTOMATRON_PATH),
521            game_path.join(MS_FO4_NUKA_WORLD_PATH),
522            game_path.join(MS_FO4_WASTELAND_PATH),
523            game_path.join(MS_FO4_TEXTURE_PACK_PATH),
524            game_path.join(MS_FO4_VAULT_TEC_PATH),
525            game_path.join(MS_FO4_FAR_HARBOR_PATH),
526            game_path.join(MS_FO4_CONTRAPTIONS_PATH),
527        ])
528    } else if game_id == GameId::Starfield {
529        if is_microsoft_store_install(game_id, game_path) {
530            Ok(vec![
531                my_games_path.join("Data"),
532                game_path.join("../../Old Mars/Content/Data"),
533                game_path.join("../../Shattered Space/Content/Data"),
534            ])
535        } else {
536            Ok(vec![my_games_path.join("Data")])
537        }
538    } else if game_id == GameId::OpenMW {
539        openmw_config::additional_data_paths(game_path, my_games_path)
540    } else {
541        Ok(Vec::new())
542    }
543}
544
545fn load_order_path(
546    game_id: GameId,
547    local_path: &Path,
548    plugins_file_path: &Path,
549) -> Option<PathBuf> {
550    const LOADORDER_TXT: &str = "loadorder.txt";
551    match game_id {
552        GameId::Skyrim => Some(local_path.join(LOADORDER_TXT)),
553        GameId::OblivionRemastered => plugins_file_path.parent().map(|p| p.join(LOADORDER_TXT)),
554        _ => None,
555    }
556}
557
558fn plugins_file_path(
559    game_id: GameId,
560    game_path: &Path,
561    local_path: &Path,
562) -> Result<PathBuf, Error> {
563    match game_id {
564        GameId::OpenMW => Ok(local_path.join("openmw.cfg")),
565        GameId::Morrowind => Ok(game_path.join("Morrowind.ini")),
566        GameId::Oblivion => oblivion_plugins_file_path(game_path, local_path),
567        GameId::OblivionRemastered => Ok(game_path
568            .join(OBLIVION_REMASTERED_RELATIVE_DATA_PATH)
569            .join(PLUGINS_TXT)),
570        // Although the launchers for Fallout 3, Fallout NV and Skyrim all create plugins.txt, the
571        // games themselves read Plugins.txt.
572        _ => Ok(local_path.join(PLUGINS_TXT)),
573    }
574}
575
576fn oblivion_plugins_file_path(game_path: &Path, local_path: &Path) -> Result<PathBuf, Error> {
577    let ini_path = game_path.join("Oblivion.ini");
578
579    let parent_path = if use_my_games_directory(&ini_path)? {
580        local_path
581    } else {
582        game_path
583    };
584
585    // Although Oblivion's launcher creates plugins.txt, the game itself reads Plugins.txt.
586    Ok(parent_path.join(PLUGINS_TXT))
587}
588
589fn ccc_file_paths(game_id: GameId, game_path: &Path, my_games_path: &Path) -> Vec<PathBuf> {
590    match game_id {
591        GameId::Fallout4 => vec![game_path.join("Fallout4.ccc")],
592        GameId::SkyrimSE => vec![game_path.join("Skyrim.ccc")],
593        // If the My Games CCC file is present, it overrides the other, even if empty.
594        GameId::Starfield => vec![
595            my_games_path.join("Starfield.ccc"),
596            game_path.join("Starfield.ccc"),
597        ],
598        _ => vec![],
599    }
600}
601
602fn hardcoded_plugins(game_id: GameId) -> &'static [&'static str] {
603    match game_id {
604        GameId::Skyrim => SKYRIM_HARDCODED_PLUGINS,
605        GameId::SkyrimSE => SKYRIM_SE_HARDCODED_PLUGINS,
606        GameId::SkyrimVR => SKYRIM_VR_HARDCODED_PLUGINS,
607        GameId::Fallout4 => FALLOUT4_HARDCODED_PLUGINS,
608        GameId::Fallout4VR => FALLOUT4VR_HARDCODED_PLUGINS,
609        GameId::Starfield => STARFIELD_HARDCODED_PLUGINS,
610        GameId::OpenMW => OPENMW_HARDCODED_PLUGINS,
611        _ => &[],
612    }
613}
614
615fn find_nam_plugins(plugins_path: &Path) -> Result<Vec<String>, Error> {
616    // Scan the path for .nam files. Each .nam file can activate a .esm or .esp
617    // plugin with the same basename, so return those filenames.
618    let mut plugin_names = Vec::new();
619
620    if !plugins_path.exists() {
621        return Ok(plugin_names);
622    }
623
624    let dir_iter = plugins_path
625        .read_dir()
626        .map_err(|e| Error::IoError(plugins_path.to_path_buf(), e))?
627        .filter_map(Result::ok)
628        .filter(|e| is_file_type_supported(e, GameId::FalloutNV))
629        .filter(|e| {
630            e.path()
631                .extension()
632                .unwrap_or_default()
633                .eq_ignore_ascii_case("nam")
634        });
635
636    for entry in dir_iter {
637        let file_name = entry.file_name();
638
639        let plugin = Path::new(&file_name).with_extension("esp");
640        if let Some(esp) = plugin.to_str() {
641            plugin_names.push(esp.to_owned());
642        }
643
644        let master = Path::new(&file_name).with_extension("esm");
645        if let Some(esm) = master.to_str() {
646            plugin_names.push(esm.to_owned());
647        }
648    }
649
650    Ok(plugin_names)
651}
652
653fn is_file_type_supported(dir_entry: &DirEntry, game_id: GameId) -> bool {
654    dir_entry
655        .file_type()
656        .map(|f| keep_file_type(f, game_id))
657        .unwrap_or(false)
658}
659
660#[cfg(unix)]
661#[expect(
662    clippy::filetype_is_file,
663    reason = "Only files and symlinks are supported"
664)]
665fn keep_file_type(f: FileType, _game_id: GameId) -> bool {
666    f.is_file() || f.is_symlink()
667}
668
669#[cfg(windows)]
670#[expect(
671    clippy::filetype_is_file,
672    reason = "Only files and sometimes symlinks are supported"
673)]
674fn keep_file_type(f: FileType, game_id: GameId) -> bool {
675    if matches!(game_id, GameId::OblivionRemastered | GameId::OpenMW) {
676        f.is_file() || f.is_symlink()
677    } else {
678        f.is_file()
679    }
680}
681
682fn early_loading_plugins(
683    game_id: GameId,
684    game_path: &Path,
685    my_games_path: &Path,
686    has_test_files: bool,
687) -> Result<Vec<String>, Error> {
688    let mut plugin_names: Vec<String> = hardcoded_plugins(game_id)
689        .iter()
690        .map(|s| (*s).to_owned())
691        .collect();
692
693    if matches!(game_id, GameId::Fallout4 | GameId::Starfield) && has_test_files {
694        // If test files are configured for Fallout 4, CCC plugins are not loaded.
695        // No need to check for Fallout 4 VR, as it has no CCC plugins file.
696        return Ok(plugin_names);
697    }
698
699    for file_path in ccc_file_paths(game_id, game_path, my_games_path) {
700        if file_path.exists() {
701            let reader =
702                BufReader::new(File::open(&file_path).map_err(|e| Error::IoError(file_path, e))?);
703
704            let lines = reader
705                .lines()
706                .filter_map(|line| line.ok().filter(|l| !l.is_empty()));
707
708            plugin_names.extend(lines);
709            break;
710        }
711    }
712
713    if game_id == GameId::OpenMW {
714        plugin_names.extend(openmw_config::non_user_active_plugin_names(game_path)?);
715    }
716
717    deduplicate(&mut plugin_names);
718
719    Ok(plugin_names)
720}
721
722fn implicitly_active_plugins(
723    game_id: GameId,
724    game_path: &Path,
725    early_loading_plugins: &[String],
726    test_files: &[String],
727) -> Result<Vec<String>, Error> {
728    let mut plugin_names = Vec::new();
729
730    plugin_names.extend_from_slice(early_loading_plugins);
731    plugin_names.extend_from_slice(test_files);
732
733    if game_id == GameId::FalloutNV {
734        // If there is a .nam file with the same basename as a plugin then the plugin is activated
735        // and listed as a DLC in the game's title screen menu. This only works in the game's
736        // Data path, so ignore additional plugin directories.
737        let nam_plugins = find_nam_plugins(&game_path.join("Data"))?;
738
739        plugin_names.extend(nam_plugins);
740    } else if game_id == GameId::Skyrim {
741        // Update.esm is always active, but loads after all other masters if it is not made to load
742        // earlier (e.g. by listing in plugins.txt or by being a master of another master).
743        plugin_names.push("Update.esm".to_owned());
744    } else if game_id == GameId::Starfield {
745        // BlueprintShips-Starfield.esm is always active but loads after all other plugins if not
746        // made to load earlier.
747        plugin_names.push("BlueprintShips-Starfield.esm".to_owned());
748    }
749
750    deduplicate(&mut plugin_names);
751
752    Ok(plugin_names)
753}
754
755/// Remove duplicates, keeping only the first instance of each plugin.
756fn deduplicate(plugin_names: &mut Vec<String>) {
757    let mut set = std::collections::HashSet::new();
758    plugin_names.retain(|e| set.insert(unicase::UniCase::new(e.clone())));
759}
760
761fn find_map_path(directory: &Path, plugin_name: &str, game_id: GameId) -> Option<PathBuf> {
762    if game_id.allow_plugin_ghosting() {
763        // Plugins may be ghosted, so take that into account when checking.
764        use crate::ghostable_path::GhostablePath;
765
766        directory.join(plugin_name).resolve_path().ok()
767    } else {
768        let path = directory.join(plugin_name);
769        path.exists().then_some(path)
770    }
771}
772
773fn pick_plugin_path<'a>(
774    game_id: GameId,
775    plugin_name: &str,
776    plugins_directory: &Path,
777    mut dir_iter: impl Iterator<Item = &'a PathBuf>,
778) -> PathBuf {
779    dir_iter
780        .find_map(|d| find_map_path(d, plugin_name, game_id))
781        .unwrap_or_else(|| plugins_directory.join(plugin_name))
782}
783
784fn plugin_path(
785    game_id: GameId,
786    plugin_name: &str,
787    plugins_directory: &Path,
788    additional_plugins_directories: &[PathBuf],
789) -> PathBuf {
790    // There may be multiple directories that the plugin could be installed in, so check each in
791    // turn.
792
793    // Starfield (at least as of 1.12.32.0) only loads plugins from its additional directory if
794    // they're also present in plugins_directory, so there's no point checking the additional
795    // directory if a plugin isn't present in plugins_directory.
796    if game_id == GameId::Starfield {
797        // Plugins may be ghosted, so take that into account when checking.
798        use crate::ghostable_path::GhostablePath;
799
800        let path = plugins_directory.join(plugin_name);
801        if path.resolve_path().is_err() {
802            return path;
803        }
804    }
805
806    // In OpenMW, if there are multiple directories containing the same filename, the last directory
807    // listed "wins".
808    match game_id {
809        GameId::OpenMW => pick_plugin_path(
810            game_id,
811            plugin_name,
812            plugins_directory,
813            additional_plugins_directories.iter().rev(),
814        ),
815        _ => pick_plugin_path(
816            game_id,
817            plugin_name,
818            plugins_directory,
819            additional_plugins_directories.iter(),
820        ),
821    }
822}
823
824fn sort_plugins_dir_entries(a: &DirEntry, b: &DirEntry) -> Ordering {
825    // Sort by file modification timestamps, in ascending order. If two
826    // timestamps are equal, sort by filenames in descending order.
827    let m_a = get_target_modified_timestamp(a);
828    let m_b = get_target_modified_timestamp(b);
829
830    match m_a.cmp(&m_b) {
831        Ordering::Equal => a.file_name().cmp(&b.file_name()).reverse(),
832        x => x,
833    }
834}
835
836fn sort_plugins_dir_entries_starfield(a: &DirEntry, b: &DirEntry) -> Ordering {
837    // Sort by file modification timestamps, in ascending order. If two
838    // timestamps are equal, sort by filenames in ascending order.
839    let m_a = get_target_modified_timestamp(a);
840    let m_b = get_target_modified_timestamp(b);
841
842    match m_a.cmp(&m_b) {
843        Ordering::Equal => a.file_name().cmp(&b.file_name()),
844        x => x,
845    }
846}
847
848fn get_target_modified_timestamp(entry: &DirEntry) -> Option<SystemTime> {
849    let metadata = if entry.file_type().is_ok_and(|f| f.is_symlink()) {
850        entry.path().metadata()
851    } else {
852        entry.metadata()
853    };
854
855    metadata.and_then(|m| m.modified()).ok()
856}
857
858fn sort_plugins_dir_entries_openmw(a: &DirEntry, b: &DirEntry) -> Ordering {
859    // Preserve the directory ordering, but sort case-sensitive
860    // lexicographically within directories.
861    if a.path().parent() == b.path().parent() {
862        a.file_name().cmp(&b.file_name())
863    } else {
864        Ordering::Equal
865    }
866}
867
868fn find_plugins_in_directories<'a>(
869    directories_iter: impl Iterator<Item = &'a PathBuf>,
870    game_id: GameId,
871) -> Vec<PathBuf> {
872    let mut dir_entries: Vec<_> = directories_iter
873        .flat_map(read_dir)
874        .flatten()
875        .filter_map(Result::ok)
876        .filter(|e| is_file_type_supported(e, game_id))
877        .filter(|e| {
878            e.file_name()
879                .to_str()
880                .is_some_and(|f| has_plugin_extension(f, game_id))
881        })
882        .collect();
883
884    let compare = match game_id {
885        GameId::OpenMW => sort_plugins_dir_entries_openmw,
886        GameId::Starfield => sort_plugins_dir_entries_starfield,
887        _ => sort_plugins_dir_entries,
888    };
889
890    dir_entries.sort_by(compare);
891
892    dir_entries.into_iter().map(|e| e.path()).collect()
893}
894
895#[cfg(test)]
896mod tests {
897    #[cfg(windows)]
898    use std::env;
899    use std::{fs::create_dir_all, io::Write};
900    use tempfile::tempdir;
901
902    use crate::tests::{copy_to_dir, set_file_timestamps, symlink_file, NON_ASCII};
903
904    use super::*;
905
906    fn game_with_generic_paths(game_id: GameId) -> GameSettings {
907        GameSettings::with_local_and_my_games_paths(
908            game_id,
909            &PathBuf::from("game"),
910            &PathBuf::from("local"),
911            PathBuf::from("my games"),
912        )
913        .unwrap()
914    }
915
916    fn game_with_game_path(game_id: GameId, game_path: &Path) -> GameSettings {
917        GameSettings::with_local_and_my_games_paths(
918            game_id,
919            game_path,
920            &PathBuf::default(),
921            PathBuf::default(),
922        )
923        .unwrap()
924    }
925
926    fn game_with_ccc_plugins(
927        game_id: GameId,
928        game_path: &Path,
929        plugin_names: &[&str],
930    ) -> GameSettings {
931        let ccc_path = &ccc_file_paths(game_id, game_path, &PathBuf::new())[0];
932        create_ccc_file(ccc_path, plugin_names);
933
934        game_with_game_path(game_id, game_path)
935    }
936
937    fn create_ccc_file(path: &Path, plugin_names: &[&str]) {
938        create_dir_all(path.parent().unwrap()).unwrap();
939
940        let mut file = File::create(path).unwrap();
941
942        for plugin_name in plugin_names {
943            writeln!(file, "{plugin_name}").unwrap();
944        }
945    }
946
947    fn generate_file_file_type() -> FileType {
948        let tmp_dir = tempdir().unwrap();
949        let file_path = tmp_dir.path().join("file");
950
951        File::create(&file_path).unwrap();
952
953        let file_file_type = file_path.metadata().unwrap().file_type();
954
955        assert!(file_file_type.is_file());
956
957        file_file_type
958    }
959
960    fn generate_symlink_file_type() -> FileType {
961        let tmp_dir = tempdir().unwrap();
962        let file_path = tmp_dir.path().join("file");
963        let symlink_path = tmp_dir.path().join("symlink");
964
965        File::create(&file_path).unwrap();
966        symlink_file(&file_path, &symlink_path);
967
968        let symlink_file_type = symlink_path.symlink_metadata().unwrap().file_type();
969
970        assert!(symlink_file_type.is_symlink());
971
972        symlink_file_type
973    }
974
975    #[test]
976    #[cfg(windows)]
977    fn new_should_determine_correct_local_path_on_windows() {
978        let settings = GameSettings::new(GameId::Skyrim, Path::new("game")).unwrap();
979        let local_app_data = env::var("LOCALAPPDATA").unwrap();
980        let local_app_data_path = Path::new(&local_app_data);
981
982        assert_eq!(
983            local_app_data_path.join("Skyrim").join("Plugins.txt"),
984            *settings.active_plugins_file()
985        );
986        assert_eq!(
987            &local_app_data_path.join("Skyrim").join("loadorder.txt"),
988            *settings.load_order_file().as_ref().unwrap()
989        );
990    }
991
992    #[test]
993    #[cfg(windows)]
994    fn new_should_determine_correct_local_path_for_openmw() {
995        let tmp_dir = tempdir().unwrap();
996        let global_cfg_path = tmp_dir.path().join("openmw.cfg");
997
998        std::fs::write(&global_cfg_path, "config=local").unwrap();
999
1000        let settings = GameSettings::new(GameId::OpenMW, tmp_dir.path()).unwrap();
1001
1002        assert_eq!(
1003            &tmp_dir.path().join("local/openmw.cfg"),
1004            settings.active_plugins_file()
1005        );
1006        assert_eq!(tmp_dir.path().join("local"), settings.my_games_path);
1007    }
1008
1009    #[test]
1010    fn new_should_use_an_empty_local_path_for_morrowind() {
1011        let settings = GameSettings::new(GameId::Morrowind, Path::new("game")).unwrap();
1012
1013        assert_eq!(PathBuf::new(), settings.my_games_path);
1014    }
1015
1016    #[test]
1017    #[cfg(not(windows))]
1018    fn new_should_determine_correct_local_path_for_openmw_on_linux() {
1019        let config_path = Path::new("/etc/openmw");
1020
1021        let settings = GameSettings::new(GameId::OpenMW, Path::new("game")).unwrap();
1022
1023        assert_eq!(
1024            &config_path.join("openmw.cfg"),
1025            settings.active_plugins_file()
1026        );
1027        assert_eq!(config_path, settings.my_games_path);
1028    }
1029
1030    #[test]
1031    fn id_should_be_the_id_the_struct_was_created_with() {
1032        let settings = game_with_generic_paths(GameId::Morrowind);
1033        assert_eq!(GameId::Morrowind, settings.id());
1034    }
1035
1036    #[test]
1037    fn load_order_method_should_be_timestamp_for_tes3_tes4_fo3_and_fonv() {
1038        let mut settings = game_with_generic_paths(GameId::Morrowind);
1039        assert_eq!(LoadOrderMethod::Timestamp, settings.load_order_method());
1040
1041        settings = game_with_generic_paths(GameId::Oblivion);
1042        assert_eq!(LoadOrderMethod::Timestamp, settings.load_order_method());
1043
1044        settings = game_with_generic_paths(GameId::Fallout3);
1045        assert_eq!(LoadOrderMethod::Timestamp, settings.load_order_method());
1046
1047        settings = game_with_generic_paths(GameId::FalloutNV);
1048        assert_eq!(LoadOrderMethod::Timestamp, settings.load_order_method());
1049    }
1050
1051    #[test]
1052    fn load_order_method_should_be_textfile_for_tes5() {
1053        let settings = game_with_generic_paths(GameId::Skyrim);
1054        assert_eq!(LoadOrderMethod::Textfile, settings.load_order_method());
1055    }
1056
1057    #[test]
1058    fn load_order_method_should_be_asterisk_for_tes5se_tes5vr_fo4_fo4vr_and_starfield() {
1059        let mut settings = game_with_generic_paths(GameId::SkyrimSE);
1060        assert_eq!(LoadOrderMethod::Asterisk, settings.load_order_method());
1061
1062        settings = game_with_generic_paths(GameId::SkyrimVR);
1063        assert_eq!(LoadOrderMethod::Asterisk, settings.load_order_method());
1064
1065        settings = game_with_generic_paths(GameId::Fallout4);
1066        assert_eq!(LoadOrderMethod::Asterisk, settings.load_order_method());
1067
1068        settings = game_with_generic_paths(GameId::Fallout4VR);
1069        assert_eq!(LoadOrderMethod::Asterisk, settings.load_order_method());
1070
1071        settings = game_with_generic_paths(GameId::Starfield);
1072        assert_eq!(LoadOrderMethod::Asterisk, settings.load_order_method());
1073    }
1074
1075    #[test]
1076    fn load_order_method_should_be_openmw_for_openmw() {
1077        let settings = game_with_generic_paths(GameId::OpenMW);
1078
1079        assert_eq!(LoadOrderMethod::OpenMW, settings.load_order_method());
1080    }
1081
1082    #[test]
1083    #[expect(deprecated)]
1084    fn master_file_should_be_mapped_from_game_id() {
1085        let mut settings = game_with_generic_paths(GameId::OpenMW);
1086        assert_eq!("Morrowind.esm", settings.master_file());
1087
1088        settings = game_with_generic_paths(GameId::Morrowind);
1089        assert_eq!("Morrowind.esm", settings.master_file());
1090
1091        settings = game_with_generic_paths(GameId::Oblivion);
1092        assert_eq!("Oblivion.esm", settings.master_file());
1093
1094        settings = game_with_generic_paths(GameId::Skyrim);
1095        assert_eq!("Skyrim.esm", settings.master_file());
1096
1097        settings = game_with_generic_paths(GameId::SkyrimSE);
1098        assert_eq!("Skyrim.esm", settings.master_file());
1099
1100        settings = game_with_generic_paths(GameId::SkyrimVR);
1101        assert_eq!("Skyrim.esm", settings.master_file());
1102
1103        settings = game_with_generic_paths(GameId::Fallout3);
1104        assert_eq!("Fallout3.esm", settings.master_file());
1105
1106        settings = game_with_generic_paths(GameId::FalloutNV);
1107        assert_eq!("FalloutNV.esm", settings.master_file());
1108
1109        settings = game_with_generic_paths(GameId::Fallout4);
1110        assert_eq!("Fallout4.esm", settings.master_file());
1111
1112        settings = game_with_generic_paths(GameId::Fallout4VR);
1113        assert_eq!("Fallout4.esm", settings.master_file());
1114
1115        settings = game_with_generic_paths(GameId::Starfield);
1116        assert_eq!("Starfield.esm", settings.master_file());
1117    }
1118
1119    #[test]
1120    fn appdata_folder_name_should_be_mapped_from_game_id() {
1121        // The game path is unused for most game IDs.
1122        let game_path = Path::new("");
1123
1124        assert!(appdata_folder_name(GameId::OpenMW, game_path).is_none());
1125
1126        assert!(appdata_folder_name(GameId::Morrowind, game_path).is_none());
1127
1128        let mut folder = appdata_folder_name(GameId::Oblivion, game_path).unwrap();
1129        assert_eq!("Oblivion", folder);
1130
1131        folder = appdata_folder_name(GameId::Skyrim, game_path).unwrap();
1132        assert_eq!("Skyrim", folder);
1133
1134        folder = appdata_folder_name(GameId::SkyrimSE, game_path).unwrap();
1135        assert_eq!("Skyrim Special Edition", folder);
1136
1137        folder = appdata_folder_name(GameId::SkyrimVR, game_path).unwrap();
1138        assert_eq!("Skyrim VR", folder);
1139
1140        folder = appdata_folder_name(GameId::Fallout3, game_path).unwrap();
1141        assert_eq!("Fallout3", folder);
1142
1143        folder = appdata_folder_name(GameId::FalloutNV, game_path).unwrap();
1144        assert_eq!("FalloutNV", folder);
1145
1146        folder = appdata_folder_name(GameId::Fallout4, game_path).unwrap();
1147        assert_eq!("Fallout4", folder);
1148
1149        folder = appdata_folder_name(GameId::Fallout4VR, game_path).unwrap();
1150        assert_eq!("Fallout4VR", folder);
1151
1152        folder = appdata_folder_name(GameId::Starfield, game_path).unwrap();
1153        assert_eq!("Starfield", folder);
1154    }
1155
1156    #[test]
1157    fn appdata_folder_name_for_skyrim_se_should_have_gog_suffix_if_galaxy_dll_is_in_game_path() {
1158        let tmp_dir = tempdir().unwrap();
1159        let game_path = tmp_dir.path();
1160
1161        let mut folder = appdata_folder_name(GameId::SkyrimSE, game_path).unwrap();
1162        assert_eq!("Skyrim Special Edition", folder);
1163
1164        let dll_path = game_path.join("Galaxy64.dll");
1165        File::create(&dll_path).unwrap();
1166
1167        folder = appdata_folder_name(GameId::SkyrimSE, game_path).unwrap();
1168        assert_eq!("Skyrim Special Edition GOG", folder);
1169    }
1170
1171    #[test]
1172    fn appdata_folder_name_for_skyrim_se_should_have_epic_suffix_if_eossdk_dll_is_in_game_path() {
1173        let tmp_dir = tempdir().unwrap();
1174        let game_path = tmp_dir.path();
1175
1176        let mut folder = appdata_folder_name(GameId::SkyrimSE, game_path).unwrap();
1177        assert_eq!("Skyrim Special Edition", folder);
1178
1179        let dll_path = game_path.join("EOSSDK-Win64-Shipping.dll");
1180        File::create(&dll_path).unwrap();
1181
1182        folder = appdata_folder_name(GameId::SkyrimSE, game_path).unwrap();
1183        assert_eq!("Skyrim Special Edition EPIC", folder);
1184    }
1185
1186    #[test]
1187    fn appdata_folder_name_for_skyrim_se_should_have_ms_suffix_if_appxmanifest_xml_is_in_game_path()
1188    {
1189        let tmp_dir = tempdir().unwrap();
1190        let game_path = tmp_dir.path();
1191
1192        let mut folder = appdata_folder_name(GameId::SkyrimSE, game_path).unwrap();
1193        assert_eq!("Skyrim Special Edition", folder);
1194
1195        let dll_path = game_path.join("appxmanifest.xml");
1196        File::create(&dll_path).unwrap();
1197
1198        folder = appdata_folder_name(GameId::SkyrimSE, game_path).unwrap();
1199        assert_eq!("Skyrim Special Edition MS", folder);
1200    }
1201
1202    #[test]
1203    fn appdata_folder_name_for_skyrim_se_prefers_gog_suffix_over_epic_suffix() {
1204        let tmp_dir = tempdir().unwrap();
1205        let game_path = tmp_dir.path();
1206
1207        let dll_path = game_path.join("Galaxy64.dll");
1208        File::create(&dll_path).unwrap();
1209
1210        let dll_path = game_path.join("EOSSDK-Win64-Shipping.dll");
1211        File::create(&dll_path).unwrap();
1212
1213        let folder = appdata_folder_name(GameId::SkyrimSE, game_path).unwrap();
1214        assert_eq!("Skyrim Special Edition GOG", folder);
1215    }
1216
1217    #[test]
1218    fn appdata_folder_name_for_fallout_nv_should_have_epic_suffix_if_eossdk_dll_is_in_game_path() {
1219        let tmp_dir = tempdir().unwrap();
1220        let game_path = tmp_dir.path();
1221
1222        let mut folder = appdata_folder_name(GameId::FalloutNV, game_path).unwrap();
1223        assert_eq!("FalloutNV", folder);
1224
1225        let dll_path = game_path.join("EOSSDK-Win32-Shipping.dll");
1226        File::create(&dll_path).unwrap();
1227
1228        folder = appdata_folder_name(GameId::FalloutNV, game_path).unwrap();
1229        assert_eq!("FalloutNV_Epic", folder);
1230    }
1231
1232    #[test]
1233    fn appdata_folder_name_for_fallout4_should_have_ms_suffix_if_appxmanifest_xml_is_in_game_path()
1234    {
1235        let tmp_dir = tempdir().unwrap();
1236        let game_path = tmp_dir.path();
1237
1238        let mut folder = appdata_folder_name(GameId::Fallout4, game_path).unwrap();
1239        assert_eq!("Fallout4", folder);
1240
1241        let dll_path = game_path.join("appxmanifest.xml");
1242        File::create(&dll_path).unwrap();
1243
1244        folder = appdata_folder_name(GameId::Fallout4, game_path).unwrap();
1245        assert_eq!("Fallout4 MS", folder);
1246    }
1247
1248    #[test]
1249    fn appdata_folder_name_for_fallout4_should_have_epic_suffix_if_eossdk_dll_is_in_game_path() {
1250        let tmp_dir = tempdir().unwrap();
1251        let game_path = tmp_dir.path();
1252
1253        let mut folder = appdata_folder_name(GameId::Fallout4, game_path).unwrap();
1254        assert_eq!("Fallout4", folder);
1255
1256        let dll_path = game_path.join("EOSSDK-Win64-Shipping.dll");
1257        File::create(&dll_path).unwrap();
1258
1259        folder = appdata_folder_name(GameId::Fallout4, game_path).unwrap();
1260        assert_eq!("Fallout4 EPIC", folder);
1261    }
1262
1263    #[test]
1264    #[cfg(windows)]
1265    fn my_games_path_should_be_in_documents_path_on_windows() {
1266        let empty_path = Path::new("");
1267        let parent_path = dirs::document_dir().unwrap().join("My Games");
1268
1269        let path = my_games_path(GameId::Morrowind, empty_path, empty_path).unwrap();
1270        assert!(path.is_none());
1271
1272        let path = my_games_path(GameId::Oblivion, empty_path, empty_path)
1273            .unwrap()
1274            .unwrap();
1275        assert_eq!(parent_path.join("Oblivion"), path);
1276
1277        let path = my_games_path(GameId::Skyrim, empty_path, empty_path)
1278            .unwrap()
1279            .unwrap();
1280        assert_eq!(parent_path.join("Skyrim"), path);
1281
1282        let path = my_games_path(GameId::SkyrimSE, empty_path, empty_path)
1283            .unwrap()
1284            .unwrap();
1285        assert_eq!(parent_path.join("Skyrim Special Edition"), path);
1286
1287        let path = my_games_path(GameId::SkyrimVR, empty_path, empty_path)
1288            .unwrap()
1289            .unwrap();
1290        assert_eq!(parent_path.join("Skyrim VR"), path);
1291
1292        let path = my_games_path(GameId::Fallout3, empty_path, empty_path)
1293            .unwrap()
1294            .unwrap();
1295        assert_eq!(parent_path.join("Fallout3"), path);
1296
1297        let path = my_games_path(GameId::FalloutNV, empty_path, empty_path)
1298            .unwrap()
1299            .unwrap();
1300        assert_eq!(parent_path.join("FalloutNV"), path);
1301
1302        let path = my_games_path(GameId::Fallout4, empty_path, empty_path)
1303            .unwrap()
1304            .unwrap();
1305        assert_eq!(parent_path.join("Fallout4"), path);
1306
1307        let path = my_games_path(GameId::Fallout4VR, empty_path, empty_path)
1308            .unwrap()
1309            .unwrap();
1310        assert_eq!(parent_path.join("Fallout4VR"), path);
1311
1312        let path = my_games_path(GameId::Starfield, empty_path, empty_path)
1313            .unwrap()
1314            .unwrap();
1315        assert_eq!(parent_path.join("Starfield"), path);
1316    }
1317
1318    #[test]
1319    #[cfg(not(windows))]
1320    fn my_games_path_should_be_relative_to_local_path_on_linux() {
1321        let empty_path = Path::new("");
1322        let local_path = Path::new("wineprefix/drive_c/Users/user/AppData/Local/Game");
1323        let parent_path = Path::new("wineprefix/drive_c/Users/user/Documents/My Games");
1324
1325        let path = my_games_path(GameId::Morrowind, empty_path, local_path).unwrap();
1326        assert!(path.is_none());
1327
1328        let path = my_games_path(GameId::Oblivion, empty_path, local_path)
1329            .unwrap()
1330            .unwrap();
1331        assert_eq!(parent_path.join("Oblivion"), path);
1332
1333        let path = my_games_path(GameId::Skyrim, empty_path, local_path)
1334            .unwrap()
1335            .unwrap();
1336        assert_eq!(parent_path.join("Skyrim"), path);
1337
1338        let path = my_games_path(GameId::SkyrimSE, empty_path, local_path)
1339            .unwrap()
1340            .unwrap();
1341        assert_eq!(parent_path.join("Skyrim Special Edition"), path);
1342
1343        let path = my_games_path(GameId::SkyrimVR, empty_path, local_path)
1344            .unwrap()
1345            .unwrap();
1346        assert_eq!(parent_path.join("Skyrim VR"), path);
1347
1348        let path = my_games_path(GameId::Fallout3, empty_path, local_path)
1349            .unwrap()
1350            .unwrap();
1351        assert_eq!(parent_path.join("Fallout3"), path);
1352
1353        let path = my_games_path(GameId::FalloutNV, empty_path, local_path)
1354            .unwrap()
1355            .unwrap();
1356        assert_eq!(parent_path.join("FalloutNV"), path);
1357
1358        let path = my_games_path(GameId::Fallout4, empty_path, local_path)
1359            .unwrap()
1360            .unwrap();
1361        assert_eq!(parent_path.join("Fallout4"), path);
1362
1363        let path = my_games_path(GameId::Fallout4VR, empty_path, local_path)
1364            .unwrap()
1365            .unwrap();
1366        assert_eq!(parent_path.join("Fallout4VR"), path);
1367
1368        let path = my_games_path(GameId::Starfield, empty_path, local_path)
1369            .unwrap()
1370            .unwrap();
1371        assert_eq!(parent_path.join("Starfield"), path);
1372    }
1373
1374    #[test]
1375    #[cfg(windows)]
1376    fn my_games_path_should_be_local_path_for_openmw() {
1377        let local_path = Path::new("path/to/local");
1378
1379        let path = my_games_path(GameId::OpenMW, Path::new(""), local_path)
1380            .unwrap()
1381            .unwrap();
1382        assert_eq!(local_path, path);
1383    }
1384
1385    #[test]
1386    fn plugins_directory_should_be_mapped_from_game_id() {
1387        let data_path = Path::new("Data");
1388        let empty_path = Path::new("");
1389        let closure = |game_id| plugins_directory(game_id, empty_path, empty_path).unwrap();
1390
1391        assert_eq!(Path::new("resources/vfs"), closure(GameId::OpenMW));
1392        assert_eq!(Path::new("Data Files"), closure(GameId::Morrowind));
1393        assert_eq!(data_path, closure(GameId::Oblivion));
1394        assert_eq!(data_path, closure(GameId::Skyrim));
1395        assert_eq!(data_path, closure(GameId::SkyrimSE));
1396        assert_eq!(data_path, closure(GameId::SkyrimVR));
1397        assert_eq!(data_path, closure(GameId::Fallout3));
1398        assert_eq!(data_path, closure(GameId::FalloutNV));
1399        assert_eq!(data_path, closure(GameId::Fallout4));
1400        assert_eq!(data_path, closure(GameId::Fallout4VR));
1401        assert_eq!(data_path, closure(GameId::Starfield));
1402    }
1403
1404    #[test]
1405    fn active_plugins_file_should_be_mapped_from_game_id() {
1406        let mut settings = game_with_generic_paths(GameId::OpenMW);
1407        assert_eq!(
1408            Path::new("local/openmw.cfg"),
1409            settings.active_plugins_file()
1410        );
1411
1412        settings = game_with_generic_paths(GameId::Morrowind);
1413        assert_eq!(
1414            Path::new("game/Morrowind.ini"),
1415            settings.active_plugins_file()
1416        );
1417
1418        settings = game_with_generic_paths(GameId::Oblivion);
1419        assert_eq!(
1420            Path::new("local/Plugins.txt"),
1421            settings.active_plugins_file()
1422        );
1423
1424        settings = game_with_generic_paths(GameId::Skyrim);
1425        assert_eq!(
1426            Path::new("local/Plugins.txt"),
1427            settings.active_plugins_file()
1428        );
1429
1430        settings = game_with_generic_paths(GameId::SkyrimSE);
1431        assert_eq!(
1432            Path::new("local/Plugins.txt"),
1433            settings.active_plugins_file()
1434        );
1435
1436        settings = game_with_generic_paths(GameId::SkyrimVR);
1437        assert_eq!(
1438            Path::new("local/Plugins.txt"),
1439            settings.active_plugins_file()
1440        );
1441
1442        settings = game_with_generic_paths(GameId::Fallout3);
1443        assert_eq!(
1444            Path::new("local/Plugins.txt"),
1445            settings.active_plugins_file()
1446        );
1447
1448        settings = game_with_generic_paths(GameId::FalloutNV);
1449        assert_eq!(
1450            Path::new("local/Plugins.txt"),
1451            settings.active_plugins_file()
1452        );
1453
1454        settings = game_with_generic_paths(GameId::Fallout4);
1455        assert_eq!(
1456            Path::new("local/Plugins.txt"),
1457            settings.active_plugins_file()
1458        );
1459
1460        settings = game_with_generic_paths(GameId::Fallout4VR);
1461        assert_eq!(
1462            Path::new("local/Plugins.txt"),
1463            settings.active_plugins_file()
1464        );
1465
1466        settings = game_with_generic_paths(GameId::Starfield);
1467        assert_eq!(
1468            Path::new("local/Plugins.txt"),
1469            settings.active_plugins_file()
1470        );
1471    }
1472
1473    #[test]
1474    fn active_plugins_file_should_be_in_game_path_for_oblivion_if_ini_setting_is_not_1() {
1475        let tmp_dir = tempdir().unwrap();
1476        let game_path = tmp_dir.path();
1477        let ini_path = game_path.join("Oblivion.ini");
1478
1479        std::fs::write(ini_path, "[General]\nbUseMyGamesDirectory=0\n").unwrap();
1480
1481        let settings = game_with_game_path(GameId::Oblivion, game_path);
1482        assert_eq!(
1483            game_path.join("Plugins.txt"),
1484            *settings.active_plugins_file()
1485        );
1486    }
1487
1488    #[test]
1489    #[cfg(not(windows))]
1490    fn find_nam_plugins_should_also_find_symlinks_to_nam_plugins() {
1491        let tmp_dir = tempdir().unwrap();
1492        let data_path = tmp_dir.path().join("Data");
1493        let other_path = tmp_dir.path().join("other");
1494
1495        create_dir_all(&data_path).unwrap();
1496        create_dir_all(&other_path).unwrap();
1497        File::create(data_path.join("plugin1.nam")).unwrap();
1498
1499        let original = other_path.join("plugin2.NAM");
1500        File::create(&original).unwrap();
1501        symlink_file(&original, &data_path.join("plugin2.NAM"));
1502
1503        let mut plugins = find_nam_plugins(&data_path).unwrap();
1504        plugins.sort();
1505
1506        let expected_plugins = vec!["plugin1.esm", "plugin1.esp", "plugin2.esm", "plugin2.esp"];
1507
1508        assert_eq!(expected_plugins, plugins);
1509    }
1510
1511    #[test]
1512    fn keep_file_type_should_return_true_for_files_for_all_games() {
1513        let file = generate_file_file_type();
1514
1515        assert!(keep_file_type(file, GameId::Morrowind));
1516        assert!(keep_file_type(file, GameId::OpenMW));
1517        assert!(keep_file_type(file, GameId::Oblivion));
1518        assert!(keep_file_type(file, GameId::OblivionRemastered));
1519        assert!(keep_file_type(file, GameId::Skyrim));
1520        assert!(keep_file_type(file, GameId::SkyrimSE));
1521        assert!(keep_file_type(file, GameId::SkyrimVR));
1522        assert!(keep_file_type(file, GameId::Fallout3));
1523        assert!(keep_file_type(file, GameId::FalloutNV));
1524        assert!(keep_file_type(file, GameId::Fallout4));
1525        assert!(keep_file_type(file, GameId::Fallout4VR));
1526        assert!(keep_file_type(file, GameId::Starfield));
1527    }
1528
1529    #[test]
1530    #[cfg(not(windows))]
1531    fn keep_file_type_should_return_true_for_symlinks_for_all_games_on_linux() {
1532        let symlink = generate_symlink_file_type();
1533
1534        assert!(keep_file_type(symlink, GameId::Morrowind));
1535        assert!(keep_file_type(symlink, GameId::OpenMW));
1536        assert!(keep_file_type(symlink, GameId::Oblivion));
1537        assert!(keep_file_type(symlink, GameId::OblivionRemastered));
1538        assert!(keep_file_type(symlink, GameId::Skyrim));
1539        assert!(keep_file_type(symlink, GameId::SkyrimSE));
1540        assert!(keep_file_type(symlink, GameId::SkyrimVR));
1541        assert!(keep_file_type(symlink, GameId::Fallout3));
1542        assert!(keep_file_type(symlink, GameId::FalloutNV));
1543        assert!(keep_file_type(symlink, GameId::Fallout4));
1544        assert!(keep_file_type(symlink, GameId::Fallout4VR));
1545        assert!(keep_file_type(symlink, GameId::Starfield));
1546    }
1547
1548    #[test]
1549    #[cfg(windows)]
1550    fn keep_file_type_should_return_true_for_symlinks_for_openmw_and_oblivion_remastered_on_windows(
1551    ) {
1552        let symlink = generate_symlink_file_type();
1553
1554        assert!(!keep_file_type(symlink, GameId::Morrowind));
1555        assert!(keep_file_type(symlink, GameId::OpenMW));
1556        assert!(!keep_file_type(symlink, GameId::Oblivion));
1557        assert!(keep_file_type(symlink, GameId::OblivionRemastered));
1558        assert!(!keep_file_type(symlink, GameId::Skyrim));
1559        assert!(!keep_file_type(symlink, GameId::SkyrimSE));
1560        assert!(!keep_file_type(symlink, GameId::SkyrimVR));
1561        assert!(!keep_file_type(symlink, GameId::Fallout3));
1562        assert!(!keep_file_type(symlink, GameId::FalloutNV));
1563        assert!(!keep_file_type(symlink, GameId::Fallout4));
1564        assert!(!keep_file_type(symlink, GameId::Fallout4VR));
1565        assert!(!keep_file_type(symlink, GameId::Starfield));
1566    }
1567
1568    #[test]
1569    fn early_loading_plugins_should_be_mapped_from_game_id() {
1570        let mut settings = game_with_generic_paths(GameId::Skyrim);
1571        let mut plugins = vec!["Skyrim.esm"];
1572        assert_eq!(plugins, settings.early_loading_plugins());
1573
1574        settings = game_with_generic_paths(GameId::SkyrimSE);
1575        plugins = vec![
1576            "Skyrim.esm",
1577            "Update.esm",
1578            "Dawnguard.esm",
1579            "HearthFires.esm",
1580            "Dragonborn.esm",
1581        ];
1582        assert_eq!(plugins, settings.early_loading_plugins());
1583
1584        settings = game_with_generic_paths(GameId::SkyrimVR);
1585        plugins = vec![
1586            "Skyrim.esm",
1587            "Update.esm",
1588            "Dawnguard.esm",
1589            "HearthFires.esm",
1590            "Dragonborn.esm",
1591            "SkyrimVR.esm",
1592        ];
1593        assert_eq!(plugins, settings.early_loading_plugins());
1594
1595        settings = game_with_generic_paths(GameId::Fallout4);
1596        plugins = vec![
1597            "Fallout4.esm",
1598            "DLCRobot.esm",
1599            "DLCworkshop01.esm",
1600            "DLCCoast.esm",
1601            "DLCworkshop02.esm",
1602            "DLCworkshop03.esm",
1603            "DLCNukaWorld.esm",
1604            "DLCUltraHighResolution.esm",
1605        ];
1606        assert_eq!(plugins, settings.early_loading_plugins());
1607
1608        settings = game_with_generic_paths(GameId::OpenMW);
1609        plugins = vec!["builtin.omwscripts"];
1610        assert_eq!(plugins, settings.early_loading_plugins());
1611
1612        settings = game_with_generic_paths(GameId::Morrowind);
1613        assert!(settings.early_loading_plugins().is_empty());
1614
1615        settings = game_with_generic_paths(GameId::Oblivion);
1616        assert!(settings.early_loading_plugins().is_empty());
1617
1618        settings = game_with_generic_paths(GameId::Fallout3);
1619        assert!(settings.early_loading_plugins().is_empty());
1620
1621        settings = game_with_generic_paths(GameId::FalloutNV);
1622        assert!(settings.early_loading_plugins().is_empty());
1623
1624        settings = game_with_generic_paths(GameId::Fallout4VR);
1625        plugins = vec!["Fallout4.esm", "Fallout4_VR.esm"];
1626        assert_eq!(plugins, settings.early_loading_plugins());
1627
1628        settings = game_with_generic_paths(GameId::Starfield);
1629        plugins = vec![
1630            "Starfield.esm",
1631            "Constellation.esm",
1632            "OldMars.esm",
1633            "ShatteredSpace.esm",
1634            "SFBGS003.esm",
1635            "SFBGS004.esm",
1636            "SFBGS006.esm",
1637            "SFBGS007.esm",
1638            "SFBGS008.esm",
1639        ];
1640        assert_eq!(plugins, settings.early_loading_plugins());
1641    }
1642
1643    #[test]
1644    fn early_loading_plugins_should_include_plugins_loaded_from_ccc_file() {
1645        let tmp_dir = tempdir().unwrap();
1646        let game_path = tmp_dir.path();
1647
1648        let mut plugins = vec![
1649            "Skyrim.esm",
1650            "Update.esm",
1651            "Dawnguard.esm",
1652            "HearthFires.esm",
1653            "Dragonborn.esm",
1654            "ccBGSSSE002-ExoticArrows.esl",
1655            "ccBGSSSE003-Zombies.esl",
1656            "ccBGSSSE004-RuinsEdge.esl",
1657            "ccBGSSSE006-StendarsHammer.esl",
1658            "ccBGSSSE007-Chrysamere.esl",
1659            "ccBGSSSE010-PetDwarvenArmoredMudcrab.esl",
1660            "ccBGSSSE014-SpellPack01.esl",
1661            "ccBGSSSE019-StaffofSheogorath.esl",
1662            "ccMTYSSE001-KnightsoftheNine.esl",
1663            "ccQDRSSE001-SurvivalMode.esl",
1664        ];
1665        let mut settings = game_with_ccc_plugins(GameId::SkyrimSE, game_path, &plugins[5..]);
1666        assert_eq!(plugins, settings.early_loading_plugins());
1667
1668        plugins = vec![
1669            "Fallout4.esm",
1670            "DLCRobot.esm",
1671            "DLCworkshop01.esm",
1672            "DLCCoast.esm",
1673            "DLCworkshop02.esm",
1674            "DLCworkshop03.esm",
1675            "DLCNukaWorld.esm",
1676            "DLCUltraHighResolution.esm",
1677            "ccBGSFO4001-PipBoy(Black).esl",
1678            "ccBGSFO4002-PipBoy(Blue).esl",
1679            "ccBGSFO4003-PipBoy(Camo01).esl",
1680            "ccBGSFO4004-PipBoy(Camo02).esl",
1681            "ccBGSFO4006-PipBoy(Chrome).esl",
1682            "ccBGSFO4012-PipBoy(Red).esl",
1683            "ccBGSFO4014-PipBoy(White).esl",
1684            "ccBGSFO4016-Prey.esl",
1685            "ccBGSFO4017-Mauler.esl",
1686            "ccBGSFO4018-GaussRiflePrototype.esl",
1687            "ccBGSFO4019-ChineseStealthArmor.esl",
1688            "ccBGSFO4020-PowerArmorSkin(Black).esl",
1689            "ccBGSFO4022-PowerArmorSkin(Camo01).esl",
1690            "ccBGSFO4023-PowerArmorSkin(Camo02).esl",
1691            "ccBGSFO4025-PowerArmorSkin(Chrome).esl",
1692            "ccBGSFO4038-HorseArmor.esl",
1693            "ccBGSFO4039-TunnelSnakes.esl",
1694            "ccBGSFO4041-DoomMarineArmor.esl",
1695            "ccBGSFO4042-BFG.esl",
1696            "ccBGSFO4043-DoomChainsaw.esl",
1697            "ccBGSFO4044-HellfirePowerArmor.esl",
1698            "ccFSVFO4001-ModularMilitaryBackpack.esl",
1699            "ccFSVFO4002-MidCenturyModern.esl",
1700            "ccFRSFO4001-HandmadeShotgun.esl",
1701            "ccEEJFO4001-DecorationPack.esl",
1702        ];
1703        settings = game_with_ccc_plugins(GameId::Fallout4, game_path, &plugins[8..]);
1704        assert_eq!(plugins, settings.early_loading_plugins());
1705    }
1706
1707    #[test]
1708    fn early_loading_plugins_should_use_the_starfield_ccc_file_in_game_path() {
1709        let tmp_dir = tempdir().unwrap();
1710        let game_path = tmp_dir.path().join("game");
1711        let my_games_path = tmp_dir.path().join("my games");
1712
1713        create_ccc_file(&game_path.join("Starfield.ccc"), &["test.esm"]);
1714
1715        let settings = GameSettings::with_local_and_my_games_paths(
1716            GameId::Starfield,
1717            &game_path,
1718            &PathBuf::default(),
1719            my_games_path,
1720        )
1721        .unwrap();
1722
1723        let expected = &[
1724            "Starfield.esm",
1725            "Constellation.esm",
1726            "OldMars.esm",
1727            "ShatteredSpace.esm",
1728            "SFBGS003.esm",
1729            "SFBGS004.esm",
1730            "SFBGS006.esm",
1731            "SFBGS007.esm",
1732            "SFBGS008.esm",
1733            "test.esm",
1734        ];
1735        assert_eq!(expected, settings.early_loading_plugins());
1736    }
1737
1738    #[test]
1739    fn early_loading_plugins_should_use_the_starfield_ccc_file_in_my_games_path() {
1740        let tmp_dir = tempdir().unwrap();
1741        let game_path = tmp_dir.path().join("game");
1742        let my_games_path = tmp_dir.path().join("my games");
1743
1744        create_ccc_file(&my_games_path.join("Starfield.ccc"), &["test.esm"]);
1745
1746        let settings = GameSettings::with_local_and_my_games_paths(
1747            GameId::Starfield,
1748            &game_path,
1749            &PathBuf::default(),
1750            my_games_path,
1751        )
1752        .unwrap();
1753
1754        let expected = &[
1755            "Starfield.esm",
1756            "Constellation.esm",
1757            "OldMars.esm",
1758            "ShatteredSpace.esm",
1759            "SFBGS003.esm",
1760            "SFBGS004.esm",
1761            "SFBGS006.esm",
1762            "SFBGS007.esm",
1763            "SFBGS008.esm",
1764            "test.esm",
1765        ];
1766        assert_eq!(expected, settings.early_loading_plugins());
1767    }
1768
1769    #[test]
1770    fn early_loading_plugins_should_use_the_first_ccc_file_that_exists() {
1771        let tmp_dir = tempdir().unwrap();
1772        let game_path = tmp_dir.path().join("game");
1773        let my_games_path = tmp_dir.path().join("my games");
1774
1775        create_ccc_file(&game_path.join("Starfield.ccc"), &["test1.esm"]);
1776        create_ccc_file(&my_games_path.join("Starfield.ccc"), &["test2.esm"]);
1777
1778        let settings = GameSettings::with_local_and_my_games_paths(
1779            GameId::Starfield,
1780            &game_path,
1781            &PathBuf::default(),
1782            my_games_path,
1783        )
1784        .unwrap();
1785
1786        let expected = &[
1787            "Starfield.esm",
1788            "Constellation.esm",
1789            "OldMars.esm",
1790            "ShatteredSpace.esm",
1791            "SFBGS003.esm",
1792            "SFBGS004.esm",
1793            "SFBGS006.esm",
1794            "SFBGS007.esm",
1795            "SFBGS008.esm",
1796            "test2.esm",
1797        ];
1798        assert_eq!(expected, settings.early_loading_plugins());
1799    }
1800
1801    #[test]
1802    fn early_loading_plugins_should_not_include_cc_plugins_for_fallout4_if_test_files_are_configured(
1803    ) {
1804        let tmp_dir = tempdir().unwrap();
1805        let game_path = tmp_dir.path();
1806
1807        create_ccc_file(
1808            &game_path.join("Fallout4.ccc"),
1809            &["ccBGSFO4001-PipBoy(Black).esl"],
1810        );
1811
1812        let ini_path = game_path.join("Fallout4.ini");
1813        std::fs::write(&ini_path, "[General]\nsTestFile1=Blank.esp\n").unwrap();
1814
1815        copy_to_dir(
1816            "Blank.esp",
1817            &game_path.join("Data"),
1818            "Blank.esp",
1819            GameId::Fallout4,
1820        );
1821
1822        let settings = GameSettings::with_local_and_my_games_paths(
1823            GameId::Fallout4,
1824            game_path,
1825            &PathBuf::default(),
1826            game_path.to_path_buf(),
1827        )
1828        .unwrap();
1829
1830        assert_eq!(FALLOUT4_HARDCODED_PLUGINS, settings.early_loading_plugins());
1831    }
1832
1833    #[test]
1834    fn early_loading_plugins_should_not_include_cc_plugins_for_starfield_if_test_files_are_configured(
1835    ) {
1836        let tmp_dir = tempdir().unwrap();
1837        let game_path = tmp_dir.path();
1838        let my_games_path = tmp_dir.path().join("my games");
1839
1840        create_ccc_file(&my_games_path.join("Starfield.ccc"), &["test.esp"]);
1841
1842        let ini_path = game_path.join("Starfield.ini");
1843        std::fs::write(&ini_path, "[General]\nsTestFile1=Blank.esp\n").unwrap();
1844
1845        copy_to_dir(
1846            "Blank.esp",
1847            &game_path.join("Data"),
1848            "Blank.esp",
1849            GameId::Starfield,
1850        );
1851
1852        let settings = GameSettings::with_local_and_my_games_paths(
1853            GameId::Starfield,
1854            game_path,
1855            &PathBuf::default(),
1856            my_games_path,
1857        )
1858        .unwrap();
1859
1860        assert!(!settings.loads_early("test.esp"));
1861    }
1862
1863    #[test]
1864    fn early_loading_plugins_should_include_plugins_from_global_config_for_openmw() {
1865        let tmp_dir = tempdir().unwrap();
1866        let global_cfg_path = tmp_dir.path().join("openmw.cfg");
1867
1868        std::fs::write(
1869            &global_cfg_path,
1870            "config=local\ncontent=test.esm\ncontent=test.esp",
1871        )
1872        .unwrap();
1873
1874        let settings = game_with_game_path(GameId::OpenMW, tmp_dir.path());
1875
1876        let expected = &["builtin.omwscripts", "test.esm", "test.esp"];
1877
1878        assert_eq!(expected, settings.early_loading_plugins());
1879    }
1880
1881    #[test]
1882    fn early_loading_plugins_should_ignore_later_duplicate_entries() {
1883        let tmp_dir = tempdir().unwrap();
1884        let game_path = tmp_dir.path();
1885        let my_games_path = tmp_dir.path().join("my games");
1886
1887        create_ccc_file(
1888            &my_games_path.join("Starfield.ccc"),
1889            &["Starfield.esm", "test.esm"],
1890        );
1891
1892        let settings = GameSettings::with_local_and_my_games_paths(
1893            GameId::Starfield,
1894            game_path,
1895            &PathBuf::default(),
1896            my_games_path,
1897        )
1898        .unwrap();
1899
1900        let expected = &[
1901            "Starfield.esm",
1902            "Constellation.esm",
1903            "OldMars.esm",
1904            "ShatteredSpace.esm",
1905            "SFBGS003.esm",
1906            "SFBGS004.esm",
1907            "SFBGS006.esm",
1908            "SFBGS007.esm",
1909            "SFBGS008.esm",
1910            "test.esm",
1911        ];
1912        assert_eq!(expected, settings.early_loading_plugins());
1913    }
1914
1915    #[test]
1916    fn implicitly_active_plugins_should_include_early_loading_plugins() {
1917        let tmp_dir = tempdir().unwrap();
1918        let game_path = tmp_dir.path();
1919
1920        let settings = game_with_game_path(GameId::SkyrimSE, game_path);
1921
1922        assert_eq!(
1923            settings.early_loading_plugins(),
1924            settings.implicitly_active_plugins()
1925        );
1926    }
1927
1928    #[test]
1929    fn implicitly_active_plugins_should_include_test_files() {
1930        let tmp_dir = tempdir().unwrap();
1931        let game_path = tmp_dir.path();
1932
1933        let ini_path = game_path.join("Skyrim.ini");
1934        std::fs::write(&ini_path, "[General]\nsTestFile1=plugin.esp\n").unwrap();
1935
1936        let settings = GameSettings::with_local_and_my_games_paths(
1937            GameId::SkyrimSE,
1938            game_path,
1939            &PathBuf::default(),
1940            game_path.to_path_buf(),
1941        )
1942        .unwrap();
1943
1944        let mut expected_plugins = settings.early_loading_plugins().to_vec();
1945        expected_plugins.push("plugin.esp".to_owned());
1946
1947        assert_eq!(expected_plugins, settings.implicitly_active_plugins());
1948    }
1949
1950    #[test]
1951    fn implicitly_active_plugins_should_only_include_valid_test_files_for_fallout4() {
1952        let tmp_dir = tempdir().unwrap();
1953        let game_path = tmp_dir.path();
1954
1955        let ini_path = game_path.join("Fallout4.ini");
1956        std::fs::write(
1957            &ini_path,
1958            "[General]\nsTestFile1=plugin.esp\nsTestFile2=Blank.esp",
1959        )
1960        .unwrap();
1961
1962        copy_to_dir(
1963            "Blank.esp",
1964            &game_path.join("Data"),
1965            "Blank.esp",
1966            GameId::Fallout4,
1967        );
1968
1969        let settings = GameSettings::with_local_and_my_games_paths(
1970            GameId::Fallout4,
1971            game_path,
1972            &PathBuf::default(),
1973            game_path.to_path_buf(),
1974        )
1975        .unwrap();
1976
1977        let mut expected_plugins = settings.early_loading_plugins().to_vec();
1978        expected_plugins.push("Blank.esp".to_owned());
1979
1980        assert_eq!(expected_plugins, settings.implicitly_active_plugins());
1981    }
1982
1983    #[test]
1984    fn implicitly_active_plugins_should_only_include_valid_test_files_for_fallout4vr() {
1985        let tmp_dir = tempdir().unwrap();
1986        let game_path = tmp_dir.path();
1987
1988        let ini_path = game_path.join("Fallout4VR.ini");
1989        std::fs::write(
1990            &ini_path,
1991            "[General]\nsTestFile1=plugin.esp\nsTestFile2=Blank.esp",
1992        )
1993        .unwrap();
1994
1995        copy_to_dir(
1996            "Blank.esp",
1997            &game_path.join("Data"),
1998            "Blank.esp",
1999            GameId::Fallout4VR,
2000        );
2001
2002        let settings = GameSettings::with_local_and_my_games_paths(
2003            GameId::Fallout4VR,
2004            game_path,
2005            &PathBuf::default(),
2006            game_path.to_path_buf(),
2007        )
2008        .unwrap();
2009
2010        let mut expected_plugins = settings.early_loading_plugins().to_vec();
2011        expected_plugins.push("Blank.esp".to_owned());
2012
2013        assert_eq!(expected_plugins, settings.implicitly_active_plugins());
2014    }
2015
2016    #[test]
2017    fn implicitly_active_plugins_should_include_plugins_with_nam_files_for_fallout_nv() {
2018        let tmp_dir = tempdir().unwrap();
2019        let game_path = tmp_dir.path();
2020        let data_path = game_path.join("Data");
2021
2022        create_dir_all(&data_path).unwrap();
2023        File::create(data_path.join("plugin1.nam")).unwrap();
2024        File::create(data_path.join("plugin2.NAM")).unwrap();
2025
2026        let settings = game_with_game_path(GameId::FalloutNV, game_path);
2027        let expected_plugins = vec!["plugin1.esm", "plugin1.esp", "plugin2.esm", "plugin2.esp"];
2028        let mut plugins = settings.implicitly_active_plugins().to_vec();
2029        plugins.sort();
2030
2031        assert_eq!(expected_plugins, plugins);
2032    }
2033
2034    #[test]
2035    fn implicitly_active_plugins_should_include_update_esm_for_skyrim() {
2036        let settings = game_with_generic_paths(GameId::Skyrim);
2037        let plugins = settings.implicitly_active_plugins();
2038
2039        assert!(plugins.contains(&"Update.esm".to_owned()));
2040    }
2041
2042    #[test]
2043    fn implicitly_active_plugins_should_include_blueprintships_starfield_esm_for_starfield() {
2044        let settings = game_with_generic_paths(GameId::Starfield);
2045        let plugins = settings.implicitly_active_plugins();
2046
2047        assert!(plugins.contains(&"BlueprintShips-Starfield.esm".to_owned()));
2048    }
2049
2050    #[test]
2051    fn implicitly_active_plugins_should_include_plugins_with_nam_files_for_games_other_than_fallout_nv(
2052    ) {
2053        let tmp_dir = tempdir().unwrap();
2054        let game_path = tmp_dir.path();
2055        let data_path = game_path.join("Data");
2056
2057        create_dir_all(&data_path).unwrap();
2058        File::create(data_path.join("plugin.nam")).unwrap();
2059
2060        let settings = game_with_game_path(GameId::Fallout3, game_path);
2061        assert!(settings.implicitly_active_plugins().is_empty());
2062    }
2063
2064    #[test]
2065    fn implicitly_active_plugins_should_not_include_case_insensitive_duplicates() {
2066        let tmp_dir = tempdir().unwrap();
2067        let game_path = tmp_dir.path();
2068
2069        let ini_path = game_path.join("Fallout4.ini");
2070        std::fs::write(&ini_path, "[General]\nsTestFile1=fallout4.esm\n").unwrap();
2071
2072        let settings = GameSettings::with_local_and_my_games_paths(
2073            GameId::Fallout4,
2074            game_path,
2075            &PathBuf::default(),
2076            game_path.to_path_buf(),
2077        )
2078        .unwrap();
2079
2080        assert_eq!(
2081            settings.early_loading_plugins(),
2082            settings.implicitly_active_plugins()
2083        );
2084    }
2085
2086    #[test]
2087    fn is_implicitly_active_should_return_true_iff_the_plugin_is_implicitly_active() {
2088        let settings = game_with_generic_paths(GameId::Skyrim);
2089        assert!(settings.is_implicitly_active("Update.esm"));
2090        assert!(!settings.is_implicitly_active("Test.esm"));
2091    }
2092
2093    #[test]
2094    fn is_implicitly_active_should_match_case_insensitively() {
2095        let settings = game_with_generic_paths(GameId::Skyrim);
2096        assert!(settings.is_implicitly_active("update.esm"));
2097    }
2098
2099    #[test]
2100    fn loads_early_should_return_true_iff_the_plugin_loads_early() {
2101        let settings = game_with_generic_paths(GameId::SkyrimSE);
2102        assert!(settings.loads_early("Dawnguard.esm"));
2103        assert!(!settings.loads_early("Test.esm"));
2104    }
2105
2106    #[test]
2107    fn loads_early_should_match_case_insensitively() {
2108        let settings = game_with_generic_paths(GameId::SkyrimSE);
2109        assert!(settings.loads_early("dawnguard.esm"));
2110    }
2111
2112    #[test]
2113    fn plugins_folder_should_be_a_child_of_the_game_path() {
2114        let settings = game_with_generic_paths(GameId::Skyrim);
2115        assert_eq!(Path::new("game/Data"), settings.plugins_directory());
2116    }
2117
2118    #[test]
2119    fn load_order_file_should_be_in_local_path_for_skyrim_and_none_for_other_games() {
2120        let mut settings = game_with_generic_paths(GameId::Skyrim);
2121        assert_eq!(
2122            Path::new("local/loadorder.txt"),
2123            settings.load_order_file().unwrap()
2124        );
2125
2126        settings = game_with_generic_paths(GameId::SkyrimSE);
2127        assert!(settings.load_order_file().is_none());
2128
2129        settings = game_with_generic_paths(GameId::OpenMW);
2130        assert!(settings.load_order_file().is_none());
2131
2132        settings = game_with_generic_paths(GameId::Morrowind);
2133        assert!(settings.load_order_file().is_none());
2134
2135        settings = game_with_generic_paths(GameId::Oblivion);
2136        assert!(settings.load_order_file().is_none());
2137
2138        settings = game_with_generic_paths(GameId::Fallout3);
2139        assert!(settings.load_order_file().is_none());
2140
2141        settings = game_with_generic_paths(GameId::FalloutNV);
2142        assert!(settings.load_order_file().is_none());
2143
2144        settings = game_with_generic_paths(GameId::Fallout4);
2145        assert!(settings.load_order_file().is_none());
2146    }
2147
2148    #[test]
2149    fn additional_plugins_directories_should_be_empty_if_game_is_not_fallout4_or_starfield() {
2150        let tmp_dir = tempdir().unwrap();
2151        let game_path = tmp_dir.path();
2152
2153        File::create(game_path.join("appxmanifest.xml")).unwrap();
2154
2155        let game_ids = [
2156            GameId::Morrowind,
2157            GameId::Oblivion,
2158            GameId::Skyrim,
2159            GameId::SkyrimSE,
2160            GameId::SkyrimVR,
2161            GameId::Fallout3,
2162            GameId::FalloutNV,
2163        ];
2164
2165        for game_id in game_ids {
2166            let settings = game_with_game_path(game_id, game_path);
2167
2168            assert!(settings.additional_plugins_directories().is_empty());
2169        }
2170    }
2171
2172    #[test]
2173    fn additional_plugins_directories_should_be_empty_if_fallout4_is_not_from_the_microsoft_store()
2174    {
2175        let settings = game_with_generic_paths(GameId::Fallout4);
2176
2177        assert!(settings.additional_plugins_directories().is_empty());
2178    }
2179
2180    #[test]
2181    fn additional_plugins_directories_should_not_be_empty_if_game_is_fallout4_from_the_microsoft_store(
2182    ) {
2183        let tmp_dir = tempdir().unwrap();
2184        let game_path = tmp_dir.path();
2185
2186        File::create(game_path.join("appxmanifest.xml")).unwrap();
2187
2188        let settings = game_with_game_path(GameId::Fallout4, game_path);
2189
2190        assert_eq!(
2191            vec![
2192                game_path.join(MS_FO4_AUTOMATRON_PATH),
2193                game_path.join(MS_FO4_NUKA_WORLD_PATH),
2194                game_path.join(MS_FO4_WASTELAND_PATH),
2195                game_path.join(MS_FO4_TEXTURE_PACK_PATH),
2196                game_path.join(MS_FO4_VAULT_TEC_PATH),
2197                game_path.join(MS_FO4_FAR_HARBOR_PATH),
2198                game_path.join(MS_FO4_CONTRAPTIONS_PATH),
2199            ],
2200            settings.additional_plugins_directories()
2201        );
2202    }
2203
2204    #[test]
2205    fn additional_plugins_directories_should_not_be_empty_if_game_is_starfield() {
2206        let settings = game_with_generic_paths(GameId::Starfield);
2207
2208        assert_eq!(
2209            vec![Path::new("my games").join("Data")],
2210            settings.additional_plugins_directories()
2211        );
2212    }
2213
2214    #[test]
2215    fn additional_plugins_directories_should_include_dlc_paths_if_game_is_ms_store_starfield() {
2216        let tmp_dir = tempdir().unwrap();
2217        let game_path = tmp_dir.path();
2218
2219        File::create(game_path.join("appxmanifest.xml")).unwrap();
2220
2221        let settings = game_with_game_path(GameId::Starfield, game_path);
2222
2223        assert_eq!(
2224            vec![
2225                Path::new("Data"),
2226                &game_path.join("../../Old Mars/Content/Data"),
2227                &game_path.join("../../Shattered Space/Content/Data"),
2228            ],
2229            settings.additional_plugins_directories()
2230        );
2231    }
2232
2233    #[test]
2234    fn additional_plugins_directories_should_read_from_openmw_cfgs() {
2235        let tmp_dir = tempdir().unwrap();
2236        let game_path = tmp_dir.path().join("game");
2237        let my_games_path = tmp_dir.path().join("my games");
2238        let global_cfg_path = game_path.join("openmw.cfg");
2239        let cfg_path = my_games_path.join("openmw.cfg");
2240
2241        create_dir_all(global_cfg_path.parent().unwrap()).unwrap();
2242        std::fs::write(&global_cfg_path, "config=\"../my games\"\ndata=\"foo/bar\"").unwrap();
2243
2244        create_dir_all(cfg_path.parent().unwrap()).unwrap();
2245        std::fs::write(
2246            &cfg_path,
2247            "data=\"Path\\&&&\"&a&&&&\\Data Files\"\ndata=games/path",
2248        )
2249        .unwrap();
2250
2251        let settings =
2252            GameSettings::with_local_path(GameId::OpenMW, &game_path, &my_games_path).unwrap();
2253
2254        let expected: Vec<PathBuf> = vec![
2255            game_path.join("foo/bar"),
2256            my_games_path.join("Path\\&\"a&&\\Data Files"),
2257            my_games_path.join("games/path"),
2258        ];
2259        assert_eq!(expected, settings.additional_plugins_directories());
2260    }
2261
2262    #[test]
2263    fn plugin_path_should_append_plugin_name_to_additional_plugin_directory_if_that_path_exists() {
2264        let tmp_dir = tempdir().unwrap();
2265        let other_dir = tmp_dir.path().join("other");
2266
2267        let plugin_name = "external.esp";
2268        let expected_plugin_path = other_dir.join(plugin_name);
2269
2270        let mut settings = game_with_generic_paths(GameId::Fallout4);
2271        settings.additional_plugins_directories = vec![other_dir.clone()];
2272
2273        copy_to_dir("Blank.esp", &other_dir, plugin_name, GameId::Fallout4);
2274
2275        let plugin_path = settings.plugin_path(plugin_name);
2276
2277        assert_eq!(expected_plugin_path, plugin_path);
2278    }
2279
2280    #[test]
2281    fn plugin_path_should_append_plugin_name_to_additional_plugin_directory_if_the_ghosted_path_exists(
2282    ) {
2283        let tmp_dir = tempdir().unwrap();
2284        let other_dir = tmp_dir.path().join("other");
2285
2286        let plugin_name = "external.esp";
2287        let ghosted_plugin_name = "external.esp.ghost";
2288        let expected_plugin_path = other_dir.join(ghosted_plugin_name);
2289
2290        let mut settings = game_with_generic_paths(GameId::Fallout4);
2291        settings.additional_plugins_directories = vec![other_dir.clone()];
2292
2293        copy_to_dir(
2294            "Blank.esp",
2295            &other_dir,
2296            ghosted_plugin_name,
2297            GameId::Fallout4,
2298        );
2299
2300        let plugin_path = settings.plugin_path(plugin_name);
2301
2302        assert_eq!(expected_plugin_path, plugin_path);
2303    }
2304
2305    #[test]
2306    fn plugin_path_should_not_resolve_ghosted_paths_for_openmw() {
2307        let tmp_dir = tempdir().unwrap();
2308        let game_path = tmp_dir.path().join("game");
2309        let other_dir = tmp_dir.path().join("other");
2310
2311        let plugin_name = "external.esp";
2312
2313        let mut settings = game_with_game_path(GameId::OpenMW, &game_path);
2314        settings.additional_plugins_directories = vec![other_dir.clone()];
2315
2316        copy_to_dir(
2317            "Blank.esp",
2318            &other_dir,
2319            "external.esp.ghost",
2320            GameId::OpenMW,
2321        );
2322
2323        let plugin_path = settings.plugin_path(plugin_name);
2324
2325        assert_eq!(
2326            game_path.join("resources/vfs").join(plugin_name),
2327            plugin_path
2328        );
2329    }
2330
2331    #[test]
2332    fn plugin_path_should_return_the_last_directory_that_contains_a_file_for_openmw() {
2333        let tmp_dir = tempdir().unwrap();
2334        let other_dir_1 = tmp_dir.path().join("other1");
2335        let other_dir_2 = tmp_dir.path().join("other2");
2336
2337        let plugin_name = "Blank.esp";
2338
2339        let mut settings = game_with_game_path(GameId::OpenMW, tmp_dir.path());
2340        settings.additional_plugins_directories = vec![other_dir_1.clone(), other_dir_2.clone()];
2341
2342        copy_to_dir("Blank.esp", &other_dir_1, plugin_name, GameId::OpenMW);
2343        copy_to_dir("Blank.esp", &other_dir_2, plugin_name, GameId::OpenMW);
2344
2345        let plugin_path = settings.plugin_path(plugin_name);
2346
2347        assert_eq!(other_dir_2.join(plugin_name), plugin_path);
2348    }
2349
2350    #[test]
2351    fn plugin_path_should_return_plugins_dir_subpath_if_name_does_not_match_any_external_plugin() {
2352        let settings = game_with_generic_paths(GameId::Fallout4);
2353
2354        let plugin_name = "DLCCoast.esm";
2355        assert_eq!(
2356            settings.plugins_directory().join(plugin_name),
2357            settings.plugin_path(plugin_name)
2358        );
2359    }
2360
2361    #[test]
2362    fn plugin_path_should_only_resolve_additional_starfield_plugin_paths_if_they_exist_or_are_ghosted_in_the_plugins_directory(
2363    ) {
2364        let tmp_dir = tempdir().unwrap();
2365        let game_path = tmp_dir.path().join("game");
2366        let data_path = game_path.join("Data");
2367        let other_dir = tmp_dir.path().join("other");
2368
2369        let plugin_name_1 = "external1.esp";
2370        let plugin_name_2 = "external2.esp";
2371        let plugin_name_3 = "external3.esp";
2372        let ghosted_plugin_name_3 = "external3.esp.ghost";
2373
2374        let mut settings = game_with_game_path(GameId::Starfield, &game_path);
2375        settings.additional_plugins_directories = vec![other_dir.clone()];
2376
2377        copy_to_dir("Blank.esp", &other_dir, plugin_name_1, GameId::Starfield);
2378        copy_to_dir("Blank.esp", &other_dir, plugin_name_2, GameId::Starfield);
2379        copy_to_dir("Blank.esp", &data_path, plugin_name_2, GameId::Starfield);
2380        copy_to_dir("Blank.esp", &other_dir, plugin_name_3, GameId::Starfield);
2381        copy_to_dir(
2382            "Blank.esp",
2383            &data_path,
2384            ghosted_plugin_name_3,
2385            GameId::Starfield,
2386        );
2387
2388        let plugin_1_path = settings.plugin_path(plugin_name_1);
2389        let plugin_2_path = settings.plugin_path(plugin_name_2);
2390        let plugin_3_path = settings.plugin_path(plugin_name_3);
2391
2392        assert_eq!(data_path.join(plugin_name_1), plugin_1_path);
2393        assert_eq!(other_dir.join(plugin_name_2), plugin_2_path);
2394        assert_eq!(other_dir.join(plugin_name_3), plugin_3_path);
2395    }
2396
2397    #[test]
2398    fn refresh_implicitly_active_plugins_should_update_early_loading_and_implicitly_active_plugins()
2399    {
2400        let tmp_dir = tempdir().unwrap();
2401        let game_path = tmp_dir.path();
2402
2403        let mut settings = GameSettings::with_local_and_my_games_paths(
2404            GameId::SkyrimSE,
2405            game_path,
2406            &PathBuf::default(),
2407            game_path.to_path_buf(),
2408        )
2409        .unwrap();
2410
2411        let hardcoded_plugins = vec![
2412            "Skyrim.esm",
2413            "Update.esm",
2414            "Dawnguard.esm",
2415            "HearthFires.esm",
2416            "Dragonborn.esm",
2417        ];
2418        assert_eq!(hardcoded_plugins, settings.early_loading_plugins());
2419        assert_eq!(hardcoded_plugins, settings.implicitly_active_plugins());
2420
2421        std::fs::write(game_path.join("Skyrim.ccc"), "ccBGSSSE002-ExoticArrows.esl").unwrap();
2422        std::fs::write(
2423            game_path.join("Skyrim.ini"),
2424            "[General]\nsTestFile1=plugin.esp\n",
2425        )
2426        .unwrap();
2427
2428        settings.refresh_implicitly_active_plugins().unwrap();
2429
2430        let mut expected_plugins = hardcoded_plugins;
2431        expected_plugins.push("ccBGSSSE002-ExoticArrows.esl");
2432        assert_eq!(expected_plugins, settings.early_loading_plugins());
2433
2434        expected_plugins.push("plugin.esp");
2435        assert_eq!(expected_plugins, settings.implicitly_active_plugins());
2436    }
2437
2438    #[test]
2439    fn get_target_modified_timestamp_should_return_the_modified_timestamp_of_a_symlinks_target_file(
2440    ) {
2441        let tmp_dir = tempdir().unwrap();
2442        let file_path = tmp_dir.path().join("file");
2443        let symlink_path = tmp_dir.path().join("symlink");
2444
2445        std::fs::File::create(&file_path).unwrap();
2446
2447        symlink_file(&file_path, &symlink_path);
2448
2449        let symlink_timestamp = symlink_path.symlink_metadata().unwrap().modified().unwrap();
2450        let file_timestamp = symlink_timestamp - std::time::Duration::from_secs(1);
2451        assert_ne!(symlink_timestamp, file_timestamp);
2452        let file = File::options().append(true).open(file_path).unwrap();
2453        file.set_modified(file_timestamp).unwrap();
2454
2455        let mut dir_entries = tmp_dir
2456            .path()
2457            .read_dir()
2458            .unwrap()
2459            .collect::<Result<Vec<_>, _>>()
2460            .unwrap();
2461
2462        dir_entries.sort_by_key(DirEntry::file_name);
2463
2464        assert!(dir_entries[0].file_type().unwrap().is_file());
2465        assert_eq!(
2466            file_timestamp,
2467            get_target_modified_timestamp(&dir_entries[0]).unwrap()
2468        );
2469
2470        assert!(dir_entries[1].file_type().unwrap().is_symlink());
2471        assert_eq!(
2472            file_timestamp,
2473            get_target_modified_timestamp(&dir_entries[1]).unwrap()
2474        );
2475    }
2476
2477    #[test]
2478    fn find_plugins_in_directories_should_sort_files_by_modification_timestamp() {
2479        let tmp_dir = tempdir().unwrap();
2480        let game_path = tmp_dir.path();
2481
2482        let plugin_names = [
2483            "Blank.esp",
2484            "Blank - Different.esp",
2485            "Blank - Master Dependent.esp",
2486            NON_ASCII,
2487        ];
2488
2489        copy_to_dir("Blank.esp", game_path, NON_ASCII, GameId::Oblivion);
2490
2491        for (i, plugin_name) in plugin_names.iter().enumerate() {
2492            let path = game_path.join(plugin_name);
2493            if !path.exists() {
2494                copy_to_dir(plugin_name, game_path, plugin_name, GameId::Oblivion);
2495            }
2496            set_file_timestamps(&path, i.try_into().unwrap());
2497        }
2498
2499        let result = find_plugins_in_directories(once(&game_path.to_path_buf()), GameId::Oblivion);
2500
2501        let expected: Vec<_> = plugin_names.iter().map(|n| game_path.join(n)).collect();
2502
2503        assert_eq!(expected, result);
2504    }
2505
2506    #[test]
2507    fn find_plugins_in_directories_should_sort_files_by_descending_filename_if_timestamps_are_equal(
2508    ) {
2509        let tmp_dir = tempdir().unwrap();
2510        let game_path = tmp_dir.path();
2511
2512        let non_ascii = NON_ASCII;
2513        let plugin_names = [
2514            "Blank.esm",
2515            "Blank.esp",
2516            "Blank - Different.esp",
2517            "Blank - Master Dependent.esp",
2518            non_ascii,
2519        ];
2520
2521        copy_to_dir("Blank.esp", game_path, non_ascii, GameId::Oblivion);
2522
2523        for (i, plugin_name) in plugin_names.iter().enumerate() {
2524            let path = game_path.join(plugin_name);
2525            if !path.exists() {
2526                copy_to_dir(plugin_name, game_path, plugin_name, GameId::Oblivion);
2527            }
2528            set_file_timestamps(&path, i.try_into().unwrap());
2529        }
2530
2531        let timestamp = 3;
2532        set_file_timestamps(&game_path.join("Blank - Different.esp"), timestamp);
2533        set_file_timestamps(&game_path.join("Blank - Master Dependent.esp"), timestamp);
2534
2535        let result = find_plugins_in_directories(once(&game_path.to_path_buf()), GameId::Oblivion);
2536
2537        let plugin_paths = vec![
2538            game_path.join("Blank.esm"),
2539            game_path.join("Blank.esp"),
2540            game_path.join("Blank - Master Dependent.esp"),
2541            game_path.join("Blank - Different.esp"),
2542            game_path.join(non_ascii),
2543        ];
2544
2545        assert_eq!(plugin_paths, result);
2546    }
2547
2548    #[test]
2549    fn find_plugins_in_directories_should_sort_files_by_ascending_filename_if_timestamps_are_equal_and_game_is_starfield(
2550    ) {
2551        let tmp_dir = tempdir().unwrap();
2552        let game_path = tmp_dir.path();
2553
2554        let plugin_names = [
2555            "Blank.full.esm",
2556            "Blank.small.esm",
2557            "Blank.medium.esm",
2558            "Blank.esp",
2559            "Blank - Override.esp",
2560        ];
2561
2562        let timestamp = 1_321_009_991;
2563
2564        for plugin_name in &plugin_names {
2565            let path = game_path.join(plugin_name);
2566            if !path.exists() {
2567                copy_to_dir(plugin_name, game_path, plugin_name, GameId::Starfield);
2568            }
2569            set_file_timestamps(&path, timestamp);
2570        }
2571
2572        let result = find_plugins_in_directories(once(&game_path.to_path_buf()), GameId::Starfield);
2573
2574        let plugin_paths = vec![
2575            game_path.join("Blank - Override.esp"),
2576            game_path.join("Blank.esp"),
2577            game_path.join("Blank.full.esm"),
2578            game_path.join("Blank.medium.esm"),
2579            game_path.join("Blank.small.esm"),
2580        ];
2581
2582        assert_eq!(plugin_paths, result);
2583    }
2584
2585    #[test]
2586    fn find_plugins_in_directories_should_find_symlinks_to_plugins() {
2587        const BLANK_ESM: &str = "Blank.esm";
2588        const BLANK_ESP: &str = "Blank.esp";
2589
2590        let tmp_dir = tempdir().unwrap();
2591        let data_path = tmp_dir.path().join("game");
2592        let other_path = tmp_dir.path().join("other");
2593
2594        copy_to_dir(BLANK_ESM, &data_path, BLANK_ESM, GameId::OpenMW);
2595        copy_to_dir(BLANK_ESP, &other_path, BLANK_ESP, GameId::OpenMW);
2596
2597        symlink_file(&other_path.join(BLANK_ESP), &data_path.join(BLANK_ESP));
2598
2599        let result = find_plugins_in_directories(once(&data_path), GameId::OpenMW);
2600
2601        let plugin_paths = vec![data_path.join(BLANK_ESM), data_path.join(BLANK_ESP)];
2602
2603        assert_eq!(plugin_paths, result);
2604    }
2605}