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

Ortham / libloadorder / 6303450966

25 Sep 2023 06:35PM UTC coverage: 89.461% (+0.4%) from 89.059%
6303450966

push

github

Ortham
Refresh implicitly active plugins on load

Now that implicitly active plugins are pulled from sources that can't be
reasonably assumed to be unchanging, refresh them on load in case
they've changed since the last time load order data was loaded.

Do it as the first thing so that a failure doesn't erase existing state.

152 of 152 new or added lines in 6 files covered. (100.0%)

6562 of 7335 relevant lines covered (89.46%)

70691.5 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;
21
use std::path::Path;
22
use std::time::SystemTime;
23

24
use filetime::{set_file_times, FileTime};
25
use unicase::eq;
26

27
use crate::enums::{Error, GameId};
28
use crate::game_settings::GameSettings;
29
use crate::ghostable_path::{GhostablePath, GHOST_FILE_EXTENSION};
30

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

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

42
#[derive(Clone, Debug)]
39✔
43
pub struct Plugin {
44
    active: bool,
45
    modification_time: SystemTime,
46
    data: esplugin::Plugin,
47
    name: String,
48
}
49

50
impl Plugin {
51
    pub fn new(filename: &str, game_settings: &GameSettings) -> Result<Plugin, Error> {
1,155✔
52
        Plugin::with_active(filename, game_settings, false)
1,155✔
53
    }
1,155✔
54

55
    pub fn with_active(
12,256✔
56
        filename: &str,
12,256✔
57
        game_settings: &GameSettings,
12,256✔
58
        active: bool,
12,256✔
59
    ) -> Result<Plugin, Error> {
12,256✔
60
        let filepath = game_settings.plugin_path(filename);
12,256✔
61

62
        let filepath = if active {
12,256✔
63
            filepath.unghost()?
10,877✔
64
        } else {
65
            filepath.resolve_path()?
1,379✔
66
        };
67

68
        Plugin::with_path(&filepath, game_settings.id(), active)
12,250✔
69
    }
12,256✔
70

71
    pub(crate) fn with_path(path: &Path, game_id: GameId, active: bool) -> Result<Plugin, Error> {
12,265✔
72
        let filename = match path.file_name().and_then(OsStr::to_str) {
12,265✔
73
            Some(n) => n,
12,265✔
74
            None => return Err(Error::NoFilename),
×
75
        };
76

77
        if !has_valid_extension(filename, game_id) {
12,265✔
78
            return Err(Error::InvalidPlugin(filename.to_owned()));
×
79
        }
12,265✔
80

81
        let file = File::open(&path)?;
12,265✔
82
        let modification_time = file.metadata()?.modified()?;
12,135✔
83

84
        let mut data = esplugin::Plugin::new(game_id.to_esplugin_id(), &path);
12,135✔
85
        data.parse_open_file(file, true)?;
12,135✔
86

87
        Ok(Plugin {
12,129✔
88
            active,
12,129✔
89
            modification_time,
12,129✔
90
            data,
12,129✔
91
            name: trim_dot_ghost(filename).to_string(),
12,129✔
92
        })
12,129✔
93
    }
12,265✔
94

95
    pub fn name(&self) -> &str {
19,144,279✔
96
        &self.name
19,144,279✔
97
    }
19,144,279✔
98

99
    pub fn name_matches(&self, string: &str) -> bool {
19,128,429✔
100
        eq(self.name(), trim_dot_ghost(string))
19,128,429✔
101
    }
19,128,429✔
102

103
    pub fn modification_time(&self) -> SystemTime {
284✔
104
        self.modification_time
284✔
105
    }
284✔
106

107
    pub fn is_active(&self) -> bool {
103,181✔
108
        self.active
103,181✔
109
    }
103,181✔
110

111
    pub fn is_master_file(&self) -> bool {
27,814,475✔
112
        self.data.is_master_file()
27,814,475✔
113
    }
27,814,475✔
114

115
    pub fn is_light_plugin(&self) -> bool {
91,908✔
116
        self.data.is_light_plugin()
91,908✔
117
    }
91,908✔
118

119
    pub fn masters(&self) -> Result<Vec<String>, Error> {
53,443✔
120
        self.data.masters().map_err(Error::from)
53,443✔
121
    }
53,443✔
122

123
    pub fn set_modification_time(&mut self, time: SystemTime) -> Result<(), Error> {
124
        // Always write the file time. This has a huge performance impact, but
125
        // is important for correctness, as otherwise external changes to plugin
126
        // timestamps between calls to WritableLoadOrder::load() and
127
        // WritableLoadOrder::save() could lead to libloadorder not setting all
128
        // the timestamps it needs to and producing an incorrect load order.
129
        set_file_times(
34✔
130
            self.data.path(),
34✔
131
            FileTime::from_system_time(SystemTime::now()),
34✔
132
            FileTime::from_system_time(time),
34✔
133
        )?;
34✔
134

135
        self.modification_time = time;
34✔
136
        Ok(())
34✔
137
    }
34✔
138

139
    pub fn activate(&mut self) -> Result<(), Error> {
9,282✔
140
        if !self.is_active() {
9,282✔
141
            if self.data.path().is_ghosted() {
9,279✔
142
                let new_path = self.data.path().unghost()?;
1✔
143

144
                self.data = esplugin::Plugin::new(*self.data.game_id(), &new_path);
1✔
145
                self.data.parse_file(true)?;
1✔
146
                let modification_time = self.modification_time();
1✔
147
                self.set_modification_time(modification_time)?;
1✔
148
            }
9,278✔
149

150
            self.active = true;
9,279✔
151
        }
3✔
152
        Ok(())
9,282✔
153
    }
9,282✔
154

155
    pub fn deactivate(&mut self) {
10,675✔
156
        self.active = false;
10,675✔
157
    }
10,675✔
158
}
159

160
fn has_valid_extension(filename: &str, game: GameId) -> bool {
12,265✔
161
    let valid_extensions = if game.supports_light_plugins() {
12,265✔
162
        VALID_EXTENSIONS_WITH_ESL
11,013✔
163
    } else {
164
        VALID_EXTENSIONS
1,252✔
165
    };
166

167
    valid_extensions
12,265✔
168
        .iter()
12,265✔
169
        .any(|e| iends_with_ascii(filename, e))
53,356✔
170
}
12,265✔
171

172
fn iends_with_ascii(string: &str, suffix: &str) -> bool {
19,226,296✔
173
    // as_bytes().into_iter() is faster than bytes().
19,226,296✔
174
    string.len() >= suffix.len()
19,226,296✔
175
        && string
19,226,096✔
176
            .as_bytes()
19,226,096✔
177
            .iter()
19,226,096✔
178
            .rev()
19,226,096✔
179
            .zip(suffix.as_bytes().iter().rev())
19,226,096✔
180
            .all(|(string_byte, suffix_byte)| string_byte.eq_ignore_ascii_case(suffix_byte))
19,263,069✔
181
}
19,226,296✔
182

183
pub fn trim_dot_ghost(string: &str) -> &str {
19,172,940✔
184
    if iends_with_ascii(string, GHOST_FILE_EXTENSION) {
19,172,940✔
185
        &string[..(string.len() - GHOST_FILE_EXTENSION.len())]
14✔
186
    } else {
187
        string
19,172,926✔
188
    }
189
}
19,172,940✔
190

191
#[cfg(test)]
192
mod tests {
193
    use super::*;
194

195
    use crate::tests::copy_to_test_dir;
196
    use std::path::{Path, PathBuf};
197
    use std::time::{Duration, UNIX_EPOCH};
198
    use tempfile::tempdir;
199

200
    fn game_settings(game_id: GameId, game_path: &Path) -> GameSettings {
12✔
201
        GameSettings::with_local_and_my_games_paths(
12✔
202
            game_id,
12✔
203
            game_path,
12✔
204
            &PathBuf::default(),
12✔
205
            PathBuf::default(),
12✔
206
        )
12✔
207
        .unwrap()
12✔
208
    }
12✔
209

210
    #[test]
1✔
211
    fn name_should_return_the_plugin_filename_without_any_ghost_extension() {
1✔
212
        let tmp_dir = tempdir().unwrap();
1✔
213
        let game_dir = tmp_dir.path();
1✔
214

1✔
215
        let settings = game_settings(GameId::Oblivion, &game_dir);
1✔
216

1✔
217
        copy_to_test_dir("Blank.esp", "Blank.esp", &settings);
1✔
218
        let plugin = Plugin::new("Blank.esp.ghost", &settings).unwrap();
1✔
219
        assert_eq!("Blank.esp", plugin.name());
1✔
220

221
        copy_to_test_dir("Blank.esp", "Blank.esp", &settings);
1✔
222
        let plugin = Plugin::new("Blank.esp", &settings).unwrap();
1✔
223
        assert_eq!("Blank.esp", plugin.name());
1✔
224

225
        copy_to_test_dir("Blank.esm", "Blank.esm.ghost", &settings);
1✔
226
        let plugin = Plugin::new("Blank.esm", &settings).unwrap();
1✔
227
        assert_eq!("Blank.esm", plugin.name());
1✔
228
    }
1✔
229

230
    #[test]
1✔
231
    fn name_matches_should_ignore_plugin_ghost_extension() {
1✔
232
        let tmp_dir = tempdir().unwrap();
1✔
233
        let settings = game_settings(GameId::Skyrim, tmp_dir.path());
1✔
234
        copy_to_test_dir("Blank.esp", "BlanK.esp.GHoSt", &settings);
1✔
235

1✔
236
        let plugin = Plugin::new("BlanK.esp.GHoSt", &settings).unwrap();
1✔
237
        assert!(plugin.name_matches("Blank.esp"));
1✔
238
    }
1✔
239

240
    #[test]
1✔
241
    fn name_matches_should_ignore_string_ghost_suffix() {
1✔
242
        let tmp_dir = tempdir().unwrap();
1✔
243
        let settings = game_settings(GameId::Skyrim, tmp_dir.path());
1✔
244
        copy_to_test_dir("Blank.esp", "BlanK.esp", &settings);
1✔
245

1✔
246
        let plugin = Plugin::new("BlanK.esp", &settings).unwrap();
1✔
247
        assert!(plugin.name_matches("Blank.esp.GHoSt"));
1✔
248
    }
1✔
249

250
    #[test]
1✔
251
    fn modification_time_should_return_the_plugin_modification_time_at_creation() {
1✔
252
        let tmp_dir = tempdir().unwrap();
1✔
253
        let game_dir = tmp_dir.path();
1✔
254

1✔
255
        let settings = game_settings(GameId::Oblivion, &game_dir);
1✔
256

1✔
257
        copy_to_test_dir("Blank.esp", "Blank.esp", &settings);
1✔
258
        let plugin_path = game_dir.join("Data").join("Blank.esp");
1✔
259
        let mtime = plugin_path.metadata().unwrap().modified().unwrap();
1✔
260

1✔
261
        let plugin = Plugin::new("Blank.esp", &settings).unwrap();
1✔
262
        assert_eq!(mtime, plugin.modification_time());
1✔
263
    }
1✔
264

265
    #[test]
1✔
266
    fn is_active_should_be_false() {
1✔
267
        let tmp_dir = tempdir().unwrap();
1✔
268
        let game_dir = tmp_dir.path();
1✔
269

1✔
270
        let settings = game_settings(GameId::Oblivion, &game_dir);
1✔
271

1✔
272
        copy_to_test_dir("Blank.esp", "Blank.esp", &settings);
1✔
273
        let plugin = Plugin::new("Blank.esp", &settings).unwrap();
1✔
274

1✔
275
        assert!(!plugin.is_active());
1✔
276
    }
1✔
277

278
    #[test]
1✔
279
    fn is_master_file_should_be_true_if_the_plugin_is_a_master_file() {
1✔
280
        let tmp_dir = tempdir().unwrap();
1✔
281
        let game_dir = tmp_dir.path();
1✔
282

1✔
283
        let settings = game_settings(GameId::Oblivion, &game_dir);
1✔
284

1✔
285
        copy_to_test_dir("Blank.esm", "Blank.esm", &settings);
1✔
286
        let plugin = Plugin::new("Blank.esm", &settings).unwrap();
1✔
287

1✔
288
        assert!(plugin.is_master_file());
1✔
289
    }
1✔
290

291
    #[test]
1✔
292
    fn is_master_file_should_be_false_if_the_plugin_is_not_a_master_file() {
1✔
293
        let tmp_dir = tempdir().unwrap();
1✔
294
        let game_dir = tmp_dir.path();
1✔
295

1✔
296
        let settings = game_settings(GameId::Oblivion, &game_dir);
1✔
297

1✔
298
        copy_to_test_dir("Blank.esp", "Blank.esp", &settings);
1✔
299
        let plugin = Plugin::new("Blank.esp", &settings).unwrap();
1✔
300

1✔
301
        assert!(!plugin.is_master_file());
1✔
302
    }
1✔
303

304
    #[test]
1✔
305
    fn is_light_plugin_should_be_true_for_esl_files_only() {
1✔
306
        let tmp_dir = tempdir().unwrap();
1✔
307
        let game_dir = tmp_dir.path();
1✔
308

1✔
309
        let settings = game_settings(GameId::SkyrimSE, &game_dir);
1✔
310

1✔
311
        copy_to_test_dir("Blank.esp", "Blank.esp", &settings);
1✔
312
        let plugin = Plugin::new("Blank.esp", &settings).unwrap();
1✔
313

1✔
314
        assert!(!plugin.is_master_file());
1✔
315

316
        copy_to_test_dir("Blank.esm", "Blank.esm", &settings);
1✔
317
        let plugin = Plugin::new("Blank.esm", &settings).unwrap();
1✔
318

1✔
319
        assert!(!plugin.is_light_plugin());
1✔
320

321
        copy_to_test_dir("Blank.esm", "Blank.esl", &settings);
1✔
322
        let plugin = Plugin::new("Blank.esl", &settings).unwrap();
1✔
323

1✔
324
        assert!(plugin.is_light_plugin());
1✔
325

326
        copy_to_test_dir("Blank - Different.esp", "Blank - Different.esl", &settings);
1✔
327
        let plugin = Plugin::new("Blank - Different.esl", &settings).unwrap();
1✔
328

1✔
329
        assert!(plugin.is_light_plugin());
1✔
330
    }
1✔
331

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

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

1✔
339
        copy_to_test_dir("Blank.esp", "Blank.esp", &settings);
1✔
340
        let mut plugin = Plugin::new("Blank.esp", &settings).unwrap();
1✔
341

1✔
342
        assert_ne!(UNIX_EPOCH, plugin.modification_time());
1✔
343
        plugin.set_modification_time(UNIX_EPOCH).unwrap();
1✔
344
        let new_mtime = game_dir
1✔
345
            .join("Data")
1✔
346
            .join("Blank.esp")
1✔
347
            .metadata()
1✔
348
            .unwrap()
1✔
349
            .modified()
1✔
350
            .unwrap();
1✔
351

1✔
352
        assert_eq!(UNIX_EPOCH, plugin.modification_time());
1✔
353
        assert_eq!(UNIX_EPOCH, new_mtime);
1✔
354
    }
1✔
355

356
    #[test]
1✔
357
    fn set_modification_time_should_be_able_to_handle_pre_unix_timestamps() {
1✔
358
        let tmp_dir = tempdir().unwrap();
1✔
359
        let game_dir = tmp_dir.path();
1✔
360

1✔
361
        let settings = game_settings(GameId::Oblivion, &game_dir);
1✔
362

1✔
363
        copy_to_test_dir("Blank.esp", "Blank.esp", &settings);
1✔
364
        let mut plugin = Plugin::new("Blank.esp", &settings).unwrap();
1✔
365
        let target_mtime = UNIX_EPOCH - Duration::from_secs(1);
1✔
366

1✔
367
        assert_ne!(target_mtime, plugin.modification_time());
1✔
368
        plugin.set_modification_time(target_mtime).unwrap();
1✔
369
        let new_mtime = game_dir
1✔
370
            .join("Data")
1✔
371
            .join("Blank.esp")
1✔
372
            .metadata()
1✔
373
            .unwrap()
1✔
374
            .modified()
1✔
375
            .unwrap();
1✔
376

1✔
377
        assert_eq!(target_mtime, plugin.modification_time());
1✔
378
        assert_eq!(target_mtime, new_mtime);
1✔
379
    }
1✔
380

381
    #[test]
1✔
382
    fn activate_should_unghost_a_ghosted_plugin() {
1✔
383
        let tmp_dir = tempdir().unwrap();
1✔
384
        let game_dir = tmp_dir.path();
1✔
385

1✔
386
        let settings = game_settings(GameId::Oblivion, &game_dir);
1✔
387

1✔
388
        copy_to_test_dir("Blank.esp", "Blank.esp.ghost", &settings);
1✔
389
        let mut plugin = Plugin::new("Blank.esp", &settings).unwrap();
1✔
390

1✔
391
        plugin.activate().unwrap();
1✔
392

1✔
393
        assert!(plugin.is_active());
1✔
394
        assert_eq!("Blank.esp", plugin.name());
1✔
395
        assert!(game_dir.join("Data").join("Blank.esp").exists());
1✔
396
    }
1✔
397

398
    #[test]
1✔
399
    fn deactivate_should_not_ghost_a_plugin() {
1✔
400
        let tmp_dir = tempdir().unwrap();
1✔
401
        let game_dir = tmp_dir.path();
1✔
402

1✔
403
        let settings = game_settings(GameId::Oblivion, &game_dir);
1✔
404

1✔
405
        copy_to_test_dir("Blank.esp", "Blank.esp", &settings);
1✔
406
        let mut plugin = Plugin::new("Blank.esp", &settings).unwrap();
1✔
407

1✔
408
        plugin.deactivate();
1✔
409

1✔
410
        assert!(!plugin.is_active());
1✔
411
        assert!(game_dir.join("Data").join("Blank.esp").exists());
1✔
412
    }
1✔
413
}
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