• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Ortham / libloadorder / 23689330707

28 Mar 2026 04:16PM UTC coverage: 92.975% (+0.2%) from 92.789%
23689330707

push

github

Ortham
Deactivate BlueprintShips plugins when their base plugin is deactivated

When deactivating a plugin, check if there's a corresponding BlueprintShips plugin that's not explicitly active and which isn't implicitly active for any other reason (e.g. game config, another base plugin with a different file extension). If found, deactivate that BlueprintShips plugin.

100 of 100 new or added lines in 1 file covered. (100.0%)

56 existing lines in 7 files now uncovered.

9715 of 10449 relevant lines covered (92.98%)

3134320.06 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

99.33
/src/plugin.rs
1
use std::ffi::OsStr;
2
/*
3
 * This file is part of libloadorder
4
 *
5
 * Copyright (C) 2017 Oliver Hamlet
6
 *
7
 * libloadorder is free software: you can redistribute it and/or modify
8
 * it under the terms of the GNU General Public License as published by
9
 * the Free Software Foundation, either version 3 of the License, or
10
 * (at your option) any later version.
11
 *
12
 * libloadorder is distributed in the hope that it will be useful,
13
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
14
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15
 * GNU General Public License for more details.
16
 *
17
 * You should have received a copy of the GNU General Public License
18
 * along with libloadorder. If not, see <http://www.gnu.org/licenses/>.
19
 */
20
use std::fs::{File, FileTimes};
21
use std::path::Path;
22
use std::time::SystemTime;
23

24
use esplugin::ParseOptions;
25
use unicase::eq;
26

27
use crate::enums::{Error, GameId};
28
use crate::game_settings::GameSettings;
29

30
const VALID_EXTENSIONS: &[&str] = &[".esp", ".esm", ".esp.ghost", ".esm.ghost"];
31

32
const VALID_EXTENSIONS_WITH_ESL: &[&str] = &[
33
    ".esp",
34
    ".esm",
35
    ".esp.ghost",
36
    ".esm.ghost",
37
    ".esl",
38
    ".esl.ghost",
39
];
40

41
const VALID_EXTENSIONS_OPENMW: &[&str] = &[".esp", ".esm", ".omwaddon", ".omwgame", ".omwscripts"];
42

43
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
44
pub enum ActiveState {
45
    Inactive,
46
    ImplicitlyActive,
47
    ExplicitlyActive,
48
}
49

50
impl ActiveState {
51
    fn is_active(self) -> bool {
3,045,601✔
52
        !matches!(self, Self::Inactive)
3,045,601✔
53
    }
3,045,601✔
54
}
55

56
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
57
pub struct Plugin {
58
    active: ActiveState,
59
    modification_time: SystemTime,
60
    data: esplugin::Plugin,
61
    name: String,
62
    game_id: GameId,
63
}
64

65
impl Plugin {
66
    pub fn new(filename: &str, game_settings: &GameSettings) -> Result<Plugin, Error> {
40,914✔
67
        Plugin::with_active(filename, game_settings, ActiveState::Inactive)
40,914✔
68
    }
40,914✔
69

70
    pub fn with_active(
42,225✔
71
        filename: &str,
42,225✔
72
        game_settings: &GameSettings,
42,225✔
73
        active: ActiveState,
42,225✔
74
    ) -> Result<Plugin, Error> {
42,225✔
75
        let filepath = game_settings.plugin_path(filename);
42,225✔
76

77
        let filepath = if game_settings.id().allow_plugin_ghosting() {
42,225✔
78
            use crate::ghostable_path::GhostablePath;
79

80
            if active.is_active() {
41,581✔
81
                filepath.unghost()?
849✔
82
            } else {
83
                filepath.resolve_path()?
40,732✔
84
            }
85
        } else {
86
            filepath
644✔
87
        };
88

89
        Plugin::with_path(&filepath, game_settings.id(), active)
42,213✔
90
    }
42,225✔
91

92
    pub(crate) fn with_path(
42,253✔
93
        path: &Path,
42,253✔
94
        game_id: GameId,
42,253✔
95
        active: ActiveState,
42,253✔
96
    ) -> Result<Plugin, Error> {
42,253✔
97
        let Some(filename) = path.file_name().and_then(OsStr::to_str) else {
42,253✔
UNCOV
98
            return Err(Error::NoFilename(path.to_path_buf()));
×
99
        };
100

101
        if !has_plugin_extension(filename, game_id) {
42,253✔
102
            return Err(Error::InvalidPath(path.to_path_buf()));
2✔
103
        }
42,251✔
104

105
        let file = File::open(path).map_err(|e| Error::IoError(path.to_path_buf(), e))?;
42,251✔
106
        let modification_time = file
41,912✔
107
            .metadata()
41,912✔
108
            .and_then(|m| m.modified())
41,912✔
109
            .map_err(|e| Error::IoError(path.to_path_buf(), e))?;
41,912✔
110

111
        let mut data = esplugin::Plugin::new(game_id.to_esplugin_id(), path);
41,912✔
112

113
        // OpenMW has .omwscripts plugins that form part of the load order but
114
        // are not of the same file format as the .esm/.esp/.omwgame/.omwaddon
115
        // files.
116
        if !iends_with_ascii(filename, ".omwscripts") {
41,912✔
117
            data.parse_reader(file, ParseOptions::header_only())
41,896✔
118
                .map_err(|e| file_error(path, e))?;
41,896✔
119
        }
16✔
120

121
        Ok(Plugin {
41,900✔
122
            active,
41,900✔
123
            modification_time,
41,900✔
124
            data,
41,900✔
125
            name: trim_dot_ghost(filename, game_id).to_owned(),
41,900✔
126
            game_id,
41,900✔
127
        })
41,900✔
128
    }
42,253✔
129

130
    pub fn name(&self) -> &str {
945,271,544✔
131
        &self.name
945,271,544✔
132
    }
945,271,544✔
133

134
    pub fn name_without_extension(&self) -> &str {
26✔
135
        self.name
26✔
136
            .rfind('.')
26✔
137
            .and_then(|i| self.name.get(..i))
26✔
138
            .unwrap_or_default()
26✔
139
    }
26✔
140

141
    pub fn name_matches(&self, string: &str) -> bool {
944,909,876✔
142
        eq(self.name(), trim_dot_ghost(string, self.game_id))
944,909,876✔
143
    }
944,909,876✔
144

145
    pub fn modification_time(&self) -> SystemTime {
300✔
146
        self.modification_time
300✔
147
    }
300✔
148

149
    pub fn is_active(&self) -> bool {
3,004,014✔
150
        self.active.is_active()
3,004,014✔
151
    }
3,004,014✔
152

153
    pub fn is_explicitly_active(&self) -> bool {
18✔
154
        self.active == ActiveState::ExplicitlyActive
18✔
155
    }
18✔
156

157
    pub fn is_master_file(&self) -> bool {
173,936,864✔
158
        self.game_id.treats_master_files_differently() && self.data.is_master_file()
173,936,864✔
159
    }
173,936,864✔
160

161
    pub fn is_light_plugin(&self) -> bool {
1,079,040✔
162
        self.data.is_light_plugin()
1,079,040✔
163
    }
1,079,040✔
164

165
    pub fn is_medium_plugin(&self) -> bool {
1,002,204✔
166
        self.data.is_medium_plugin()
1,002,204✔
167
    }
1,002,204✔
168

169
    pub fn is_blueprint_plugin(&self) -> bool {
1,562,288,278✔
170
        self.data.is_blueprint_plugin()
1,562,288,278✔
171
    }
1,562,288,278✔
172

173
    pub fn is_blueprint_master(&self) -> bool {
1,562,288,256✔
174
        self.is_blueprint_plugin() && self.is_master_file()
1,562,288,256✔
175
    }
1,562,288,256✔
176

177
    pub fn masters(&self) -> Result<Vec<String>, Error> {
86,874,775✔
178
        self.data
86,874,775✔
179
            .masters()
86,874,775✔
180
            .map_err(|e| file_error(self.data.path(), e))
86,874,775✔
181
    }
86,874,775✔
182

183
    pub fn has_master(&self, master: &str) -> bool {
208✔
184
        self.masters()
208✔
185
            .unwrap_or_default()
208✔
186
            .iter()
208✔
187
            .any(|m| eq(m.as_str(), master))
208✔
188
    }
208✔
189

190
    pub fn set_modification_time(&mut self, time: SystemTime) -> Result<(), Error> {
74✔
191
        // Always write the file time. This has a huge performance impact, but
192
        // is important for correctness, as otherwise external changes to plugin
193
        // timestamps between calls to WritableLoadOrder::load() and
194
        // WritableLoadOrder::save() could lead to libloadorder not setting all
195
        // the timestamps it needs to and producing an incorrect load order.
196
        let times = FileTimes::new()
74✔
197
            .set_accessed(SystemTime::now())
74✔
198
            .set_modified(time);
74✔
199

200
        File::options()
74✔
201
            .write(true)
74✔
202
            .open(self.data.path())
74✔
203
            .and_then(|f| f.set_times(times))
74✔
204
            .map_err(|e| Error::IoError(self.data.path().to_path_buf(), e))?;
74✔
205

206
        self.modification_time = time;
74✔
207
        Ok(())
74✔
208
    }
74✔
209

210
    pub fn activate(&mut self) -> Result<(), Error> {
24,634✔
211
        // A plugin only needs to be un-ghosted if it's currently inactive.
212
        if !self.is_active() && self.game_id.allow_plugin_ghosting() {
24,634✔
213
            use crate::ghostable_path::GhostablePath;
214

215
            if self.data.path().has_ghost_extension() {
24,106✔
216
                let new_path = self.data.path().unghost()?;
4✔
217

218
                self.data = esplugin::Plugin::new(self.data.game_id(), &new_path);
4✔
219
                self.data
4✔
220
                    .parse_file(ParseOptions::header_only())
4✔
221
                    .map_err(|e| file_error(self.data.path(), e))?;
4✔
222
                let modification_time = self.modification_time();
4✔
223
                self.set_modification_time(modification_time)?;
4✔
224
            }
24,102✔
225
        }
528✔
226

227
        self.active = ActiveState::ExplicitlyActive;
24,634✔
228
        Ok(())
24,634✔
229
    }
24,634✔
230

231
    pub(crate) fn implicitly_activate(&mut self) -> Result<(), Error> {
34✔
232
        let was_inactive = self.active == ActiveState::Inactive;
34✔
233

234
        self.activate()?;
34✔
235

236
        if was_inactive {
34✔
237
            self.active = ActiveState::ImplicitlyActive;
32✔
238
        }
32✔
239

240
        Ok(())
34✔
241
    }
34✔
242

243
    /// This should only be called after checking that the plugin isn't
244
    /// considered implicitly active.
245
    pub fn deactivate(&mut self) {
23,914✔
246
        self.active = ActiveState::Inactive;
23,914✔
247
    }
23,914✔
248
}
249

250
pub(crate) fn has_plugin_extension(filename: &str, game: GameId) -> bool {
43,081✔
251
    let valid_extensions = if game == GameId::OpenMW {
43,081✔
252
        VALID_EXTENSIONS_OPENMW
752✔
253
    } else if game.supports_light_plugins() {
42,329✔
254
        VALID_EXTENSIONS_WITH_ESL
39,818✔
255
    } else {
256
        VALID_EXTENSIONS
2,511✔
257
    };
258

259
    valid_extensions
43,081✔
260
        .iter()
43,081✔
261
        .any(|e| iends_with_ascii(filename, e))
84,557✔
262
}
43,081✔
263

264
pub(crate) fn iends_with_ascii(string: &str, suffix: &str) -> bool {
126,541✔
265
    // as_bytes().into_iter() is faster than bytes().
266
    string.len() >= suffix.len()
126,541✔
267
        && string
125,602✔
268
            .as_bytes()
125,602✔
269
            .iter()
125,602✔
270
            .rev()
125,602✔
271
            .zip(suffix.as_bytes().iter().rev())
125,602✔
272
            .all(|(string_byte, suffix_byte)| string_byte.eq_ignore_ascii_case(suffix_byte))
256,199✔
273
}
126,541✔
274

275
pub(crate) fn trim_dot_ghost(string: &str, game_id: GameId) -> &str {
944,952,544✔
276
    if game_id.allow_plugin_ghosting() {
944,952,544✔
277
        trim_dot_ghost_unchecked(string)
944,748,274✔
278
    } else {
279
        string
204,270✔
280
    }
281
}
944,952,544✔
282

283
pub(crate) fn trim_dot_ghost_unchecked(string: &str) -> &str {
944,748,442✔
284
    use crate::ghostable_path::GHOST_FILE_EXTENSION;
285

286
    let suffix_start_index = string.len().saturating_sub(GHOST_FILE_EXTENSION.len());
944,748,442✔
287
    if let Some((first, last)) = string.split_at_checked(suffix_start_index) {
944,748,442✔
288
        if last.eq_ignore_ascii_case(GHOST_FILE_EXTENSION) {
944,748,026✔
289
            first
49✔
290
        } else {
291
            string
944,747,977✔
292
        }
293
    } else {
294
        string
416✔
295
    }
296
}
944,748,442✔
297

298
fn file_error(file_path: &Path, error: esplugin::Error) -> Error {
12✔
299
    match error {
12✔
300
        esplugin::Error::IoError(x) => Error::IoError(file_path.to_path_buf(), x),
12✔
UNCOV
301
        esplugin::Error::NoFilename(_) => Error::NoFilename(file_path.to_path_buf()),
×
UNCOV
302
        e => Error::PluginParsingError(file_path.to_path_buf(), Box::new(e)),
×
303
    }
304
}
12✔
305

306
#[cfg(test)]
307
mod tests {
308
    use super::*;
309

310
    use crate::tests::{copy_to_dir, copy_to_test_dir, create_file, symlink_file};
311
    use std::path::PathBuf;
312
    use std::time::{Duration, UNIX_EPOCH};
313
    use tempfile::tempdir;
314

315
    fn game_settings(game_id: GameId, game_path: &Path) -> GameSettings {
50✔
316
        GameSettings::with_local_and_my_games_paths(
50✔
317
            game_id,
50✔
318
            game_path,
50✔
319
            &PathBuf::default(),
50✔
320
            PathBuf::default(),
50✔
321
        )
322
        .unwrap()
50✔
323
    }
50✔
324

325
    #[test]
326
    fn active_state_is_active_should_be_false_for_inactive_only() {
2✔
327
        assert!(ActiveState::ExplicitlyActive.is_active());
2✔
328
        assert!(ActiveState::ImplicitlyActive.is_active());
2✔
329
        assert!(!ActiveState::Inactive.is_active());
2✔
330
    }
2✔
331

332
    #[test]
333
    fn with_active_should_unghost_active_ghosted_plugin_paths() {
2✔
334
        let tmp_dir = tempdir().unwrap();
2✔
335
        let game_dir = tmp_dir.path();
2✔
336

337
        let settings = game_settings(GameId::Oblivion, game_dir);
2✔
338

339
        let name = "Blank.esp";
2✔
340
        let ghosted_name = "Blank.esp.ghost";
2✔
341

342
        copy_to_test_dir(name, ghosted_name, &settings);
2✔
343
        let plugin =
2✔
344
            Plugin::with_active(ghosted_name, &settings, ActiveState::ExplicitlyActive).unwrap();
2✔
345

346
        assert_eq!(name, plugin.name());
2✔
347
        assert!(game_dir.join("Data").join(name).exists());
2✔
348
        assert!(!game_dir.join("Data").join(ghosted_name).exists());
2✔
349
    }
2✔
350

351
    #[test]
352
    fn with_active_should_resolve_inactive_ghosted_plugin_paths() {
2✔
353
        let tmp_dir = tempdir().unwrap();
2✔
354
        let game_dir = tmp_dir.path();
2✔
355

356
        let settings = game_settings(GameId::Oblivion, game_dir);
2✔
357

358
        let name = "Blank.esp";
2✔
359
        let ghosted_name = "Blank.esp.ghost";
2✔
360

361
        copy_to_test_dir(name, ghosted_name, &settings);
2✔
362
        let plugin = Plugin::with_active(ghosted_name, &settings, ActiveState::Inactive).unwrap();
2✔
363

364
        assert_eq!(name, plugin.name());
2✔
365
        assert!(!game_dir.join("Data").join(name).exists());
2✔
366
        assert!(game_dir.join("Data").join(ghosted_name).exists());
2✔
367
    }
2✔
368

369
    #[test]
370
    fn with_active_should_not_resolve_ghosted_plugin_paths_for_openmw() {
2✔
371
        let tmp_dir = tempdir().unwrap();
2✔
372
        let game_dir = tmp_dir.path();
2✔
373

374
        let settings = game_settings(GameId::OpenMW, game_dir);
2✔
375

376
        let name = "Blank.esp";
2✔
377
        let ghosted_name = "Blank.esp.ghost";
2✔
378

379
        copy_to_test_dir(name, ghosted_name, &settings);
2✔
380
        match Plugin::with_active(ghosted_name, &settings, ActiveState::Inactive).unwrap_err() {
2✔
381
            Error::InvalidPath(p) => {
2✔
382
                assert_eq!(game_dir.join("resources/vfs").join(ghosted_name), p);
2✔
383
            }
UNCOV
384
            e => panic!("Expected invalid path error, got {e:?}"),
×
385
        }
386
    }
2✔
387

388
    #[test]
389
    fn with_path_should_load_symlinked_plugins() {
2✔
390
        let tmp_dir = tempdir().unwrap();
2✔
391
        let data_path = tmp_dir.path();
2✔
392

393
        let name = "Blank - Master Dependent - symlinked.esm";
2✔
394
        let symlink_name = "Blank - Master Dependent - symlink.esm";
2✔
395
        copy_to_dir(
2✔
396
            "Blank - Master Dependent.esm",
2✔
397
            data_path,
2✔
398
            name,
2✔
399
            GameId::OpenMW,
2✔
400
        );
401

402
        let file_path = data_path.join(name);
2✔
403
        let symlink_path = data_path.join(symlink_name);
2✔
404
        symlink_file(&file_path, &symlink_path);
2✔
405

406
        let symlink_timestamp = symlink_path.symlink_metadata().unwrap().modified().unwrap();
2✔
407
        let file_timestamp = symlink_timestamp - Duration::from_secs(1);
2✔
408
        assert_ne!(symlink_timestamp, file_timestamp);
2✔
409
        let file = File::options().append(true).open(file_path).unwrap();
2✔
410
        file.set_modified(file_timestamp).unwrap();
2✔
411

412
        let plugin =
2✔
413
            Plugin::with_path(&symlink_path, GameId::OpenMW, ActiveState::Inactive).unwrap();
2✔
414

415
        assert_eq!(symlink_name, plugin.name());
2✔
416
        assert!(!plugin.is_master_file());
2✔
417
        assert_eq!(vec!["Blank.esm"], plugin.masters().unwrap());
2✔
418
        assert_eq!(file_timestamp, plugin.modification_time());
2✔
419
    }
2✔
420

421
    #[test]
422
    fn name_should_return_the_plugin_filename_without_any_ghost_extension() {
2✔
423
        let tmp_dir = tempdir().unwrap();
2✔
424
        let game_dir = tmp_dir.path();
2✔
425

426
        let settings = game_settings(GameId::Oblivion, game_dir);
2✔
427

428
        copy_to_test_dir("Blank.esp", "Blank.esp", &settings);
2✔
429
        let plugin = Plugin::new("Blank.esp.ghost", &settings).unwrap();
2✔
430
        assert_eq!("Blank.esp", plugin.name());
2✔
431

432
        copy_to_test_dir("Blank.esp", "Blank.esp", &settings);
2✔
433
        let plugin = Plugin::new("Blank.esp", &settings).unwrap();
2✔
434
        assert_eq!("Blank.esp", plugin.name());
2✔
435

436
        copy_to_test_dir("Blank.esm", "Blank.esm.ghost", &settings);
2✔
437
        let plugin = Plugin::new("Blank.esm", &settings).unwrap();
2✔
438
        assert_eq!("Blank.esm", plugin.name());
2✔
439
    }
2✔
440

441
    #[test]
442
    fn name_matches_should_ignore_plugin_ghost_extension() {
2✔
443
        let tmp_dir = tempdir().unwrap();
2✔
444
        let settings = game_settings(GameId::Skyrim, tmp_dir.path());
2✔
445
        copy_to_test_dir("Blank.esp", "BlanK.esp.GHoSt", &settings);
2✔
446

447
        let plugin = Plugin::new("BlanK.esp.GHoSt", &settings).unwrap();
2✔
448
        assert!(plugin.name_matches("Blank.esp"));
2✔
449
    }
2✔
450

451
    #[test]
452
    fn name_matches_should_ignore_string_ghost_suffix() {
2✔
453
        let tmp_dir = tempdir().unwrap();
2✔
454
        let settings = game_settings(GameId::Skyrim, tmp_dir.path());
2✔
455
        copy_to_test_dir("Blank.esp", "BlanK.esp", &settings);
2✔
456

457
        let plugin = Plugin::new("BlanK.esp", &settings).unwrap();
2✔
458
        assert!(plugin.name_matches("Blank.esp.GHoSt"));
2✔
459
    }
2✔
460

461
    #[test]
462
    fn modification_time_should_return_the_plugin_modification_time_at_creation() {
2✔
463
        let tmp_dir = tempdir().unwrap();
2✔
464
        let game_dir = tmp_dir.path();
2✔
465

466
        let settings = game_settings(GameId::Oblivion, game_dir);
2✔
467

468
        copy_to_test_dir("Blank.esp", "Blank.esp", &settings);
2✔
469
        let plugin_path = game_dir.join("Data").join("Blank.esp");
2✔
470
        let mtime = plugin_path.metadata().unwrap().modified().unwrap();
2✔
471

472
        let plugin = Plugin::new("Blank.esp", &settings).unwrap();
2✔
473
        assert_eq!(mtime, plugin.modification_time());
2✔
474
    }
2✔
475

476
    #[test]
477
    fn is_active_should_be_false() {
2✔
478
        let tmp_dir = tempdir().unwrap();
2✔
479
        let game_dir = tmp_dir.path();
2✔
480

481
        let settings = game_settings(GameId::Oblivion, game_dir);
2✔
482

483
        copy_to_test_dir("Blank.esp", "Blank.esp", &settings);
2✔
484
        let plugin = Plugin::new("Blank.esp", &settings).unwrap();
2✔
485

486
        assert!(!plugin.is_active());
2✔
487
    }
2✔
488

489
    #[test]
490
    fn is_master_file_should_be_true_if_the_plugin_is_a_master_file() {
2✔
491
        let tmp_dir = tempdir().unwrap();
2✔
492
        let game_dir = tmp_dir.path();
2✔
493

494
        let settings = game_settings(GameId::Oblivion, game_dir);
2✔
495

496
        copy_to_test_dir("Blank.esm", "Blank.esm", &settings);
2✔
497
        let plugin = Plugin::new("Blank.esm", &settings).unwrap();
2✔
498

499
        assert!(plugin.is_master_file());
2✔
500
    }
2✔
501

502
    #[test]
503
    fn is_master_file_should_be_false_if_the_plugin_is_not_a_master_file() {
2✔
504
        let tmp_dir = tempdir().unwrap();
2✔
505
        let game_dir = tmp_dir.path();
2✔
506

507
        let settings = game_settings(GameId::Oblivion, game_dir);
2✔
508

509
        copy_to_test_dir("Blank.esp", "Blank.esp", &settings);
2✔
510
        let plugin = Plugin::new("Blank.esp", &settings).unwrap();
2✔
511

512
        assert!(!plugin.is_master_file());
2✔
513
    }
2✔
514

515
    #[test]
516
    fn is_master_file_should_be_false_for_all_openmw_plugins() {
2✔
517
        let tmp_dir = tempdir().unwrap();
2✔
518
        let game_dir = tmp_dir.path();
2✔
519

520
        let settings = game_settings(GameId::OpenMW, game_dir);
2✔
521

522
        let name = "plugin.omwscripts";
2✔
523
        create_file(&settings.plugins_directory().join(name));
2✔
524
        let plugin = Plugin::new(name, &settings).unwrap();
2✔
525

526
        assert!(!plugin.is_master_file());
2✔
527

528
        copy_to_test_dir("Blank.esp", "Blank.esp", &settings);
2✔
529
        let plugin = Plugin::new("Blank.esp", &settings).unwrap();
2✔
530

531
        assert!(!plugin.is_master_file());
2✔
532

533
        copy_to_test_dir("Blank.esm", "Blank.esm", &settings);
2✔
534
        let plugin = Plugin::new("Blank.esm", &settings).unwrap();
2✔
535

536
        assert!(!plugin.is_master_file());
2✔
537
    }
2✔
538

539
    #[test]
540
    fn is_master_file_should_be_false_for_all_oblivion_remastered_plugins() {
2✔
541
        let tmp_dir = tempdir().unwrap();
2✔
542
        let game_dir = tmp_dir.path();
2✔
543

544
        let settings = game_settings(GameId::OblivionRemastered, game_dir);
2✔
545

546
        copy_to_test_dir("Blank.esp", "Blank.esp", &settings);
2✔
547
        let plugin = Plugin::new("Blank.esp", &settings).unwrap();
2✔
548

549
        assert!(!plugin.is_master_file());
2✔
550

551
        copy_to_test_dir("Blank.esm", "Blank.esm", &settings);
2✔
552
        let plugin = Plugin::new("Blank.esm", &settings).unwrap();
2✔
553

554
        assert!(!plugin.is_master_file());
2✔
555
    }
2✔
556

557
    #[test]
558
    fn is_light_plugin_should_be_true_for_esl_files_only() {
2✔
559
        let tmp_dir = tempdir().unwrap();
2✔
560
        let game_dir = tmp_dir.path();
2✔
561

562
        let settings = game_settings(GameId::SkyrimSE, game_dir);
2✔
563

564
        copy_to_test_dir("Blank.esp", "Blank.esp", &settings);
2✔
565
        let plugin = Plugin::new("Blank.esp", &settings).unwrap();
2✔
566

567
        assert!(!plugin.is_master_file());
2✔
568

569
        copy_to_test_dir("Blank.esm", "Blank.esm", &settings);
2✔
570
        let plugin = Plugin::new("Blank.esm", &settings).unwrap();
2✔
571

572
        assert!(!plugin.is_light_plugin());
2✔
573

574
        copy_to_test_dir("Blank.esm", "Blank.esl", &settings);
2✔
575
        let plugin = Plugin::new("Blank.esl", &settings).unwrap();
2✔
576

577
        assert!(plugin.is_light_plugin());
2✔
578

579
        copy_to_test_dir("Blank - Different.esp", "Blank - Different.esl", &settings);
2✔
580
        let plugin = Plugin::new("Blank - Different.esl", &settings).unwrap();
2✔
581

582
        assert!(plugin.is_light_plugin());
2✔
583
    }
2✔
584

585
    #[test]
586
    fn is_light_plugin_should_be_false_for_an_omwscripts_plugin() {
2✔
587
        let tmp_dir = tempdir().unwrap();
2✔
588
        let game_dir = tmp_dir.path();
2✔
589

590
        let settings = game_settings(GameId::OpenMW, game_dir);
2✔
591

592
        let name = "plugin.omwscripts";
2✔
593
        create_file(&settings.plugins_directory().join(name));
2✔
594
        let plugin = Plugin::new(name, &settings).unwrap();
2✔
595

596
        assert!(!plugin.is_light_plugin());
2✔
597
    }
2✔
598

599
    #[test]
600
    fn is_medium_plugin_should_be_false_for_an_omwscripts_plugin() {
2✔
601
        let tmp_dir = tempdir().unwrap();
2✔
602
        let game_dir = tmp_dir.path();
2✔
603

604
        let settings = game_settings(GameId::OpenMW, game_dir);
2✔
605

606
        let name = "plugin.omwscripts";
2✔
607
        create_file(&settings.plugins_directory().join(name));
2✔
608
        let plugin = Plugin::new(name, &settings).unwrap();
2✔
609

610
        assert!(!plugin.is_light_plugin());
2✔
611
    }
2✔
612

613
    #[test]
614
    fn is_blueprint_master_should_be_false_for_an_omwscripts_plugin() {
2✔
615
        let tmp_dir = tempdir().unwrap();
2✔
616
        let game_dir = tmp_dir.path();
2✔
617

618
        let settings = game_settings(GameId::OpenMW, game_dir);
2✔
619

620
        let name = "plugin.omwscripts";
2✔
621
        create_file(&settings.plugins_directory().join(name));
2✔
622
        let plugin = Plugin::new(name, &settings).unwrap();
2✔
623

624
        assert!(!plugin.is_blueprint_master());
2✔
625
    }
2✔
626

627
    #[test]
628
    fn masters_should_be_empty_for_an_omwscripts_plugin() {
2✔
629
        let tmp_dir = tempdir().unwrap();
2✔
630
        let game_dir = tmp_dir.path();
2✔
631

632
        let settings = game_settings(GameId::OpenMW, game_dir);
2✔
633

634
        let name = "plugin.omwscripts";
2✔
635
        create_file(&settings.plugins_directory().join(name));
2✔
636
        let plugin = Plugin::new(name, &settings).unwrap();
2✔
637

638
        assert!(plugin.masters().unwrap().is_empty());
2✔
639
    }
2✔
640

641
    #[test]
642
    fn set_modification_time_should_update_the_file_modification_time() {
2✔
643
        let tmp_dir = tempdir().unwrap();
2✔
644
        let game_dir = tmp_dir.path();
2✔
645

646
        let settings = game_settings(GameId::Oblivion, game_dir);
2✔
647

648
        copy_to_test_dir("Blank.esp", "Blank.esp", &settings);
2✔
649

650
        let path = game_dir.join("Data").join("Blank.esp");
2✔
651
        let file_size = path.metadata().unwrap().len();
2✔
652

653
        let mut plugin = Plugin::new("Blank.esp", &settings).unwrap();
2✔
654

655
        assert_ne!(UNIX_EPOCH, plugin.modification_time());
2✔
656
        plugin.set_modification_time(UNIX_EPOCH).unwrap();
2✔
657

658
        let metadata = path.metadata().unwrap();
2✔
659
        let new_mtime = metadata.modified().unwrap();
2✔
660
        let new_size = metadata.len();
2✔
661

662
        assert_eq!(UNIX_EPOCH, plugin.modification_time());
2✔
663
        assert_eq!(UNIX_EPOCH, new_mtime);
2✔
664
        assert_eq!(file_size, new_size);
2✔
665
    }
2✔
666

667
    #[test]
668
    fn set_modification_time_should_be_able_to_handle_pre_unix_timestamps() {
2✔
669
        let tmp_dir = tempdir().unwrap();
2✔
670
        let game_dir = tmp_dir.path();
2✔
671

672
        let settings = game_settings(GameId::Oblivion, game_dir);
2✔
673

674
        copy_to_test_dir("Blank.esp", "Blank.esp", &settings);
2✔
675
        let mut plugin = Plugin::new("Blank.esp", &settings).unwrap();
2✔
676
        let target_mtime = UNIX_EPOCH - Duration::from_secs(1);
2✔
677

678
        assert_ne!(target_mtime, plugin.modification_time());
2✔
679
        plugin.set_modification_time(target_mtime).unwrap();
2✔
680
        let new_mtime = game_dir
2✔
681
            .join("Data")
2✔
682
            .join("Blank.esp")
2✔
683
            .metadata()
2✔
684
            .unwrap()
2✔
685
            .modified()
2✔
686
            .unwrap();
2✔
687

688
        assert_eq!(target_mtime, plugin.modification_time());
2✔
689
        assert_eq!(target_mtime, new_mtime);
2✔
690
    }
2✔
691

692
    #[test]
693
    fn activate_should_unghost_a_ghosted_plugin() {
2✔
694
        let tmp_dir = tempdir().unwrap();
2✔
695
        let game_dir = tmp_dir.path();
2✔
696

697
        let settings = game_settings(GameId::Oblivion, game_dir);
2✔
698

699
        copy_to_test_dir("Blank.esp", "Blank.esp.ghost", &settings);
2✔
700
        let mut plugin = Plugin::new("Blank.esp", &settings).unwrap();
2✔
701

702
        plugin.activate().unwrap();
2✔
703

704
        assert!(plugin.is_active());
2✔
705
        assert_eq!(ActiveState::ExplicitlyActive, plugin.active);
2✔
706
        assert_eq!("Blank.esp", plugin.name());
2✔
707
        assert!(game_dir.join("Data").join("Blank.esp").exists());
2✔
708
    }
2✔
709

710
    #[test]
711
    fn activate_should_not_unghost_an_openmw_plugin() {
2✔
712
        // It's not possible to create an OpenMW Plugin from a path ending in
713
        // .ghost outside of this module, so this is just for internal
714
        // consistency.
715
        let tmp_dir = tempdir().unwrap();
2✔
716
        let game_dir = tmp_dir.path();
2✔
717

718
        let settings = game_settings(GameId::OpenMW, game_dir);
2✔
719

720
        let plugin_name = "Blank.esp.ghost";
2✔
721
        copy_to_test_dir("Blank.esp", plugin_name, &settings);
2✔
722

723
        let data = esplugin::Plugin::new(
2✔
724
            GameId::OpenMW.to_esplugin_id(),
2✔
725
            &game_dir.join("Data Files").join(plugin_name),
2✔
726
        );
727

728
        let mut plugin = Plugin {
2✔
729
            active: ActiveState::Inactive,
2✔
730
            modification_time: SystemTime::now(),
2✔
731
            data,
2✔
732
            name: plugin_name.to_owned(),
2✔
733
            game_id: GameId::OpenMW,
2✔
734
        };
2✔
735

736
        plugin.activate().unwrap();
2✔
737
        assert!(plugin.is_active());
2✔
738
        assert_eq!(plugin_name, plugin.name());
2✔
739
    }
2✔
740

741
    #[test]
742
    fn activate_should_make_an_implicitly_active_plugin_explicitly_active() {
2✔
743
        let tmp_dir = tempdir().unwrap();
2✔
744
        let game_dir = tmp_dir.path();
2✔
745

746
        let settings = game_settings(GameId::Oblivion, game_dir);
2✔
747

748
        copy_to_test_dir("Blank.esp", "Blank.esp", &settings);
2✔
749
        let mut plugin =
2✔
750
            Plugin::with_active("Blank.esp", &settings, ActiveState::ImplicitlyActive).unwrap();
2✔
751

752
        plugin.activate().unwrap();
2✔
753

754
        assert!(plugin.is_active());
2✔
755
        assert_eq!(ActiveState::ExplicitlyActive, plugin.active);
2✔
756
    }
2✔
757

758
    #[test]
759
    fn implicitly_activate_should_unghost_a_ghosted_plugin() {
2✔
760
        let tmp_dir = tempdir().unwrap();
2✔
761
        let game_dir = tmp_dir.path();
2✔
762

763
        let settings = game_settings(GameId::Oblivion, game_dir);
2✔
764

765
        copy_to_test_dir("Blank.esp", "Blank.esp.ghost", &settings);
2✔
766
        let mut plugin = Plugin::new("Blank.esp", &settings).unwrap();
2✔
767

768
        plugin.implicitly_activate().unwrap();
2✔
769

770
        assert!(plugin.is_active());
2✔
771
        assert_eq!(ActiveState::ImplicitlyActive, plugin.active);
2✔
772
        assert_eq!("Blank.esp", plugin.name());
2✔
773
        assert!(game_dir.join("Data").join("Blank.esp").exists());
2✔
774
    }
2✔
775

776
    #[test]
777
    fn implicitly_activate_should_not_change_state_of_explicitly_active_plugin() {
2✔
778
        let tmp_dir = tempdir().unwrap();
2✔
779
        let game_dir = tmp_dir.path();
2✔
780

781
        let settings = game_settings(GameId::Oblivion, game_dir);
2✔
782

783
        copy_to_test_dir("Blank.esp", "Blank.esp", &settings);
2✔
784
        let mut plugin =
2✔
785
            Plugin::with_active("Blank.esp", &settings, ActiveState::ExplicitlyActive).unwrap();
2✔
786

787
        plugin.implicitly_activate().unwrap();
2✔
788

789
        assert!(plugin.is_active());
2✔
790
        assert_eq!(ActiveState::ExplicitlyActive, plugin.active);
2✔
791
    }
2✔
792

793
    #[test]
794
    fn deactivate_should_not_ghost_a_plugin() {
2✔
795
        let tmp_dir = tempdir().unwrap();
2✔
796
        let game_dir = tmp_dir.path();
2✔
797

798
        let settings = game_settings(GameId::Oblivion, game_dir);
2✔
799

800
        copy_to_test_dir("Blank.esp", "Blank.esp", &settings);
2✔
801
        let mut plugin = Plugin::new("Blank.esp", &settings).unwrap();
2✔
802

803
        plugin.deactivate();
2✔
804

805
        assert!(!plugin.is_active());
2✔
806
        assert!(game_dir.join("Data").join("Blank.esp").exists());
2✔
807
    }
2✔
808

809
    #[test]
810
    fn has_plugin_extension_should_recognise_openmw_extensions_for_openmw() {
2✔
811
        assert!(has_plugin_extension("plugin.omwgame", GameId::OpenMW));
2✔
812
        assert!(has_plugin_extension("plugin.omwaddon", GameId::OpenMW));
2✔
813
        assert!(has_plugin_extension("plugin.omwscripts", GameId::OpenMW));
2✔
814
    }
2✔
815

816
    #[test]
817
    fn has_plugin_extension_should_recognise_esp_and_esm_extensions_for_all_games() {
2✔
818
        assert!(has_plugin_extension("plugin.esp", GameId::OpenMW));
2✔
819
        assert!(has_plugin_extension("plugin.esp", GameId::Morrowind));
2✔
820
        assert!(has_plugin_extension("plugin.esp", GameId::Oblivion));
2✔
821
        assert!(has_plugin_extension("plugin.esp", GameId::Skyrim));
2✔
822
        assert!(has_plugin_extension("plugin.esp", GameId::SkyrimSE));
2✔
823
        assert!(has_plugin_extension("plugin.esp", GameId::SkyrimVR));
2✔
824
        assert!(has_plugin_extension("plugin.esp", GameId::Fallout3));
2✔
825
        assert!(has_plugin_extension("plugin.esp", GameId::FalloutNV));
2✔
826
        assert!(has_plugin_extension("plugin.esp", GameId::Fallout4));
2✔
827
        assert!(has_plugin_extension("plugin.esp", GameId::Fallout4VR));
2✔
828
        assert!(has_plugin_extension("plugin.esp", GameId::Starfield));
2✔
829

830
        assert!(has_plugin_extension("plugin.esm", GameId::OpenMW));
2✔
831
        assert!(has_plugin_extension("plugin.esm", GameId::Morrowind));
2✔
832
        assert!(has_plugin_extension("plugin.esm", GameId::Oblivion));
2✔
833
        assert!(has_plugin_extension("plugin.esm", GameId::Skyrim));
2✔
834
        assert!(has_plugin_extension("plugin.esm", GameId::SkyrimSE));
2✔
835
        assert!(has_plugin_extension("plugin.esm", GameId::SkyrimVR));
2✔
836
        assert!(has_plugin_extension("plugin.esm", GameId::Fallout3));
2✔
837
        assert!(has_plugin_extension("plugin.esm", GameId::FalloutNV));
2✔
838
        assert!(has_plugin_extension("plugin.esm", GameId::Fallout4));
2✔
839
        assert!(has_plugin_extension("plugin.esm", GameId::Fallout4VR));
2✔
840
        assert!(has_plugin_extension("plugin.esm", GameId::Starfield));
2✔
841
    }
2✔
842

843
    #[test]
844
    fn has_plugin_extension_should_recognise_ghosted_esp_and_esm_extensions_for_all_games_other_than_openmw(
2✔
845
    ) {
2✔
846
        assert!(!has_plugin_extension("plugin.esp.ghost", GameId::OpenMW));
2✔
847
        assert!(has_plugin_extension("plugin.esp.ghost", GameId::Morrowind));
2✔
848
        assert!(has_plugin_extension("plugin.esp.ghost", GameId::Oblivion));
2✔
849
        assert!(has_plugin_extension("plugin.esp.ghost", GameId::Skyrim));
2✔
850
        assert!(has_plugin_extension("plugin.esp.ghost", GameId::SkyrimSE));
2✔
851
        assert!(has_plugin_extension("plugin.esp.ghost", GameId::SkyrimVR));
2✔
852
        assert!(has_plugin_extension("plugin.esp.ghost", GameId::Fallout3));
2✔
853
        assert!(has_plugin_extension("plugin.esp.ghost", GameId::FalloutNV));
2✔
854
        assert!(has_plugin_extension("plugin.esp.ghost", GameId::Fallout4));
2✔
855
        assert!(has_plugin_extension("plugin.esp.ghost", GameId::Fallout4VR));
2✔
856
        assert!(has_plugin_extension("plugin.esp.ghost", GameId::Starfield));
2✔
857

858
        assert!(!has_plugin_extension("plugin.esm.ghost", GameId::OpenMW));
2✔
859
        assert!(has_plugin_extension("plugin.esm.ghost", GameId::Morrowind));
2✔
860
        assert!(has_plugin_extension("plugin.esm.ghost", GameId::Oblivion));
2✔
861
        assert!(has_plugin_extension("plugin.esm.ghost", GameId::Skyrim));
2✔
862
        assert!(has_plugin_extension("plugin.esm.ghost", GameId::SkyrimSE));
2✔
863
        assert!(has_plugin_extension("plugin.esm.ghost", GameId::SkyrimVR));
2✔
864
        assert!(has_plugin_extension("plugin.esm.ghost", GameId::Fallout3));
2✔
865
        assert!(has_plugin_extension("plugin.esm.ghost", GameId::FalloutNV));
2✔
866
        assert!(has_plugin_extension("plugin.esm.ghost", GameId::Fallout4));
2✔
867
        assert!(has_plugin_extension("plugin.esm.ghost", GameId::Fallout4VR));
2✔
868
        assert!(has_plugin_extension("plugin.esm.ghost", GameId::Starfield));
2✔
869
    }
2✔
870

871
    #[test]
872
    fn has_plugin_extension_should_recognise_esl_extension_and_ghosted_esl_for_fo4_and_later_games()
2✔
873
    {
874
        assert!(!has_plugin_extension("plugin.esl", GameId::OpenMW));
2✔
875
        assert!(!has_plugin_extension("plugin.esl", GameId::Morrowind));
2✔
876
        assert!(!has_plugin_extension("plugin.esl", GameId::Oblivion));
2✔
877
        assert!(!has_plugin_extension("plugin.esl", GameId::Skyrim));
2✔
878
        assert!(has_plugin_extension("plugin.esl", GameId::SkyrimSE));
2✔
879
        assert!(has_plugin_extension("plugin.esl", GameId::SkyrimVR));
2✔
880
        assert!(!has_plugin_extension("plugin.esl", GameId::Fallout3));
2✔
881
        assert!(!has_plugin_extension("plugin.esl", GameId::FalloutNV));
2✔
882
        assert!(has_plugin_extension("plugin.esl", GameId::Fallout4));
2✔
883
        assert!(has_plugin_extension("plugin.esl", GameId::Fallout4VR));
2✔
884
        assert!(has_plugin_extension("plugin.esl", GameId::Starfield));
2✔
885

886
        assert!(!has_plugin_extension("plugin.esl.ghost", GameId::OpenMW));
2✔
887
        assert!(!has_plugin_extension("plugin.esl.ghost", GameId::Morrowind));
2✔
888
        assert!(!has_plugin_extension("plugin.esl.ghost", GameId::Oblivion));
2✔
889
        assert!(!has_plugin_extension("plugin.esl.ghost", GameId::Skyrim));
2✔
890
        assert!(has_plugin_extension("plugin.esl.ghost", GameId::SkyrimSE));
2✔
891
        assert!(has_plugin_extension("plugin.esl.ghost", GameId::SkyrimVR));
2✔
892
        assert!(!has_plugin_extension("plugin.esl.ghost", GameId::Fallout3));
2✔
893
        assert!(!has_plugin_extension("plugin.esl.ghost", GameId::FalloutNV));
2✔
894
        assert!(has_plugin_extension("plugin.esl.ghost", GameId::Fallout4));
2✔
895
        assert!(has_plugin_extension("plugin.esl.ghost", GameId::Fallout4VR));
2✔
896
        assert!(has_plugin_extension("plugin.esl.ghost", GameId::Starfield));
2✔
897
    }
2✔
898

899
    #[test]
900
    fn trim_dot_ghost_should_trim_the_ghost_extension_if_the_game_allows_ghosting() {
2✔
901
        let ghosted = "plugin.esp.ghost";
2✔
902
        let unghosted = "plugin.esp";
2✔
903

904
        assert_eq!(ghosted, trim_dot_ghost(ghosted, GameId::OpenMW));
2✔
905
        assert_eq!(unghosted, trim_dot_ghost(ghosted, GameId::Morrowind));
2✔
906
        assert_eq!(unghosted, trim_dot_ghost(ghosted, GameId::Oblivion));
2✔
907
        assert_eq!(unghosted, trim_dot_ghost(ghosted, GameId::Skyrim));
2✔
908
        assert_eq!(unghosted, trim_dot_ghost(ghosted, GameId::SkyrimSE));
2✔
909
        assert_eq!(unghosted, trim_dot_ghost(ghosted, GameId::SkyrimVR));
2✔
910
        assert_eq!(unghosted, trim_dot_ghost(ghosted, GameId::Fallout3));
2✔
911
        assert_eq!(unghosted, trim_dot_ghost(ghosted, GameId::FalloutNV));
2✔
912
        assert_eq!(unghosted, trim_dot_ghost(ghosted, GameId::Fallout4));
2✔
913
        assert_eq!(unghosted, trim_dot_ghost(ghosted, GameId::Fallout4VR));
2✔
914
        assert_eq!(unghosted, trim_dot_ghost(ghosted, GameId::Starfield));
2✔
915
    }
2✔
916

917
    #[test]
918
    fn trim_dot_ghost_unchecked_should_trim_the_ghost_extension() {
2✔
919
        let ghosted = "plugin.esp.ghost";
2✔
920
        let unghosted = "plugin.esp";
2✔
921

922
        assert_eq!(unghosted, trim_dot_ghost_unchecked(ghosted));
2✔
923
        assert_eq!(unghosted, trim_dot_ghost_unchecked(unghosted));
2✔
924
    }
2✔
925
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc