Skip to main content

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