Skip to main content

loadorder/
game_settings.rs

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