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