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        .is_ok_and(|f| keep_file_type(f, game_id))
688}
689
690#[cfg(unix)]
691#[expect(
692    clippy::filetype_is_file,
693    reason = "Only files and symlinks are supported"
694)]
695fn keep_file_type(f: FileType, _game_id: GameId) -> bool {
696    f.is_file() || f.is_symlink()
697}
698
699#[cfg(windows)]
700#[expect(
701    clippy::filetype_is_file,
702    reason = "Only files and sometimes symlinks are supported"
703)]
704fn keep_file_type(f: FileType, game_id: GameId) -> bool {
705    if matches!(game_id, GameId::OblivionRemastered | GameId::OpenMW) {
706        f.is_file() || f.is_symlink()
707    } else {
708        f.is_file()
709    }
710}
711
712fn early_loading_plugins(
713    game_id: GameId,
714    game_path: &Path,
715    my_games_path: &Path,
716    has_test_files: bool,
717) -> Result<Vec<String>, Error> {
718    let mut plugin_names: Vec<String> = hardcoded_plugins(game_id)
719        .iter()
720        .map(|s| (*s).to_owned())
721        .collect();
722
723    if matches!(game_id, GameId::Fallout4 | GameId::Starfield) && has_test_files {
724        // If test files are configured for Fallout 4, CCC plugins are not loaded.
725        // No need to check for Fallout 4 VR, as it has no CCC plugins file.
726        return Ok(plugin_names);
727    }
728
729    for file_path in ccc_file_paths(game_id, game_path, my_games_path) {
730        if file_path.exists() {
731            let reader =
732                BufReader::new(File::open(&file_path).map_err(|e| Error::IoError(file_path, e))?);
733
734            let lines = reader
735                .lines()
736                .filter_map(|line| line.ok().filter(|l| !l.is_empty()));
737
738            plugin_names.extend(lines);
739            break;
740        }
741    }
742
743    if game_id == GameId::OpenMW {
744        plugin_names.extend(openmw_config::non_user_active_plugin_names(game_path)?);
745    }
746
747    deduplicate(&mut plugin_names);
748
749    Ok(plugin_names)
750}
751
752fn implicitly_active_plugins(
753    game_id: GameId,
754    game_path: &Path,
755    early_loading_plugins: &[String],
756    test_files: &[String],
757) -> Result<Vec<String>, Error> {
758    let mut plugin_names = Vec::new();
759
760    plugin_names.extend_from_slice(early_loading_plugins);
761    plugin_names.extend_from_slice(test_files);
762
763    if game_id == GameId::FalloutNV {
764        // If there is a .nam file with the same basename as a plugin then the plugin is activated
765        // and listed as a DLC in the game's title screen menu. This only works in the game's
766        // Data path, so ignore additional plugin directories.
767        let nam_plugins = find_nam_plugins(&game_path.join("Data"))?;
768
769        plugin_names.extend(nam_plugins);
770    } else if game_id == GameId::Skyrim {
771        // Update.esm is always active, but loads after all other masters if it is not made to load
772        // earlier (e.g. by listing in plugins.txt or by being a master of another master).
773        plugin_names.push("Update.esm".to_owned());
774    }
775
776    deduplicate(&mut plugin_names);
777
778    Ok(plugin_names)
779}
780
781/// Remove duplicates, keeping only the first instance of each plugin.
782fn deduplicate(plugin_names: &mut Vec<String>) {
783    let mut set = std::collections::HashSet::new();
784    plugin_names.retain(|e| set.insert(unicase::UniCase::new(e.clone())));
785}
786
787fn find_map_path(directory: &Path, plugin_name: &str, game_id: GameId) -> Option<PathBuf> {
788    if game_id.allow_plugin_ghosting() {
789        // Plugins may be ghosted, so take that into account when checking.
790        use crate::ghostable_path::GhostablePath;
791
792        directory.join(plugin_name).resolve_path().ok()
793    } else {
794        let path = directory.join(plugin_name);
795        path.exists().then_some(path)
796    }
797}
798
799fn pick_plugin_path<'a>(
800    game_id: GameId,
801    plugin_name: &str,
802    plugins_directory: &Path,
803    mut dir_iter: impl Iterator<Item = &'a PathBuf>,
804) -> PathBuf {
805    dir_iter
806        .find_map(|d| find_map_path(d, plugin_name, game_id))
807        .unwrap_or_else(|| plugins_directory.join(plugin_name))
808}
809
810fn plugin_path(
811    game_id: GameId,
812    plugin_name: &str,
813    plugins_directory: &Path,
814    additional_plugins_directories: &[PathBuf],
815) -> PathBuf {
816    // There may be multiple directories that the plugin could be installed in, so check each in
817    // turn.
818
819    // Starfield (at least as of 1.12.32.0) only loads plugins from its additional directory if
820    // they're also present in plugins_directory, so there's no point checking the additional
821    // directory if a plugin isn't present in plugins_directory.
822    if game_id == GameId::Starfield {
823        // Plugins may be ghosted, so take that into account when checking.
824        use crate::ghostable_path::GhostablePath;
825
826        let path = plugins_directory.join(plugin_name);
827        if path.resolve_path().is_err() {
828            return path;
829        }
830    }
831
832    // In OpenMW, if there are multiple directories containing the same filename, the last directory
833    // listed "wins".
834    match game_id {
835        GameId::OpenMW => pick_plugin_path(
836            game_id,
837            plugin_name,
838            plugins_directory,
839            additional_plugins_directories.iter().rev(),
840        ),
841        _ => pick_plugin_path(
842            game_id,
843            plugin_name,
844            plugins_directory,
845            additional_plugins_directories.iter(),
846        ),
847    }
848}
849
850fn sort_plugins_dir_entries(a: &DirEntry, b: &DirEntry) -> Ordering {
851    // Sort by file modification timestamps, in ascending order. If two
852    // timestamps are equal, sort by uppercased filenames in descending order.
853    let m_a = get_target_modified_timestamp(a);
854    let m_b = get_target_modified_timestamp(b);
855
856    match m_a.cmp(&m_b) {
857        Ordering::Equal => compare_uppercased_filenames(&a.file_name(), &b.file_name()).reverse(),
858        x => x,
859    }
860}
861
862fn compare_uppercased_filenames(a: &OsStr, b: &OsStr) -> Ordering {
863    match (a.to_str(), b.to_str()) {
864        (Some(a), Some(b)) => a.to_uppercase().cmp(&b.to_uppercase()),
865        // The remaining cases are practically impossible for game plugins, but are included for
866        // completeness. If the filename can't be represented as a UTF-8 string then there's no way
867        // to know how to transform its case correctly, so they can only be compared as they are.
868        _ => a.cmp(b),
869    }
870}
871
872fn get_target_modified_timestamp(entry: &DirEntry) -> Option<SystemTime> {
873    let metadata = if entry.file_type().is_ok_and(|f| f.is_symlink()) {
874        entry.path().metadata()
875    } else {
876        entry.metadata()
877    };
878
879    metadata.and_then(|m| m.modified()).ok()
880}
881
882fn sort_plugins_dir_entries_openmw(a: &DirEntry, b: &DirEntry) -> Ordering {
883    // Preserve the directory ordering, but sort case-sensitive
884    // lexicographically within directories.
885    if a.path().parent() == b.path().parent() {
886        a.file_name().cmp(&b.file_name())
887    } else {
888        Ordering::Equal
889    }
890}
891
892fn find_plugins_in_directories<'a>(
893    directories_iter: impl Iterator<Item = &'a PathBuf>,
894    game_id: GameId,
895) -> Vec<PathBuf> {
896    let mut dir_entries: Vec<_> = directories_iter
897        .flat_map(read_dir)
898        .flatten()
899        .filter_map(Result::ok)
900        .filter(|e| is_file_type_supported(e, game_id))
901        .filter(|e| {
902            e.file_name()
903                .to_str()
904                .is_some_and(|f| has_plugin_extension(f, game_id))
905        })
906        .collect();
907
908    let compare = match game_id {
909        GameId::OpenMW => sort_plugins_dir_entries_openmw,
910        _ => sort_plugins_dir_entries,
911    };
912
913    dir_entries.sort_by(compare);
914
915    dir_entries.into_iter().map(|e| e.path()).collect()
916}
917
918#[cfg(test)]
919mod tests {
920    #[cfg(windows)]
921    use std::env;
922    use std::{fs::create_dir_all, io::Write};
923    use tempfile::tempdir;
924
925    use crate::tests::{copy_to_dir, set_file_timestamps, symlink_file, NON_ASCII};
926
927    use super::*;
928
929    fn game_with_generic_paths(game_id: GameId) -> GameSettings {
930        GameSettings::with_local_and_my_games_paths(
931            game_id,
932            &PathBuf::from("game"),
933            &PathBuf::from("local"),
934            PathBuf::from("my games"),
935        )
936        .unwrap()
937    }
938
939    fn game_with_game_path(game_id: GameId, game_path: &Path) -> GameSettings {
940        GameSettings::with_local_and_my_games_paths(
941            game_id,
942            game_path,
943            &PathBuf::default(),
944            PathBuf::default(),
945        )
946        .unwrap()
947    }
948
949    fn game_with_ccc_plugins(
950        game_id: GameId,
951        game_path: &Path,
952        plugin_names: &[&str],
953    ) -> GameSettings {
954        let ccc_path = &ccc_file_paths(game_id, game_path, &PathBuf::new())[0];
955        create_ccc_file(ccc_path, plugin_names);
956
957        game_with_game_path(game_id, game_path)
958    }
959
960    fn create_ccc_file(path: &Path, plugin_names: &[&str]) {
961        create_dir_all(path.parent().unwrap()).unwrap();
962
963        let mut file = File::create(path).unwrap();
964
965        for plugin_name in plugin_names {
966            writeln!(file, "{plugin_name}").unwrap();
967        }
968    }
969
970    fn generate_file_file_type() -> FileType {
971        let tmp_dir = tempdir().unwrap();
972        let file_path = tmp_dir.path().join("file");
973
974        File::create(&file_path).unwrap();
975
976        let file_file_type = file_path.metadata().unwrap().file_type();
977
978        assert!(file_file_type.is_file());
979
980        file_file_type
981    }
982
983    fn generate_symlink_file_type() -> FileType {
984        let tmp_dir = tempdir().unwrap();
985        let file_path = tmp_dir.path().join("file");
986        let symlink_path = tmp_dir.path().join("symlink");
987
988        File::create(&file_path).unwrap();
989        symlink_file(&file_path, &symlink_path);
990
991        let symlink_file_type = symlink_path.symlink_metadata().unwrap().file_type();
992
993        assert!(symlink_file_type.is_symlink());
994
995        symlink_file_type
996    }
997
998    #[test]
999    #[cfg(windows)]
1000    fn new_should_determine_correct_local_path_on_windows() {
1001        let settings = GameSettings::new(GameId::Skyrim, Path::new("game")).unwrap();
1002        let local_app_data = env::var("LOCALAPPDATA").unwrap();
1003        let local_app_data_path = Path::new(&local_app_data);
1004
1005        assert_eq!(
1006            local_app_data_path.join("Skyrim").join("Plugins.txt"),
1007            *settings.active_plugins_file()
1008        );
1009        assert_eq!(
1010            &local_app_data_path.join("Skyrim").join("loadorder.txt"),
1011            *settings.load_order_file().as_ref().unwrap()
1012        );
1013    }
1014
1015    #[test]
1016    #[cfg(windows)]
1017    fn new_should_determine_correct_local_path_for_openmw() {
1018        let tmp_dir = tempdir().unwrap();
1019        let global_cfg_path = tmp_dir.path().join("openmw.cfg");
1020
1021        std::fs::write(&global_cfg_path, "config=local").unwrap();
1022
1023        let settings = GameSettings::new(GameId::OpenMW, tmp_dir.path()).unwrap();
1024
1025        assert_eq!(
1026            &tmp_dir.path().join("local/openmw.cfg"),
1027            settings.active_plugins_file()
1028        );
1029        assert_eq!(tmp_dir.path().join("local"), settings.my_games_path);
1030    }
1031
1032    #[test]
1033    fn new_should_use_an_empty_local_path_for_morrowind() {
1034        let settings = GameSettings::new(GameId::Morrowind, Path::new("game")).unwrap();
1035
1036        assert_eq!(PathBuf::new(), settings.my_games_path);
1037    }
1038
1039    #[test]
1040    #[cfg(not(windows))]
1041    fn new_should_determine_correct_local_path_for_openmw_on_linux() {
1042        let config_path = Path::new("/etc/openmw");
1043
1044        let settings = GameSettings::new(GameId::OpenMW, Path::new("game")).unwrap();
1045
1046        assert_eq!(
1047            &config_path.join("openmw.cfg"),
1048            settings.active_plugins_file()
1049        );
1050        assert_eq!(config_path, settings.my_games_path);
1051    }
1052
1053    #[test]
1054    fn id_should_be_the_id_the_struct_was_created_with() {
1055        let settings = game_with_generic_paths(GameId::Morrowind);
1056        assert_eq!(GameId::Morrowind, settings.id());
1057    }
1058
1059    #[test]
1060    fn supports_blueprint_ships_plugins_should_be_true_for_starfield_only() {
1061        let mut settings = game_with_generic_paths(GameId::Morrowind);
1062        assert!(!settings.supports_blueprint_ships_plugins());
1063
1064        settings = game_with_generic_paths(GameId::OpenMW);
1065        assert!(!settings.supports_blueprint_ships_plugins());
1066
1067        settings = game_with_generic_paths(GameId::Oblivion);
1068        assert!(!settings.supports_blueprint_ships_plugins());
1069
1070        settings = game_with_generic_paths(GameId::OblivionRemastered);
1071        assert!(!settings.supports_blueprint_ships_plugins());
1072
1073        settings = game_with_generic_paths(GameId::Skyrim);
1074        assert!(!settings.supports_blueprint_ships_plugins());
1075
1076        settings = game_with_generic_paths(GameId::SkyrimSE);
1077        assert!(!settings.supports_blueprint_ships_plugins());
1078
1079        settings = game_with_generic_paths(GameId::SkyrimVR);
1080        assert!(!settings.supports_blueprint_ships_plugins());
1081
1082        settings = game_with_generic_paths(GameId::Fallout3);
1083        assert!(!settings.supports_blueprint_ships_plugins());
1084
1085        settings = game_with_generic_paths(GameId::FalloutNV);
1086        assert!(!settings.supports_blueprint_ships_plugins());
1087
1088        settings = game_with_generic_paths(GameId::Fallout4);
1089        assert!(!settings.supports_blueprint_ships_plugins());
1090
1091        settings = game_with_generic_paths(GameId::Fallout4VR);
1092        assert!(!settings.supports_blueprint_ships_plugins());
1093
1094        settings = game_with_generic_paths(GameId::Starfield);
1095        assert!(settings.supports_blueprint_ships_plugins());
1096    }
1097
1098    #[test]
1099    fn load_order_method_should_be_timestamp_for_tes3_tes4_fo3_and_fonv() {
1100        let mut settings = game_with_generic_paths(GameId::Morrowind);
1101        assert_eq!(LoadOrderMethod::Timestamp, settings.load_order_method());
1102
1103        settings = game_with_generic_paths(GameId::Oblivion);
1104        assert_eq!(LoadOrderMethod::Timestamp, settings.load_order_method());
1105
1106        settings = game_with_generic_paths(GameId::Fallout3);
1107        assert_eq!(LoadOrderMethod::Timestamp, settings.load_order_method());
1108
1109        settings = game_with_generic_paths(GameId::FalloutNV);
1110        assert_eq!(LoadOrderMethod::Timestamp, settings.load_order_method());
1111    }
1112
1113    #[test]
1114    fn load_order_method_should_be_textfile_for_tes5() {
1115        let settings = game_with_generic_paths(GameId::Skyrim);
1116        assert_eq!(LoadOrderMethod::Textfile, settings.load_order_method());
1117    }
1118
1119    #[test]
1120    fn load_order_method_should_be_asterisk_for_tes5se_tes5vr_fo4_fo4vr_and_starfield() {
1121        let mut settings = game_with_generic_paths(GameId::SkyrimSE);
1122        assert_eq!(LoadOrderMethod::Asterisk, settings.load_order_method());
1123
1124        settings = game_with_generic_paths(GameId::SkyrimVR);
1125        assert_eq!(LoadOrderMethod::Asterisk, settings.load_order_method());
1126
1127        settings = game_with_generic_paths(GameId::Fallout4);
1128        assert_eq!(LoadOrderMethod::Asterisk, settings.load_order_method());
1129
1130        settings = game_with_generic_paths(GameId::Fallout4VR);
1131        assert_eq!(LoadOrderMethod::Asterisk, settings.load_order_method());
1132
1133        settings = game_with_generic_paths(GameId::Starfield);
1134        assert_eq!(LoadOrderMethod::Asterisk, settings.load_order_method());
1135    }
1136
1137    #[test]
1138    fn load_order_method_should_be_openmw_for_openmw() {
1139        let settings = game_with_generic_paths(GameId::OpenMW);
1140
1141        assert_eq!(LoadOrderMethod::OpenMW, settings.load_order_method());
1142    }
1143
1144    #[test]
1145    #[expect(deprecated)]
1146    fn master_file_should_be_mapped_from_game_id() {
1147        let mut settings = game_with_generic_paths(GameId::OpenMW);
1148        assert_eq!("Morrowind.esm", settings.master_file());
1149
1150        settings = game_with_generic_paths(GameId::Morrowind);
1151        assert_eq!("Morrowind.esm", settings.master_file());
1152
1153        settings = game_with_generic_paths(GameId::Oblivion);
1154        assert_eq!("Oblivion.esm", settings.master_file());
1155
1156        settings = game_with_generic_paths(GameId::Skyrim);
1157        assert_eq!("Skyrim.esm", settings.master_file());
1158
1159        settings = game_with_generic_paths(GameId::SkyrimSE);
1160        assert_eq!("Skyrim.esm", settings.master_file());
1161
1162        settings = game_with_generic_paths(GameId::SkyrimVR);
1163        assert_eq!("Skyrim.esm", settings.master_file());
1164
1165        settings = game_with_generic_paths(GameId::Fallout3);
1166        assert_eq!("Fallout3.esm", settings.master_file());
1167
1168        settings = game_with_generic_paths(GameId::FalloutNV);
1169        assert_eq!("FalloutNV.esm", settings.master_file());
1170
1171        settings = game_with_generic_paths(GameId::Fallout4);
1172        assert_eq!("Fallout4.esm", settings.master_file());
1173
1174        settings = game_with_generic_paths(GameId::Fallout4VR);
1175        assert_eq!("Fallout4.esm", settings.master_file());
1176
1177        settings = game_with_generic_paths(GameId::Starfield);
1178        assert_eq!("Starfield.esm", settings.master_file());
1179    }
1180
1181    #[test]
1182    fn appdata_folder_name_should_be_mapped_from_game_id() {
1183        // The game path is unused for most game IDs.
1184        let game_path = Path::new("");
1185
1186        assert!(appdata_folder_name(GameId::OpenMW, game_path).is_none());
1187
1188        assert!(appdata_folder_name(GameId::Morrowind, game_path).is_none());
1189
1190        let mut folder = appdata_folder_name(GameId::Oblivion, game_path).unwrap();
1191        assert_eq!("Oblivion", folder);
1192
1193        folder = appdata_folder_name(GameId::Skyrim, game_path).unwrap();
1194        assert_eq!("Skyrim", folder);
1195
1196        folder = appdata_folder_name(GameId::SkyrimSE, game_path).unwrap();
1197        assert_eq!("Skyrim Special Edition", folder);
1198
1199        folder = appdata_folder_name(GameId::SkyrimVR, game_path).unwrap();
1200        assert_eq!("Skyrim VR", folder);
1201
1202        folder = appdata_folder_name(GameId::Fallout3, game_path).unwrap();
1203        assert_eq!("Fallout3", folder);
1204
1205        folder = appdata_folder_name(GameId::FalloutNV, game_path).unwrap();
1206        assert_eq!("FalloutNV", folder);
1207
1208        folder = appdata_folder_name(GameId::Fallout4, game_path).unwrap();
1209        assert_eq!("Fallout4", folder);
1210
1211        folder = appdata_folder_name(GameId::Fallout4VR, game_path).unwrap();
1212        assert_eq!("Fallout4VR", folder);
1213
1214        folder = appdata_folder_name(GameId::Starfield, game_path).unwrap();
1215        assert_eq!("Starfield", folder);
1216    }
1217
1218    #[test]
1219    fn appdata_folder_name_for_skyrim_se_should_have_gog_suffix_if_galaxy_dll_is_in_game_path() {
1220        let tmp_dir = tempdir().unwrap();
1221        let game_path = tmp_dir.path();
1222
1223        let mut folder = appdata_folder_name(GameId::SkyrimSE, game_path).unwrap();
1224        assert_eq!("Skyrim Special Edition", folder);
1225
1226        let dll_path = game_path.join("Galaxy64.dll");
1227        File::create(&dll_path).unwrap();
1228
1229        folder = appdata_folder_name(GameId::SkyrimSE, game_path).unwrap();
1230        assert_eq!("Skyrim Special Edition GOG", folder);
1231    }
1232
1233    #[test]
1234    fn appdata_folder_name_for_skyrim_se_should_have_epic_suffix_if_eossdk_dll_is_in_game_path() {
1235        let tmp_dir = tempdir().unwrap();
1236        let game_path = tmp_dir.path();
1237
1238        let mut folder = appdata_folder_name(GameId::SkyrimSE, game_path).unwrap();
1239        assert_eq!("Skyrim Special Edition", folder);
1240
1241        let dll_path = game_path.join("EOSSDK-Win64-Shipping.dll");
1242        File::create(&dll_path).unwrap();
1243
1244        folder = appdata_folder_name(GameId::SkyrimSE, game_path).unwrap();
1245        assert_eq!("Skyrim Special Edition EPIC", folder);
1246    }
1247
1248    #[test]
1249    fn appdata_folder_name_for_skyrim_se_should_have_ms_suffix_if_appxmanifest_xml_is_in_game_path()
1250    {
1251        let tmp_dir = tempdir().unwrap();
1252        let game_path = tmp_dir.path();
1253
1254        let mut folder = appdata_folder_name(GameId::SkyrimSE, game_path).unwrap();
1255        assert_eq!("Skyrim Special Edition", folder);
1256
1257        let dll_path = game_path.join("appxmanifest.xml");
1258        File::create(&dll_path).unwrap();
1259
1260        folder = appdata_folder_name(GameId::SkyrimSE, game_path).unwrap();
1261        assert_eq!("Skyrim Special Edition MS", folder);
1262    }
1263
1264    #[test]
1265    fn appdata_folder_name_for_skyrim_se_prefers_gog_suffix_over_epic_suffix() {
1266        let tmp_dir = tempdir().unwrap();
1267        let game_path = tmp_dir.path();
1268
1269        let dll_path = game_path.join("Galaxy64.dll");
1270        File::create(&dll_path).unwrap();
1271
1272        let dll_path = game_path.join("EOSSDK-Win64-Shipping.dll");
1273        File::create(&dll_path).unwrap();
1274
1275        let folder = appdata_folder_name(GameId::SkyrimSE, game_path).unwrap();
1276        assert_eq!("Skyrim Special Edition GOG", folder);
1277    }
1278
1279    #[test]
1280    fn appdata_folder_name_for_fallout_nv_should_have_epic_suffix_if_eossdk_dll_is_in_game_path() {
1281        let tmp_dir = tempdir().unwrap();
1282        let game_path = tmp_dir.path();
1283
1284        let mut folder = appdata_folder_name(GameId::FalloutNV, game_path).unwrap();
1285        assert_eq!("FalloutNV", folder);
1286
1287        let dll_path = game_path.join("EOSSDK-Win32-Shipping.dll");
1288        File::create(&dll_path).unwrap();
1289
1290        folder = appdata_folder_name(GameId::FalloutNV, game_path).unwrap();
1291        assert_eq!("FalloutNV_Epic", folder);
1292    }
1293
1294    #[test]
1295    fn appdata_folder_name_for_fallout4_should_have_ms_suffix_if_appxmanifest_xml_is_in_game_path()
1296    {
1297        let tmp_dir = tempdir().unwrap();
1298        let game_path = tmp_dir.path();
1299
1300        let mut folder = appdata_folder_name(GameId::Fallout4, game_path).unwrap();
1301        assert_eq!("Fallout4", folder);
1302
1303        let dll_path = game_path.join("appxmanifest.xml");
1304        File::create(&dll_path).unwrap();
1305
1306        folder = appdata_folder_name(GameId::Fallout4, game_path).unwrap();
1307        assert_eq!("Fallout4 MS", folder);
1308    }
1309
1310    #[test]
1311    fn appdata_folder_name_for_fallout4_should_have_epic_suffix_if_eossdk_dll_is_in_game_path() {
1312        let tmp_dir = tempdir().unwrap();
1313        let game_path = tmp_dir.path();
1314
1315        let mut folder = appdata_folder_name(GameId::Fallout4, game_path).unwrap();
1316        assert_eq!("Fallout4", folder);
1317
1318        let dll_path = game_path.join("EOSSDK-Win64-Shipping.dll");
1319        File::create(&dll_path).unwrap();
1320
1321        folder = appdata_folder_name(GameId::Fallout4, game_path).unwrap();
1322        assert_eq!("Fallout4 EPIC", folder);
1323    }
1324
1325    #[test]
1326    #[cfg(windows)]
1327    fn my_games_path_should_be_in_documents_path_on_windows() {
1328        let empty_path = Path::new("");
1329        let parent_path = dirs::document_dir().unwrap().join("My Games");
1330
1331        let path = my_games_path(GameId::Morrowind, empty_path, empty_path).unwrap();
1332        assert!(path.is_none());
1333
1334        let path = my_games_path(GameId::Oblivion, empty_path, empty_path)
1335            .unwrap()
1336            .unwrap();
1337        assert_eq!(parent_path.join("Oblivion"), path);
1338
1339        let path = my_games_path(GameId::Skyrim, empty_path, empty_path)
1340            .unwrap()
1341            .unwrap();
1342        assert_eq!(parent_path.join("Skyrim"), path);
1343
1344        let path = my_games_path(GameId::SkyrimSE, empty_path, empty_path)
1345            .unwrap()
1346            .unwrap();
1347        assert_eq!(parent_path.join("Skyrim Special Edition"), path);
1348
1349        let path = my_games_path(GameId::SkyrimVR, empty_path, empty_path)
1350            .unwrap()
1351            .unwrap();
1352        assert_eq!(parent_path.join("Skyrim VR"), path);
1353
1354        let path = my_games_path(GameId::Fallout3, empty_path, empty_path)
1355            .unwrap()
1356            .unwrap();
1357        assert_eq!(parent_path.join("Fallout3"), path);
1358
1359        let path = my_games_path(GameId::FalloutNV, empty_path, empty_path)
1360            .unwrap()
1361            .unwrap();
1362        assert_eq!(parent_path.join("FalloutNV"), path);
1363
1364        let path = my_games_path(GameId::Fallout4, empty_path, empty_path)
1365            .unwrap()
1366            .unwrap();
1367        assert_eq!(parent_path.join("Fallout4"), path);
1368
1369        let path = my_games_path(GameId::Fallout4VR, empty_path, empty_path)
1370            .unwrap()
1371            .unwrap();
1372        assert_eq!(parent_path.join("Fallout4VR"), path);
1373
1374        let path = my_games_path(GameId::Starfield, empty_path, empty_path)
1375            .unwrap()
1376            .unwrap();
1377        assert_eq!(parent_path.join("Starfield"), path);
1378    }
1379
1380    #[test]
1381    #[cfg(not(windows))]
1382    fn my_games_path_should_be_relative_to_local_path_on_linux() {
1383        let empty_path = Path::new("");
1384        let local_path = Path::new("wineprefix/drive_c/Users/user/AppData/Local/Game");
1385        let parent_path = Path::new("wineprefix/drive_c/Users/user/Documents/My Games");
1386
1387        let path = my_games_path(GameId::Morrowind, empty_path, local_path).unwrap();
1388        assert!(path.is_none());
1389
1390        let path = my_games_path(GameId::Oblivion, empty_path, local_path)
1391            .unwrap()
1392            .unwrap();
1393        assert_eq!(parent_path.join("Oblivion"), path);
1394
1395        let path = my_games_path(GameId::Skyrim, empty_path, local_path)
1396            .unwrap()
1397            .unwrap();
1398        assert_eq!(parent_path.join("Skyrim"), path);
1399
1400        let path = my_games_path(GameId::SkyrimSE, empty_path, local_path)
1401            .unwrap()
1402            .unwrap();
1403        assert_eq!(parent_path.join("Skyrim Special Edition"), path);
1404
1405        let path = my_games_path(GameId::SkyrimVR, empty_path, local_path)
1406            .unwrap()
1407            .unwrap();
1408        assert_eq!(parent_path.join("Skyrim VR"), path);
1409
1410        let path = my_games_path(GameId::Fallout3, empty_path, local_path)
1411            .unwrap()
1412            .unwrap();
1413        assert_eq!(parent_path.join("Fallout3"), path);
1414
1415        let path = my_games_path(GameId::FalloutNV, empty_path, local_path)
1416            .unwrap()
1417            .unwrap();
1418        assert_eq!(parent_path.join("FalloutNV"), path);
1419
1420        let path = my_games_path(GameId::Fallout4, empty_path, local_path)
1421            .unwrap()
1422            .unwrap();
1423        assert_eq!(parent_path.join("Fallout4"), path);
1424
1425        let path = my_games_path(GameId::Fallout4VR, empty_path, local_path)
1426            .unwrap()
1427            .unwrap();
1428        assert_eq!(parent_path.join("Fallout4VR"), path);
1429
1430        let path = my_games_path(GameId::Starfield, empty_path, local_path)
1431            .unwrap()
1432            .unwrap();
1433        assert_eq!(parent_path.join("Starfield"), path);
1434    }
1435
1436    #[test]
1437    #[cfg(windows)]
1438    fn my_games_path_should_be_local_path_for_openmw() {
1439        let local_path = Path::new("path/to/local");
1440
1441        let path = my_games_path(GameId::OpenMW, Path::new(""), local_path)
1442            .unwrap()
1443            .unwrap();
1444        assert_eq!(local_path, path);
1445    }
1446
1447    #[test]
1448    fn plugins_directory_should_be_mapped_from_game_id() {
1449        let data_path = Path::new("Data");
1450        let empty_path = Path::new("");
1451        let closure = |game_id| plugins_directory(game_id, empty_path, empty_path).unwrap();
1452
1453        assert_eq!(Path::new("resources/vfs"), closure(GameId::OpenMW));
1454        assert_eq!(Path::new("Data Files"), closure(GameId::Morrowind));
1455        assert_eq!(data_path, closure(GameId::Oblivion));
1456        assert_eq!(data_path, closure(GameId::Skyrim));
1457        assert_eq!(data_path, closure(GameId::SkyrimSE));
1458        assert_eq!(data_path, closure(GameId::SkyrimVR));
1459        assert_eq!(data_path, closure(GameId::Fallout3));
1460        assert_eq!(data_path, closure(GameId::FalloutNV));
1461        assert_eq!(data_path, closure(GameId::Fallout4));
1462        assert_eq!(data_path, closure(GameId::Fallout4VR));
1463        assert_eq!(data_path, closure(GameId::Starfield));
1464    }
1465
1466    #[test]
1467    fn active_plugins_file_should_be_mapped_from_game_id() {
1468        let mut settings = game_with_generic_paths(GameId::OpenMW);
1469        assert_eq!(
1470            Path::new("local/openmw.cfg"),
1471            settings.active_plugins_file()
1472        );
1473
1474        settings = game_with_generic_paths(GameId::Morrowind);
1475        assert_eq!(
1476            Path::new("game/Morrowind.ini"),
1477            settings.active_plugins_file()
1478        );
1479
1480        settings = game_with_generic_paths(GameId::Oblivion);
1481        assert_eq!(
1482            Path::new("local/Plugins.txt"),
1483            settings.active_plugins_file()
1484        );
1485
1486        settings = game_with_generic_paths(GameId::Skyrim);
1487        assert_eq!(
1488            Path::new("local/Plugins.txt"),
1489            settings.active_plugins_file()
1490        );
1491
1492        settings = game_with_generic_paths(GameId::SkyrimSE);
1493        assert_eq!(
1494            Path::new("local/Plugins.txt"),
1495            settings.active_plugins_file()
1496        );
1497
1498        settings = game_with_generic_paths(GameId::SkyrimVR);
1499        assert_eq!(
1500            Path::new("local/Plugins.txt"),
1501            settings.active_plugins_file()
1502        );
1503
1504        settings = game_with_generic_paths(GameId::Fallout3);
1505        assert_eq!(
1506            Path::new("local/Plugins.txt"),
1507            settings.active_plugins_file()
1508        );
1509
1510        settings = game_with_generic_paths(GameId::FalloutNV);
1511        assert_eq!(
1512            Path::new("local/Plugins.txt"),
1513            settings.active_plugins_file()
1514        );
1515
1516        settings = game_with_generic_paths(GameId::Fallout4);
1517        assert_eq!(
1518            Path::new("local/Plugins.txt"),
1519            settings.active_plugins_file()
1520        );
1521
1522        settings = game_with_generic_paths(GameId::Fallout4VR);
1523        assert_eq!(
1524            Path::new("local/Plugins.txt"),
1525            settings.active_plugins_file()
1526        );
1527
1528        settings = game_with_generic_paths(GameId::Starfield);
1529        assert_eq!(
1530            Path::new("local/Plugins.txt"),
1531            settings.active_plugins_file()
1532        );
1533    }
1534
1535    #[test]
1536    fn active_plugins_file_should_be_in_game_path_for_oblivion_if_ini_setting_is_not_1() {
1537        let tmp_dir = tempdir().unwrap();
1538        let game_path = tmp_dir.path();
1539        let ini_path = game_path.join("Oblivion.ini");
1540
1541        std::fs::write(ini_path, "[General]\nbUseMyGamesDirectory=0\n").unwrap();
1542
1543        let settings = game_with_game_path(GameId::Oblivion, game_path);
1544        assert_eq!(
1545            game_path.join("Plugins.txt"),
1546            *settings.active_plugins_file()
1547        );
1548    }
1549
1550    #[test]
1551    #[cfg(not(windows))]
1552    fn find_nam_plugins_should_also_find_symlinks_to_nam_plugins() {
1553        let tmp_dir = tempdir().unwrap();
1554        let data_path = tmp_dir.path().join("Data");
1555        let other_path = tmp_dir.path().join("other");
1556
1557        create_dir_all(&data_path).unwrap();
1558        create_dir_all(&other_path).unwrap();
1559        File::create(data_path.join("plugin1.nam")).unwrap();
1560
1561        let original = other_path.join("plugin2.NAM");
1562        File::create(&original).unwrap();
1563        symlink_file(&original, &data_path.join("plugin2.NAM"));
1564
1565        let mut plugins = find_nam_plugins(&data_path).unwrap();
1566        plugins.sort();
1567
1568        let expected_plugins = vec!["plugin1.esm", "plugin1.esp", "plugin2.esm", "plugin2.esp"];
1569
1570        assert_eq!(expected_plugins, plugins);
1571    }
1572
1573    #[test]
1574    fn keep_file_type_should_return_true_for_files_for_all_games() {
1575        let file = generate_file_file_type();
1576
1577        assert!(keep_file_type(file, GameId::Morrowind));
1578        assert!(keep_file_type(file, GameId::OpenMW));
1579        assert!(keep_file_type(file, GameId::Oblivion));
1580        assert!(keep_file_type(file, GameId::OblivionRemastered));
1581        assert!(keep_file_type(file, GameId::Skyrim));
1582        assert!(keep_file_type(file, GameId::SkyrimSE));
1583        assert!(keep_file_type(file, GameId::SkyrimVR));
1584        assert!(keep_file_type(file, GameId::Fallout3));
1585        assert!(keep_file_type(file, GameId::FalloutNV));
1586        assert!(keep_file_type(file, GameId::Fallout4));
1587        assert!(keep_file_type(file, GameId::Fallout4VR));
1588        assert!(keep_file_type(file, GameId::Starfield));
1589    }
1590
1591    #[test]
1592    #[cfg(not(windows))]
1593    fn keep_file_type_should_return_true_for_symlinks_for_all_games_on_linux() {
1594        let symlink = generate_symlink_file_type();
1595
1596        assert!(keep_file_type(symlink, GameId::Morrowind));
1597        assert!(keep_file_type(symlink, GameId::OpenMW));
1598        assert!(keep_file_type(symlink, GameId::Oblivion));
1599        assert!(keep_file_type(symlink, GameId::OblivionRemastered));
1600        assert!(keep_file_type(symlink, GameId::Skyrim));
1601        assert!(keep_file_type(symlink, GameId::SkyrimSE));
1602        assert!(keep_file_type(symlink, GameId::SkyrimVR));
1603        assert!(keep_file_type(symlink, GameId::Fallout3));
1604        assert!(keep_file_type(symlink, GameId::FalloutNV));
1605        assert!(keep_file_type(symlink, GameId::Fallout4));
1606        assert!(keep_file_type(symlink, GameId::Fallout4VR));
1607        assert!(keep_file_type(symlink, GameId::Starfield));
1608    }
1609
1610    #[test]
1611    #[cfg(windows)]
1612    fn keep_file_type_should_return_true_for_symlinks_for_openmw_and_oblivion_remastered_on_windows(
1613    ) {
1614        let symlink = generate_symlink_file_type();
1615
1616        assert!(!keep_file_type(symlink, GameId::Morrowind));
1617        assert!(keep_file_type(symlink, GameId::OpenMW));
1618        assert!(!keep_file_type(symlink, GameId::Oblivion));
1619        assert!(keep_file_type(symlink, GameId::OblivionRemastered));
1620        assert!(!keep_file_type(symlink, GameId::Skyrim));
1621        assert!(!keep_file_type(symlink, GameId::SkyrimSE));
1622        assert!(!keep_file_type(symlink, GameId::SkyrimVR));
1623        assert!(!keep_file_type(symlink, GameId::Fallout3));
1624        assert!(!keep_file_type(symlink, GameId::FalloutNV));
1625        assert!(!keep_file_type(symlink, GameId::Fallout4));
1626        assert!(!keep_file_type(symlink, GameId::Fallout4VR));
1627        assert!(!keep_file_type(symlink, GameId::Starfield));
1628    }
1629
1630    #[test]
1631    fn early_loading_plugins_should_be_mapped_from_game_id() {
1632        let mut settings = game_with_generic_paths(GameId::Skyrim);
1633        let mut plugins = vec!["Skyrim.esm"];
1634        assert_eq!(plugins, settings.early_loading_plugins());
1635
1636        settings = game_with_generic_paths(GameId::SkyrimSE);
1637        plugins = vec![
1638            "Skyrim.esm",
1639            "Update.esm",
1640            "Dawnguard.esm",
1641            "HearthFires.esm",
1642            "Dragonborn.esm",
1643        ];
1644        assert_eq!(plugins, settings.early_loading_plugins());
1645
1646        settings = game_with_generic_paths(GameId::SkyrimVR);
1647        plugins = vec![
1648            "Skyrim.esm",
1649            "Update.esm",
1650            "Dawnguard.esm",
1651            "HearthFires.esm",
1652            "Dragonborn.esm",
1653            "SkyrimVR.esm",
1654        ];
1655        assert_eq!(plugins, settings.early_loading_plugins());
1656
1657        settings = game_with_generic_paths(GameId::Fallout4);
1658        plugins = vec![
1659            "Fallout4.esm",
1660            "DLCRobot.esm",
1661            "DLCworkshop01.esm",
1662            "DLCCoast.esm",
1663            "DLCworkshop02.esm",
1664            "DLCworkshop03.esm",
1665            "DLCNukaWorld.esm",
1666            "DLCUltraHighResolution.esm",
1667        ];
1668        assert_eq!(plugins, settings.early_loading_plugins());
1669
1670        settings = game_with_generic_paths(GameId::OpenMW);
1671        plugins = vec!["builtin.omwscripts"];
1672        assert_eq!(plugins, settings.early_loading_plugins());
1673
1674        settings = game_with_generic_paths(GameId::Morrowind);
1675        assert!(settings.early_loading_plugins().is_empty());
1676
1677        settings = game_with_generic_paths(GameId::Oblivion);
1678        assert!(settings.early_loading_plugins().is_empty());
1679
1680        settings = game_with_generic_paths(GameId::Fallout3);
1681        assert!(settings.early_loading_plugins().is_empty());
1682
1683        settings = game_with_generic_paths(GameId::FalloutNV);
1684        assert!(settings.early_loading_plugins().is_empty());
1685
1686        settings = game_with_generic_paths(GameId::Fallout4VR);
1687        plugins = vec!["Fallout4.esm", "Fallout4_VR.esm"];
1688        assert_eq!(plugins, settings.early_loading_plugins());
1689
1690        settings = game_with_generic_paths(GameId::Starfield);
1691        plugins = vec![
1692            "Starfield.esm",
1693            "Constellation.esm",
1694            "OldMars.esm",
1695            "ShatteredSpace.esm",
1696            "SFBGS003.esm",
1697            "SFBGS004.esm",
1698            "SFBGS006.esm",
1699            "SFBGS007.esm",
1700            "SFBGS008.esm",
1701            "SFBGS00D.esm",
1702            "SFBGS047.esm",
1703            "SFBGS050.esm",
1704        ];
1705        assert_eq!(plugins, settings.early_loading_plugins());
1706    }
1707
1708    #[test]
1709    fn early_loading_plugins_should_include_plugins_loaded_from_ccc_file() {
1710        let tmp_dir = tempdir().unwrap();
1711        let game_path = tmp_dir.path();
1712
1713        let mut plugins = vec![
1714            "Skyrim.esm",
1715            "Update.esm",
1716            "Dawnguard.esm",
1717            "HearthFires.esm",
1718            "Dragonborn.esm",
1719            "ccBGSSSE002-ExoticArrows.esl",
1720            "ccBGSSSE003-Zombies.esl",
1721            "ccBGSSSE004-RuinsEdge.esl",
1722            "ccBGSSSE006-StendarsHammer.esl",
1723            "ccBGSSSE007-Chrysamere.esl",
1724            "ccBGSSSE010-PetDwarvenArmoredMudcrab.esl",
1725            "ccBGSSSE014-SpellPack01.esl",
1726            "ccBGSSSE019-StaffofSheogorath.esl",
1727            "ccMTYSSE001-KnightsoftheNine.esl",
1728            "ccQDRSSE001-SurvivalMode.esl",
1729        ];
1730        let mut settings = game_with_ccc_plugins(GameId::SkyrimSE, game_path, &plugins[5..]);
1731        assert_eq!(plugins, settings.early_loading_plugins());
1732
1733        plugins = vec![
1734            "Fallout4.esm",
1735            "DLCRobot.esm",
1736            "DLCworkshop01.esm",
1737            "DLCCoast.esm",
1738            "DLCworkshop02.esm",
1739            "DLCworkshop03.esm",
1740            "DLCNukaWorld.esm",
1741            "DLCUltraHighResolution.esm",
1742            "ccBGSFO4001-PipBoy(Black).esl",
1743            "ccBGSFO4002-PipBoy(Blue).esl",
1744            "ccBGSFO4003-PipBoy(Camo01).esl",
1745            "ccBGSFO4004-PipBoy(Camo02).esl",
1746            "ccBGSFO4006-PipBoy(Chrome).esl",
1747            "ccBGSFO4012-PipBoy(Red).esl",
1748            "ccBGSFO4014-PipBoy(White).esl",
1749            "ccBGSFO4016-Prey.esl",
1750            "ccBGSFO4017-Mauler.esl",
1751            "ccBGSFO4018-GaussRiflePrototype.esl",
1752            "ccBGSFO4019-ChineseStealthArmor.esl",
1753            "ccBGSFO4020-PowerArmorSkin(Black).esl",
1754            "ccBGSFO4022-PowerArmorSkin(Camo01).esl",
1755            "ccBGSFO4023-PowerArmorSkin(Camo02).esl",
1756            "ccBGSFO4025-PowerArmorSkin(Chrome).esl",
1757            "ccBGSFO4038-HorseArmor.esl",
1758            "ccBGSFO4039-TunnelSnakes.esl",
1759            "ccBGSFO4041-DoomMarineArmor.esl",
1760            "ccBGSFO4042-BFG.esl",
1761            "ccBGSFO4043-DoomChainsaw.esl",
1762            "ccBGSFO4044-HellfirePowerArmor.esl",
1763            "ccFSVFO4001-ModularMilitaryBackpack.esl",
1764            "ccFSVFO4002-MidCenturyModern.esl",
1765            "ccFRSFO4001-HandmadeShotgun.esl",
1766            "ccEEJFO4001-DecorationPack.esl",
1767        ];
1768        settings = game_with_ccc_plugins(GameId::Fallout4, game_path, &plugins[8..]);
1769        assert_eq!(plugins, settings.early_loading_plugins());
1770    }
1771
1772    #[test]
1773    fn early_loading_plugins_should_use_the_starfield_ccc_file_in_game_path() {
1774        let tmp_dir = tempdir().unwrap();
1775        let game_path = tmp_dir.path().join("game");
1776        let my_games_path = tmp_dir.path().join("my games");
1777
1778        create_ccc_file(&game_path.join("Starfield.ccc"), &["test.esm"]);
1779
1780        let settings = GameSettings::with_local_and_my_games_paths(
1781            GameId::Starfield,
1782            &game_path,
1783            &PathBuf::default(),
1784            my_games_path,
1785        )
1786        .unwrap();
1787
1788        let expected = &[
1789            "Starfield.esm",
1790            "Constellation.esm",
1791            "OldMars.esm",
1792            "ShatteredSpace.esm",
1793            "SFBGS003.esm",
1794            "SFBGS004.esm",
1795            "SFBGS006.esm",
1796            "SFBGS007.esm",
1797            "SFBGS008.esm",
1798            "SFBGS00D.esm",
1799            "SFBGS047.esm",
1800            "SFBGS050.esm",
1801            "test.esm",
1802        ];
1803        assert_eq!(expected, settings.early_loading_plugins());
1804    }
1805
1806    #[test]
1807    fn early_loading_plugins_should_use_the_starfield_ccc_file_in_my_games_path() {
1808        let tmp_dir = tempdir().unwrap();
1809        let game_path = tmp_dir.path().join("game");
1810        let my_games_path = tmp_dir.path().join("my games");
1811
1812        create_ccc_file(&my_games_path.join("Starfield.ccc"), &["test.esm"]);
1813
1814        let settings = GameSettings::with_local_and_my_games_paths(
1815            GameId::Starfield,
1816            &game_path,
1817            &PathBuf::default(),
1818            my_games_path,
1819        )
1820        .unwrap();
1821
1822        let expected = &[
1823            "Starfield.esm",
1824            "Constellation.esm",
1825            "OldMars.esm",
1826            "ShatteredSpace.esm",
1827            "SFBGS003.esm",
1828            "SFBGS004.esm",
1829            "SFBGS006.esm",
1830            "SFBGS007.esm",
1831            "SFBGS008.esm",
1832            "SFBGS00D.esm",
1833            "SFBGS047.esm",
1834            "SFBGS050.esm",
1835            "test.esm",
1836        ];
1837        assert_eq!(expected, settings.early_loading_plugins());
1838    }
1839
1840    #[test]
1841    fn early_loading_plugins_should_use_the_first_ccc_file_that_exists() {
1842        let tmp_dir = tempdir().unwrap();
1843        let game_path = tmp_dir.path().join("game");
1844        let my_games_path = tmp_dir.path().join("my games");
1845
1846        create_ccc_file(&game_path.join("Starfield.ccc"), &["test1.esm"]);
1847        create_ccc_file(&my_games_path.join("Starfield.ccc"), &["test2.esm"]);
1848
1849        let settings = GameSettings::with_local_and_my_games_paths(
1850            GameId::Starfield,
1851            &game_path,
1852            &PathBuf::default(),
1853            my_games_path,
1854        )
1855        .unwrap();
1856
1857        let expected = &[
1858            "Starfield.esm",
1859            "Constellation.esm",
1860            "OldMars.esm",
1861            "ShatteredSpace.esm",
1862            "SFBGS003.esm",
1863            "SFBGS004.esm",
1864            "SFBGS006.esm",
1865            "SFBGS007.esm",
1866            "SFBGS008.esm",
1867            "SFBGS00D.esm",
1868            "SFBGS047.esm",
1869            "SFBGS050.esm",
1870            "test2.esm",
1871        ];
1872        assert_eq!(expected, settings.early_loading_plugins());
1873    }
1874
1875    #[test]
1876    fn early_loading_plugins_should_not_include_cc_plugins_for_fallout4_if_test_files_are_configured(
1877    ) {
1878        let tmp_dir = tempdir().unwrap();
1879        let game_path = tmp_dir.path();
1880
1881        create_ccc_file(
1882            &game_path.join("Fallout4.ccc"),
1883            &["ccBGSFO4001-PipBoy(Black).esl"],
1884        );
1885
1886        let ini_path = game_path.join("Fallout4.ini");
1887        std::fs::write(&ini_path, "[General]\nsTestFile1=Blank.esp\n").unwrap();
1888
1889        copy_to_dir(
1890            "Blank.esp",
1891            &game_path.join("Data"),
1892            "Blank.esp",
1893            GameId::Fallout4,
1894        );
1895
1896        let settings = GameSettings::with_local_and_my_games_paths(
1897            GameId::Fallout4,
1898            game_path,
1899            &PathBuf::default(),
1900            game_path.to_path_buf(),
1901        )
1902        .unwrap();
1903
1904        assert_eq!(FALLOUT4_HARDCODED_PLUGINS, settings.early_loading_plugins());
1905    }
1906
1907    #[test]
1908    fn early_loading_plugins_should_not_include_cc_plugins_for_starfield_if_test_files_are_configured(
1909    ) {
1910        let tmp_dir = tempdir().unwrap();
1911        let game_path = tmp_dir.path();
1912        let my_games_path = tmp_dir.path().join("my games");
1913
1914        create_ccc_file(&my_games_path.join("Starfield.ccc"), &["test.esp"]);
1915
1916        let ini_path = game_path.join("Starfield.ini");
1917        std::fs::write(&ini_path, "[General]\nsTestFile1=Blank.esp\n").unwrap();
1918
1919        copy_to_dir(
1920            "Blank.esp",
1921            &game_path.join("Data"),
1922            "Blank.esp",
1923            GameId::Starfield,
1924        );
1925
1926        let settings = GameSettings::with_local_and_my_games_paths(
1927            GameId::Starfield,
1928            game_path,
1929            &PathBuf::default(),
1930            my_games_path,
1931        )
1932        .unwrap();
1933
1934        assert!(!settings.loads_early("test.esp"));
1935    }
1936
1937    #[test]
1938    fn early_loading_plugins_should_include_plugins_from_global_config_for_openmw() {
1939        let tmp_dir = tempdir().unwrap();
1940        let global_cfg_path = tmp_dir.path().join("openmw.cfg");
1941
1942        std::fs::write(
1943            &global_cfg_path,
1944            "config=local\ncontent=test.esm\ncontent=test.esp",
1945        )
1946        .unwrap();
1947
1948        let settings = game_with_game_path(GameId::OpenMW, tmp_dir.path());
1949
1950        let expected = &["builtin.omwscripts", "test.esm", "test.esp"];
1951
1952        assert_eq!(expected, settings.early_loading_plugins());
1953    }
1954
1955    #[test]
1956    fn early_loading_plugins_should_ignore_later_duplicate_entries() {
1957        let tmp_dir = tempdir().unwrap();
1958        let game_path = tmp_dir.path();
1959        let my_games_path = tmp_dir.path().join("my games");
1960
1961        create_ccc_file(
1962            &my_games_path.join("Starfield.ccc"),
1963            &["Starfield.esm", "test.esm"],
1964        );
1965
1966        let settings = GameSettings::with_local_and_my_games_paths(
1967            GameId::Starfield,
1968            game_path,
1969            &PathBuf::default(),
1970            my_games_path,
1971        )
1972        .unwrap();
1973
1974        let expected = &[
1975            "Starfield.esm",
1976            "Constellation.esm",
1977            "OldMars.esm",
1978            "ShatteredSpace.esm",
1979            "SFBGS003.esm",
1980            "SFBGS004.esm",
1981            "SFBGS006.esm",
1982            "SFBGS007.esm",
1983            "SFBGS008.esm",
1984            "SFBGS00D.esm",
1985            "SFBGS047.esm",
1986            "SFBGS050.esm",
1987            "test.esm",
1988        ];
1989        assert_eq!(expected, settings.early_loading_plugins());
1990    }
1991
1992    #[test]
1993    fn implicitly_active_plugins_should_include_early_loading_plugins() {
1994        let tmp_dir = tempdir().unwrap();
1995        let game_path = tmp_dir.path();
1996
1997        let settings = game_with_game_path(GameId::SkyrimSE, game_path);
1998
1999        assert_eq!(
2000            settings.early_loading_plugins(),
2001            settings.implicitly_active_plugins()
2002        );
2003    }
2004
2005    #[test]
2006    fn implicitly_active_plugins_should_include_test_files() {
2007        let tmp_dir = tempdir().unwrap();
2008        let game_path = tmp_dir.path();
2009
2010        let ini_path = game_path.join("Skyrim.ini");
2011        std::fs::write(&ini_path, "[General]\nsTestFile1=plugin.esp\n").unwrap();
2012
2013        let settings = GameSettings::with_local_and_my_games_paths(
2014            GameId::SkyrimSE,
2015            game_path,
2016            &PathBuf::default(),
2017            game_path.to_path_buf(),
2018        )
2019        .unwrap();
2020
2021        let mut expected_plugins = settings.early_loading_plugins().to_vec();
2022        expected_plugins.push("plugin.esp".to_owned());
2023
2024        assert_eq!(expected_plugins, settings.implicitly_active_plugins());
2025    }
2026
2027    #[test]
2028    fn implicitly_active_plugins_should_only_include_valid_test_files_for_fallout4() {
2029        let tmp_dir = tempdir().unwrap();
2030        let game_path = tmp_dir.path();
2031
2032        let ini_path = game_path.join("Fallout4.ini");
2033        std::fs::write(
2034            &ini_path,
2035            "[General]\nsTestFile1=plugin.esp\nsTestFile2=Blank.esp",
2036        )
2037        .unwrap();
2038
2039        copy_to_dir(
2040            "Blank.esp",
2041            &game_path.join("Data"),
2042            "Blank.esp",
2043            GameId::Fallout4,
2044        );
2045
2046        let settings = GameSettings::with_local_and_my_games_paths(
2047            GameId::Fallout4,
2048            game_path,
2049            &PathBuf::default(),
2050            game_path.to_path_buf(),
2051        )
2052        .unwrap();
2053
2054        let mut expected_plugins = settings.early_loading_plugins().to_vec();
2055        expected_plugins.push("Blank.esp".to_owned());
2056
2057        assert_eq!(expected_plugins, settings.implicitly_active_plugins());
2058    }
2059
2060    #[test]
2061    fn implicitly_active_plugins_should_only_include_valid_test_files_for_fallout4vr() {
2062        let tmp_dir = tempdir().unwrap();
2063        let game_path = tmp_dir.path();
2064
2065        let ini_path = game_path.join("Fallout4VR.ini");
2066        std::fs::write(
2067            &ini_path,
2068            "[General]\nsTestFile1=plugin.esp\nsTestFile2=Blank.esp",
2069        )
2070        .unwrap();
2071
2072        copy_to_dir(
2073            "Blank.esp",
2074            &game_path.join("Data"),
2075            "Blank.esp",
2076            GameId::Fallout4VR,
2077        );
2078
2079        let settings = GameSettings::with_local_and_my_games_paths(
2080            GameId::Fallout4VR,
2081            game_path,
2082            &PathBuf::default(),
2083            game_path.to_path_buf(),
2084        )
2085        .unwrap();
2086
2087        let mut expected_plugins = settings.early_loading_plugins().to_vec();
2088        expected_plugins.push("Blank.esp".to_owned());
2089
2090        assert_eq!(expected_plugins, settings.implicitly_active_plugins());
2091    }
2092
2093    #[test]
2094    fn implicitly_active_plugins_should_include_plugins_with_nam_files_for_fallout_nv() {
2095        let tmp_dir = tempdir().unwrap();
2096        let game_path = tmp_dir.path();
2097        let data_path = game_path.join("Data");
2098
2099        create_dir_all(&data_path).unwrap();
2100        File::create(data_path.join("plugin1.nam")).unwrap();
2101        File::create(data_path.join("plugin2.NAM")).unwrap();
2102
2103        let settings = game_with_game_path(GameId::FalloutNV, game_path);
2104        let expected_plugins = vec!["plugin1.esm", "plugin1.esp", "plugin2.esm", "plugin2.esp"];
2105        let mut plugins = settings.implicitly_active_plugins().to_vec();
2106        plugins.sort();
2107
2108        assert_eq!(expected_plugins, plugins);
2109    }
2110
2111    #[test]
2112    fn implicitly_active_plugins_should_include_update_esm_for_skyrim() {
2113        let settings = game_with_generic_paths(GameId::Skyrim);
2114        let plugins = settings.implicitly_active_plugins();
2115
2116        assert!(plugins.contains(&"Update.esm".to_owned()));
2117    }
2118
2119    #[test]
2120    fn implicitly_active_plugins_should_include_plugins_with_nam_files_for_games_other_than_fallout_nv(
2121    ) {
2122        let tmp_dir = tempdir().unwrap();
2123        let game_path = tmp_dir.path();
2124        let data_path = game_path.join("Data");
2125
2126        create_dir_all(&data_path).unwrap();
2127        File::create(data_path.join("plugin.nam")).unwrap();
2128
2129        let settings = game_with_game_path(GameId::Fallout3, game_path);
2130        assert!(settings.implicitly_active_plugins().is_empty());
2131    }
2132
2133    #[test]
2134    fn implicitly_active_plugins_should_not_include_case_insensitive_duplicates() {
2135        let tmp_dir = tempdir().unwrap();
2136        let game_path = tmp_dir.path();
2137
2138        let ini_path = game_path.join("Fallout4.ini");
2139        std::fs::write(&ini_path, "[General]\nsTestFile1=fallout4.esm\n").unwrap();
2140
2141        let settings = GameSettings::with_local_and_my_games_paths(
2142            GameId::Fallout4,
2143            game_path,
2144            &PathBuf::default(),
2145            game_path.to_path_buf(),
2146        )
2147        .unwrap();
2148
2149        assert_eq!(
2150            settings.early_loading_plugins(),
2151            settings.implicitly_active_plugins()
2152        );
2153    }
2154
2155    #[test]
2156    fn is_implicitly_active_should_return_true_iff_the_plugin_is_implicitly_active() {
2157        let settings = game_with_generic_paths(GameId::Skyrim);
2158        assert!(settings.is_implicitly_active("Update.esm"));
2159        assert!(!settings.is_implicitly_active("Test.esm"));
2160    }
2161
2162    #[test]
2163    fn is_implicitly_active_should_match_case_insensitively() {
2164        let settings = game_with_generic_paths(GameId::Skyrim);
2165        assert!(settings.is_implicitly_active("update.esm"));
2166    }
2167
2168    #[test]
2169    fn loads_early_should_return_true_iff_the_plugin_loads_early() {
2170        let settings = game_with_generic_paths(GameId::SkyrimSE);
2171        assert!(settings.loads_early("Dawnguard.esm"));
2172        assert!(!settings.loads_early("Test.esm"));
2173    }
2174
2175    #[test]
2176    fn loads_early_should_match_case_insensitively() {
2177        let settings = game_with_generic_paths(GameId::SkyrimSE);
2178        assert!(settings.loads_early("dawnguard.esm"));
2179    }
2180
2181    #[test]
2182    fn plugins_folder_should_be_a_child_of_the_game_path() {
2183        let settings = game_with_generic_paths(GameId::Skyrim);
2184        assert_eq!(Path::new("game/Data"), settings.plugins_directory());
2185    }
2186
2187    #[test]
2188    fn load_order_file_should_be_in_local_path_for_skyrim_and_none_for_other_games() {
2189        let mut settings = game_with_generic_paths(GameId::Skyrim);
2190        assert_eq!(
2191            Path::new("local/loadorder.txt"),
2192            settings.load_order_file().unwrap()
2193        );
2194
2195        settings = game_with_generic_paths(GameId::SkyrimSE);
2196        assert!(settings.load_order_file().is_none());
2197
2198        settings = game_with_generic_paths(GameId::OpenMW);
2199        assert!(settings.load_order_file().is_none());
2200
2201        settings = game_with_generic_paths(GameId::Morrowind);
2202        assert!(settings.load_order_file().is_none());
2203
2204        settings = game_with_generic_paths(GameId::Oblivion);
2205        assert!(settings.load_order_file().is_none());
2206
2207        settings = game_with_generic_paths(GameId::Fallout3);
2208        assert!(settings.load_order_file().is_none());
2209
2210        settings = game_with_generic_paths(GameId::FalloutNV);
2211        assert!(settings.load_order_file().is_none());
2212
2213        settings = game_with_generic_paths(GameId::Fallout4);
2214        assert!(settings.load_order_file().is_none());
2215    }
2216
2217    #[test]
2218    fn additional_plugins_directories_should_be_empty_if_game_is_not_fallout4_or_starfield() {
2219        let tmp_dir = tempdir().unwrap();
2220        let game_path = tmp_dir.path();
2221
2222        File::create(game_path.join("appxmanifest.xml")).unwrap();
2223
2224        let game_ids = [
2225            GameId::Morrowind,
2226            GameId::Oblivion,
2227            GameId::Skyrim,
2228            GameId::SkyrimSE,
2229            GameId::SkyrimVR,
2230            GameId::Fallout3,
2231            GameId::FalloutNV,
2232        ];
2233
2234        for game_id in game_ids {
2235            let settings = game_with_game_path(game_id, game_path);
2236
2237            assert!(settings.additional_plugins_directories().is_empty());
2238        }
2239    }
2240
2241    #[test]
2242    fn additional_plugins_directories_should_be_empty_if_fallout4_is_not_from_the_microsoft_store()
2243    {
2244        let settings = game_with_generic_paths(GameId::Fallout4);
2245
2246        assert!(settings.additional_plugins_directories().is_empty());
2247    }
2248
2249    #[test]
2250    fn additional_plugins_directories_should_not_be_empty_if_game_is_fallout4_from_the_microsoft_store(
2251    ) {
2252        let tmp_dir = tempdir().unwrap();
2253        let game_path = tmp_dir.path();
2254
2255        File::create(game_path.join("appxmanifest.xml")).unwrap();
2256
2257        let settings = game_with_game_path(GameId::Fallout4, game_path);
2258
2259        assert_eq!(
2260            vec![
2261                game_path.join(MS_FO4_AUTOMATRON_PATH),
2262                game_path.join(MS_FO4_NUKA_WORLD_PATH),
2263                game_path.join(MS_FO4_WASTELAND_PATH),
2264                game_path.join(MS_FO4_TEXTURE_PACK_PATH),
2265                game_path.join(MS_FO4_VAULT_TEC_PATH),
2266                game_path.join(MS_FO4_FAR_HARBOR_PATH),
2267                game_path.join(MS_FO4_CONTRAPTIONS_PATH),
2268            ],
2269            settings.additional_plugins_directories()
2270        );
2271    }
2272
2273    #[test]
2274    fn additional_plugins_directories_should_not_be_empty_if_game_is_starfield() {
2275        let settings = game_with_generic_paths(GameId::Starfield);
2276
2277        assert_eq!(
2278            vec![Path::new("my games").join("Data")],
2279            settings.additional_plugins_directories()
2280        );
2281    }
2282
2283    #[test]
2284    fn additional_plugins_directories_should_include_dlc_paths_if_game_is_ms_store_starfield() {
2285        let tmp_dir = tempdir().unwrap();
2286        let game_path = tmp_dir.path();
2287
2288        File::create(game_path.join("appxmanifest.xml")).unwrap();
2289
2290        let settings = game_with_game_path(GameId::Starfield, game_path);
2291
2292        assert_eq!(
2293            vec![
2294                Path::new("Data"),
2295                &game_path.join("../../Old Mars/Content/Data"),
2296                &game_path.join("../../Shattered Space/Content/Data"),
2297            ],
2298            settings.additional_plugins_directories()
2299        );
2300    }
2301
2302    #[test]
2303    fn additional_plugins_directories_should_read_from_openmw_cfgs() {
2304        let tmp_dir = tempdir().unwrap();
2305        let game_path = tmp_dir.path().join("game");
2306        let my_games_path = tmp_dir.path().join("my games");
2307        let global_cfg_path = game_path.join("openmw.cfg");
2308        let cfg_path = my_games_path.join("openmw.cfg");
2309
2310        create_dir_all(global_cfg_path.parent().unwrap()).unwrap();
2311        std::fs::write(&global_cfg_path, "config=\"../my games\"\ndata=\"foo/bar\"").unwrap();
2312
2313        create_dir_all(cfg_path.parent().unwrap()).unwrap();
2314        std::fs::write(
2315            &cfg_path,
2316            "data=\"Path\\&&&\"&a&&&&\\Data Files\"\ndata=games/path",
2317        )
2318        .unwrap();
2319
2320        let settings =
2321            GameSettings::with_local_path(GameId::OpenMW, &game_path, &my_games_path).unwrap();
2322
2323        let expected: Vec<PathBuf> = vec![
2324            game_path.join("foo/bar"),
2325            my_games_path.join("Path\\&\"a&&\\Data Files"),
2326            my_games_path.join("games/path"),
2327        ];
2328        assert_eq!(expected, settings.additional_plugins_directories());
2329    }
2330
2331    #[test]
2332    fn plugin_path_should_append_plugin_name_to_additional_plugin_directory_if_that_path_exists() {
2333        let tmp_dir = tempdir().unwrap();
2334        let other_dir = tmp_dir.path().join("other");
2335
2336        let plugin_name = "external.esp";
2337        let expected_plugin_path = other_dir.join(plugin_name);
2338
2339        let mut settings = game_with_generic_paths(GameId::Fallout4);
2340        settings.additional_plugins_directories = vec![other_dir.clone()];
2341
2342        copy_to_dir("Blank.esp", &other_dir, plugin_name, GameId::Fallout4);
2343
2344        let plugin_path = settings.plugin_path(plugin_name);
2345
2346        assert_eq!(expected_plugin_path, plugin_path);
2347    }
2348
2349    #[test]
2350    fn plugin_path_should_append_plugin_name_to_additional_plugin_directory_if_the_ghosted_path_exists(
2351    ) {
2352        let tmp_dir = tempdir().unwrap();
2353        let other_dir = tmp_dir.path().join("other");
2354
2355        let plugin_name = "external.esp";
2356        let ghosted_plugin_name = "external.esp.ghost";
2357        let expected_plugin_path = other_dir.join(ghosted_plugin_name);
2358
2359        let mut settings = game_with_generic_paths(GameId::Fallout4);
2360        settings.additional_plugins_directories = vec![other_dir.clone()];
2361
2362        copy_to_dir(
2363            "Blank.esp",
2364            &other_dir,
2365            ghosted_plugin_name,
2366            GameId::Fallout4,
2367        );
2368
2369        let plugin_path = settings.plugin_path(plugin_name);
2370
2371        assert_eq!(expected_plugin_path, plugin_path);
2372    }
2373
2374    #[test]
2375    fn plugin_path_should_not_resolve_ghosted_paths_for_openmw() {
2376        let tmp_dir = tempdir().unwrap();
2377        let game_path = tmp_dir.path().join("game");
2378        let other_dir = tmp_dir.path().join("other");
2379
2380        let plugin_name = "external.esp";
2381
2382        let mut settings = game_with_game_path(GameId::OpenMW, &game_path);
2383        settings.additional_plugins_directories = vec![other_dir.clone()];
2384
2385        copy_to_dir(
2386            "Blank.esp",
2387            &other_dir,
2388            "external.esp.ghost",
2389            GameId::OpenMW,
2390        );
2391
2392        let plugin_path = settings.plugin_path(plugin_name);
2393
2394        assert_eq!(
2395            game_path.join("resources/vfs").join(plugin_name),
2396            plugin_path
2397        );
2398    }
2399
2400    #[test]
2401    fn plugin_path_should_return_the_last_directory_that_contains_a_file_for_openmw() {
2402        let tmp_dir = tempdir().unwrap();
2403        let other_dir_1 = tmp_dir.path().join("other1");
2404        let other_dir_2 = tmp_dir.path().join("other2");
2405
2406        let plugin_name = "Blank.esp";
2407
2408        let mut settings = game_with_game_path(GameId::OpenMW, tmp_dir.path());
2409        settings.additional_plugins_directories = vec![other_dir_1.clone(), other_dir_2.clone()];
2410
2411        copy_to_dir("Blank.esp", &other_dir_1, plugin_name, GameId::OpenMW);
2412        copy_to_dir("Blank.esp", &other_dir_2, plugin_name, GameId::OpenMW);
2413
2414        let plugin_path = settings.plugin_path(plugin_name);
2415
2416        assert_eq!(other_dir_2.join(plugin_name), plugin_path);
2417    }
2418
2419    #[test]
2420    fn plugin_path_should_return_plugins_dir_subpath_if_name_does_not_match_any_external_plugin() {
2421        let settings = game_with_generic_paths(GameId::Fallout4);
2422
2423        let plugin_name = "DLCCoast.esm";
2424        assert_eq!(
2425            settings.plugins_directory().join(plugin_name),
2426            settings.plugin_path(plugin_name)
2427        );
2428    }
2429
2430    #[test]
2431    fn plugin_path_should_only_resolve_additional_starfield_plugin_paths_if_they_exist_or_are_ghosted_in_the_plugins_directory(
2432    ) {
2433        let tmp_dir = tempdir().unwrap();
2434        let game_path = tmp_dir.path().join("game");
2435        let data_path = game_path.join("Data");
2436        let other_dir = tmp_dir.path().join("other");
2437
2438        let plugin_name_1 = "external1.esp";
2439        let plugin_name_2 = "external2.esp";
2440        let plugin_name_3 = "external3.esp";
2441        let ghosted_plugin_name_3 = "external3.esp.ghost";
2442
2443        let mut settings = game_with_game_path(GameId::Starfield, &game_path);
2444        settings.additional_plugins_directories = vec![other_dir.clone()];
2445
2446        copy_to_dir("Blank.esp", &other_dir, plugin_name_1, GameId::Starfield);
2447        copy_to_dir("Blank.esp", &other_dir, plugin_name_2, GameId::Starfield);
2448        copy_to_dir("Blank.esp", &data_path, plugin_name_2, GameId::Starfield);
2449        copy_to_dir("Blank.esp", &other_dir, plugin_name_3, GameId::Starfield);
2450        copy_to_dir(
2451            "Blank.esp",
2452            &data_path,
2453            ghosted_plugin_name_3,
2454            GameId::Starfield,
2455        );
2456
2457        let plugin_1_path = settings.plugin_path(plugin_name_1);
2458        let plugin_2_path = settings.plugin_path(plugin_name_2);
2459        let plugin_3_path = settings.plugin_path(plugin_name_3);
2460
2461        assert_eq!(data_path.join(plugin_name_1), plugin_1_path);
2462        assert_eq!(other_dir.join(plugin_name_2), plugin_2_path);
2463        assert_eq!(other_dir.join(plugin_name_3), plugin_3_path);
2464    }
2465
2466    #[test]
2467    fn refresh_implicitly_active_plugins_should_update_early_loading_and_implicitly_active_plugins()
2468    {
2469        let tmp_dir = tempdir().unwrap();
2470        let game_path = tmp_dir.path();
2471
2472        let mut settings = GameSettings::with_local_and_my_games_paths(
2473            GameId::SkyrimSE,
2474            game_path,
2475            &PathBuf::default(),
2476            game_path.to_path_buf(),
2477        )
2478        .unwrap();
2479
2480        let hardcoded_plugins = vec![
2481            "Skyrim.esm",
2482            "Update.esm",
2483            "Dawnguard.esm",
2484            "HearthFires.esm",
2485            "Dragonborn.esm",
2486        ];
2487        assert_eq!(hardcoded_plugins, settings.early_loading_plugins());
2488        assert_eq!(hardcoded_plugins, settings.implicitly_active_plugins());
2489
2490        std::fs::write(game_path.join("Skyrim.ccc"), "ccBGSSSE002-ExoticArrows.esl").unwrap();
2491        std::fs::write(
2492            game_path.join("Skyrim.ini"),
2493            "[General]\nsTestFile1=plugin.esp\n",
2494        )
2495        .unwrap();
2496
2497        settings.refresh_implicitly_active_plugins().unwrap();
2498
2499        let mut expected_plugins = hardcoded_plugins;
2500        expected_plugins.push("ccBGSSSE002-ExoticArrows.esl");
2501        assert_eq!(expected_plugins, settings.early_loading_plugins());
2502
2503        expected_plugins.push("plugin.esp");
2504        assert_eq!(expected_plugins, settings.implicitly_active_plugins());
2505    }
2506
2507    #[test]
2508    fn get_target_modified_timestamp_should_return_the_modified_timestamp_of_a_symlinks_target_file(
2509    ) {
2510        let tmp_dir = tempdir().unwrap();
2511        let file_path = tmp_dir.path().join("file");
2512        let symlink_path = tmp_dir.path().join("symlink");
2513
2514        std::fs::File::create(&file_path).unwrap();
2515
2516        symlink_file(&file_path, &symlink_path);
2517
2518        let symlink_timestamp = symlink_path.symlink_metadata().unwrap().modified().unwrap();
2519        let file_timestamp = symlink_timestamp - std::time::Duration::from_secs(1);
2520        assert_ne!(symlink_timestamp, file_timestamp);
2521        let file = File::options().append(true).open(file_path).unwrap();
2522        file.set_modified(file_timestamp).unwrap();
2523
2524        let mut dir_entries = tmp_dir
2525            .path()
2526            .read_dir()
2527            .unwrap()
2528            .collect::<Result<Vec<_>, _>>()
2529            .unwrap();
2530
2531        dir_entries.sort_by_key(DirEntry::file_name);
2532
2533        assert!(dir_entries[0].file_type().unwrap().is_file());
2534        assert_eq!(
2535            file_timestamp,
2536            get_target_modified_timestamp(&dir_entries[0]).unwrap()
2537        );
2538
2539        assert!(dir_entries[1].file_type().unwrap().is_symlink());
2540        assert_eq!(
2541            file_timestamp,
2542            get_target_modified_timestamp(&dir_entries[1]).unwrap()
2543        );
2544    }
2545
2546    #[test]
2547    fn find_plugins_in_directories_should_sort_files_by_modification_timestamp() {
2548        let tmp_dir = tempdir().unwrap();
2549        let game_path = tmp_dir.path();
2550
2551        let plugin_names = [
2552            "Blank.esp",
2553            "Blank - Different.esp",
2554            "Blank - Master Dependent.esp",
2555            NON_ASCII,
2556        ];
2557
2558        copy_to_dir("Blank.esp", game_path, NON_ASCII, GameId::Oblivion);
2559
2560        for (i, plugin_name) in plugin_names.iter().enumerate() {
2561            let path = game_path.join(plugin_name);
2562            if !path.exists() {
2563                copy_to_dir(plugin_name, game_path, plugin_name, GameId::Oblivion);
2564            }
2565            set_file_timestamps(&path, i.try_into().unwrap());
2566        }
2567
2568        let result = find_plugins_in_directories(once(&game_path.to_path_buf()), GameId::Oblivion);
2569
2570        let expected: Vec<_> = plugin_names.iter().map(|n| game_path.join(n)).collect();
2571
2572        assert_eq!(expected, result);
2573    }
2574
2575    #[test]
2576    fn find_plugins_in_directories_should_sort_files_by_descending_uppercased_filename_if_timestamps_are_equal(
2577    ) {
2578        let tmp_dir = tempdir().unwrap();
2579        let game_path = tmp_dir.path();
2580
2581        let non_ascii = NON_ASCII;
2582        let plugin_names = [
2583            "Blank.esm",
2584            "Blank.esp",
2585            "Blank - Different.esp",
2586            "Blank - Master Dependent.esp",
2587            non_ascii,
2588        ];
2589
2590        copy_to_dir("Blank.esp", game_path, non_ascii, GameId::Oblivion);
2591
2592        for (i, plugin_name) in plugin_names.iter().enumerate() {
2593            let path = game_path.join(plugin_name);
2594            if !path.exists() {
2595                copy_to_dir(plugin_name, game_path, plugin_name, GameId::Oblivion);
2596            }
2597            set_file_timestamps(&path, i.try_into().unwrap());
2598        }
2599
2600        let timestamp = 3;
2601        set_file_timestamps(&game_path.join("Blank - Different.esp"), timestamp);
2602        set_file_timestamps(&game_path.join("Blank - Master Dependent.esp"), timestamp);
2603
2604        copy_to_dir("Blank.esp", game_path, "a.esp", GameId::Oblivion);
2605        set_file_timestamps(&game_path.join("a.esp"), timestamp);
2606
2607        let result = find_plugins_in_directories(once(&game_path.to_path_buf()), GameId::Oblivion);
2608
2609        let plugin_paths = vec![
2610            game_path.join("Blank.esm"),
2611            game_path.join("Blank.esp"),
2612            game_path.join("Blank - Master Dependent.esp"),
2613            game_path.join("Blank - Different.esp"),
2614            game_path.join("a.esp"),
2615            game_path.join(non_ascii),
2616        ];
2617
2618        assert_eq!(plugin_paths, result);
2619    }
2620
2621    #[test]
2622    fn find_plugins_in_directories_should_sort_files_by_descending_uppercased_filename_if_timestamps_are_equal_and_game_is_starfield(
2623    ) {
2624        let tmp_dir = tempdir().unwrap();
2625        let game_path = tmp_dir.path();
2626
2627        let plugin_names = [
2628            "Blank.full.esm",
2629            "Blank.small.esm",
2630            "Blank.medium.esm",
2631            "Blank.esp",
2632            "Blank - Override.esp",
2633            "a.esp",
2634        ];
2635
2636        let timestamp = 1_321_009_991;
2637
2638        for plugin_name in &plugin_names[..plugin_names.len() - 1] {
2639            let path = game_path.join(plugin_name);
2640            if !path.exists() {
2641                copy_to_dir(plugin_name, game_path, plugin_name, GameId::Starfield);
2642            }
2643            set_file_timestamps(&path, timestamp);
2644        }
2645        copy_to_dir(
2646            "Blank.esp",
2647            game_path,
2648            plugin_names.last().unwrap(),
2649            GameId::Starfield,
2650        );
2651
2652        let result = find_plugins_in_directories(once(&game_path.to_path_buf()), GameId::Starfield);
2653
2654        let plugin_paths = vec![
2655            game_path.join("Blank.small.esm"),
2656            game_path.join("Blank.medium.esm"),
2657            game_path.join("Blank.full.esm"),
2658            game_path.join("Blank.esp"),
2659            game_path.join("Blank - Override.esp"),
2660            game_path.join("a.esp"),
2661        ];
2662
2663        assert_eq!(plugin_paths, result);
2664    }
2665
2666    #[test]
2667    fn find_plugins_in_directories_should_find_symlinks_to_plugins() {
2668        const BLANK_ESM: &str = "Blank.esm";
2669        const BLANK_ESP: &str = "Blank.esp";
2670
2671        let tmp_dir = tempdir().unwrap();
2672        let data_path = tmp_dir.path().join("game");
2673        let other_path = tmp_dir.path().join("other");
2674
2675        copy_to_dir(BLANK_ESM, &data_path, BLANK_ESM, GameId::OpenMW);
2676        copy_to_dir(BLANK_ESP, &other_path, BLANK_ESP, GameId::OpenMW);
2677
2678        symlink_file(&other_path.join(BLANK_ESP), &data_path.join(BLANK_ESP));
2679
2680        let result = find_plugins_in_directories(once(&data_path), GameId::OpenMW);
2681
2682        let plugin_paths = vec![data_path.join(BLANK_ESM), data_path.join(BLANK_ESP)];
2683
2684        assert_eq!(plugin_paths, result);
2685    }
2686}