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

Ortham / libloadorder / 12774354782

14 Jan 2025 06:46PM UTC coverage: 92.089% (-0.03%) from 92.119%
12774354782

push

github

Ortham
Add support for OpenMW .omwaddon and .omwgame plugins

6 of 9 new or added lines in 5 files covered. (66.67%)

1 existing line in 1 file now uncovered.

8090 of 8785 relevant lines covered (92.09%)

1348092.16 hits per line

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

98.43
/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
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
const VALID_EXTENSIONS_OPENMW: &[&str] = &[
43
    ".esp",
44
    ".esm",
45
    ".esp.ghost",
46
    ".esm.ghost",
47
    ".omwaddon",
48
    ".omwgame",
49
    ".omwaddon.ghost",
50
    ".omwgame.ghost",
51
];
52

53
#[derive(Clone, Debug)]
54
pub struct Plugin {
55
    active: bool,
56
    modification_time: SystemTime,
57
    data: esplugin::Plugin,
58
    name: String,
59
}
60

61
impl Plugin {
62
    pub fn new(filename: &str, game_settings: &GameSettings) -> Result<Plugin, Error> {
20,133✔
63
        Plugin::with_active(filename, game_settings, false)
20,133✔
64
    }
20,133✔
65

66
    pub fn with_active(
20,680✔
67
        filename: &str,
20,680✔
68
        game_settings: &GameSettings,
20,680✔
69
        active: bool,
20,680✔
70
    ) -> Result<Plugin, Error> {
20,680✔
71
        let filepath = game_settings.plugin_path(filename);
20,680✔
72

73
        let filepath = if active {
20,680✔
74
            filepath.unghost()?
365✔
75
        } else {
76
            filepath.resolve_path()?
20,315✔
77
        };
78

79
        Plugin::with_path(&filepath, game_settings.id(), active)
20,674✔
80
    }
20,680✔
81

82
    pub(crate) fn with_path(path: &Path, game_id: GameId, active: bool) -> Result<Plugin, Error> {
20,693✔
83
        let filename = match path.file_name().and_then(OsStr::to_str) {
20,693✔
84
            Some(n) => n,
20,693✔
85
            None => return Err(Error::NoFilename(path.to_path_buf())),
×
86
        };
87

88
        if !has_plugin_extension(filename, game_id) {
20,693✔
89
            return Err(Error::InvalidPath(path.to_path_buf()));
×
90
        }
20,693✔
91

92
        let file = File::open(path).map_err(|e| Error::IoError(path.to_path_buf(), e))?;
20,693✔
93
        let modification_time = file
20,578✔
94
            .metadata()
20,578✔
95
            .and_then(|m| m.modified())
20,578✔
96
            .map_err(|e| Error::IoError(path.to_path_buf(), e))?;
20,578✔
97

98
        let mut data = esplugin::Plugin::new(game_id.to_esplugin_id(), path);
20,578✔
99
        data.parse_reader(file, ParseOptions::header_only())
20,578✔
100
            .map_err(|e| file_error(path, e))?;
20,578✔
101

102
        Ok(Plugin {
20,572✔
103
            active,
20,572✔
104
            modification_time,
20,572✔
105
            data,
20,572✔
106
            name: trim_dot_ghost(filename).to_string(),
20,572✔
107
        })
20,572✔
108
    }
20,693✔
109

110
    pub fn name(&self) -> &str {
377,541,016✔
111
        &self.name
377,541,016✔
112
    }
377,541,016✔
113

114
    pub fn name_matches(&self, string: &str) -> bool {
377,363,323✔
115
        eq(self.name(), trim_dot_ghost(string))
377,363,323✔
116
    }
377,363,323✔
117

118
    pub fn modification_time(&self) -> SystemTime {
178✔
119
        self.modification_time
178✔
120
    }
178✔
121

122
    pub fn is_active(&self) -> bool {
528,080✔
123
        self.active
528,080✔
124
    }
528,080✔
125

126
    pub fn is_master_file(&self) -> bool {
86,904,642✔
127
        self.data.is_master_file()
86,904,642✔
128
    }
86,904,642✔
129

130
    pub fn is_light_plugin(&self) -> bool {
273,516✔
131
        self.data.is_light_plugin()
273,516✔
132
    }
273,516✔
133

134
    pub fn is_medium_plugin(&self) -> bool {
236,644✔
135
        self.data.is_medium_plugin()
236,644✔
136
    }
236,644✔
137

138
    pub fn is_blueprint_master(&self) -> bool {
694,056,705✔
139
        self.data.is_blueprint_plugin() && self.is_master_file()
694,056,705✔
140
    }
694,056,705✔
141

142
    pub fn masters(&self) -> Result<Vec<String>, Error> {
43,422,662✔
143
        self.data
43,422,662✔
144
            .masters()
43,422,662✔
145
            .map_err(|e| file_error(self.data.path(), e))
43,422,662✔
146
    }
43,422,662✔
147

148
    pub fn set_modification_time(&mut self, time: SystemTime) -> Result<(), Error> {
39✔
149
        // Always write the file time. This has a huge performance impact, but
39✔
150
        // is important for correctness, as otherwise external changes to plugin
39✔
151
        // timestamps between calls to WritableLoadOrder::load() and
39✔
152
        // WritableLoadOrder::save() could lead to libloadorder not setting all
39✔
153
        // the timestamps it needs to and producing an incorrect load order.
39✔
154
        let times = FileTimes::new()
39✔
155
            .set_accessed(SystemTime::now())
39✔
156
            .set_modified(time);
39✔
157

39✔
158
        File::options()
39✔
159
            .write(true)
39✔
160
            .open(self.data.path())
39✔
161
            .and_then(|f| f.set_times(times))
39✔
162
            .map_err(|e| Error::IoError(self.data.path().to_path_buf(), e))?;
39✔
163

164
        self.modification_time = time;
39✔
165
        Ok(())
39✔
166
    }
39✔
167

168
    pub fn activate(&mut self) -> Result<(), Error> {
11,814✔
169
        if !self.is_active() {
11,814✔
170
            if self.data.path().is_ghosted() {
11,813✔
171
                let new_path = self.data.path().unghost()?;
1✔
172

173
                self.data = esplugin::Plugin::new(self.data.game_id(), &new_path);
1✔
174
                self.data
1✔
175
                    .parse_file(ParseOptions::header_only())
1✔
176
                    .map_err(|e| file_error(self.data.path(), e))?;
1✔
177
                let modification_time = self.modification_time();
1✔
178
                self.set_modification_time(modification_time)?;
1✔
179
            }
11,812✔
180

181
            self.active = true;
11,813✔
182
        }
1✔
183
        Ok(())
11,814✔
184
    }
11,814✔
185

186
    pub fn deactivate(&mut self) {
11,963✔
187
        self.active = false;
11,963✔
188
    }
11,963✔
189
}
190

191
pub fn has_plugin_extension(filename: &str, game: GameId) -> bool {
21,014✔
192
    let valid_extensions = if game == GameId::OpenMW {
21,014✔
NEW
193
        VALID_EXTENSIONS_OPENMW
×
194
    } else if game.supports_light_plugins() {
21,014✔
195
        VALID_EXTENSIONS_WITH_ESL
19,642✔
196
    } else {
197
        VALID_EXTENSIONS
1,372✔
198
    };
199

200
    valid_extensions
21,014✔
201
        .iter()
21,014✔
202
        .any(|e| iends_with_ascii(filename, e))
41,166✔
203
}
21,014✔
204

205
fn iends_with_ascii(string: &str, suffix: &str) -> bool {
377,425,797✔
206
    // as_bytes().into_iter() is faster than bytes().
377,425,797✔
207
    string.len() >= suffix.len()
377,425,797✔
208
        && string
377,425,793✔
209
            .as_bytes()
377,425,793✔
210
            .iter()
377,425,793✔
211
            .rev()
377,425,793✔
212
            .zip(suffix.as_bytes().iter().rev())
377,425,793✔
213
            .all(|(string_byte, suffix_byte)| string_byte.eq_ignore_ascii_case(suffix_byte))
377,489,003✔
214
}
377,425,797✔
215

216
pub fn trim_dot_ghost(string: &str) -> &str {
377,384,631✔
217
    if iends_with_ascii(string, GHOST_FILE_EXTENSION) {
377,384,631✔
218
        &string[..(string.len() - GHOST_FILE_EXTENSION.len())]
12✔
219
    } else {
220
        string
377,384,619✔
221
    }
222
}
377,384,631✔
223

224
fn file_error(file_path: &Path, error: esplugin::Error) -> Error {
6✔
225
    match error {
6✔
226
        esplugin::Error::IoError(x) => Error::IoError(file_path.to_path_buf(), x),
6✔
227
        esplugin::Error::NoFilename(_) => Error::NoFilename(file_path.to_path_buf()),
×
228
        e => Error::PluginParsingError(file_path.to_path_buf(), Box::new(e)),
×
229
    }
230
}
6✔
231

232
#[cfg(test)]
233
mod tests {
234
    use super::*;
235

236
    use crate::tests::copy_to_test_dir;
237
    use std::path::{Path, PathBuf};
238
    use std::time::{Duration, UNIX_EPOCH};
239
    use tempfile::tempdir;
240

241
    fn game_settings(game_id: GameId, game_path: &Path) -> GameSettings {
12✔
242
        GameSettings::with_local_and_my_games_paths(
12✔
243
            game_id,
12✔
244
            game_path,
12✔
245
            &PathBuf::default(),
12✔
246
            PathBuf::default(),
12✔
247
        )
12✔
248
        .unwrap()
12✔
249
    }
12✔
250

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

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

1✔
258
        copy_to_test_dir("Blank.esp", "Blank.esp", &settings);
1✔
259
        let plugin = Plugin::new("Blank.esp.ghost", &settings).unwrap();
1✔
260
        assert_eq!("Blank.esp", plugin.name());
1✔
261

262
        copy_to_test_dir("Blank.esp", "Blank.esp", &settings);
1✔
263
        let plugin = Plugin::new("Blank.esp", &settings).unwrap();
1✔
264
        assert_eq!("Blank.esp", plugin.name());
1✔
265

266
        copy_to_test_dir("Blank.esm", "Blank.esm.ghost", &settings);
1✔
267
        let plugin = Plugin::new("Blank.esm", &settings).unwrap();
1✔
268
        assert_eq!("Blank.esm", plugin.name());
1✔
269
    }
1✔
270

271
    #[test]
272
    fn name_matches_should_ignore_plugin_ghost_extension() {
1✔
273
        let tmp_dir = tempdir().unwrap();
1✔
274
        let settings = game_settings(GameId::Skyrim, tmp_dir.path());
1✔
275
        copy_to_test_dir("Blank.esp", "BlanK.esp.GHoSt", &settings);
1✔
276

1✔
277
        let plugin = Plugin::new("BlanK.esp.GHoSt", &settings).unwrap();
1✔
278
        assert!(plugin.name_matches("Blank.esp"));
1✔
279
    }
1✔
280

281
    #[test]
282
    fn name_matches_should_ignore_string_ghost_suffix() {
1✔
283
        let tmp_dir = tempdir().unwrap();
1✔
284
        let settings = game_settings(GameId::Skyrim, tmp_dir.path());
1✔
285
        copy_to_test_dir("Blank.esp", "BlanK.esp", &settings);
1✔
286

1✔
287
        let plugin = Plugin::new("BlanK.esp", &settings).unwrap();
1✔
288
        assert!(plugin.name_matches("Blank.esp.GHoSt"));
1✔
289
    }
1✔
290

291
    #[test]
292
    fn modification_time_should_return_the_plugin_modification_time_at_creation() {
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_path = game_dir.join("Data").join("Blank.esp");
1✔
300
        let mtime = plugin_path.metadata().unwrap().modified().unwrap();
1✔
301

1✔
302
        let plugin = Plugin::new("Blank.esp", &settings).unwrap();
1✔
303
        assert_eq!(mtime, plugin.modification_time());
1✔
304
    }
1✔
305

306
    #[test]
307
    fn is_active_should_be_false() {
1✔
308
        let tmp_dir = tempdir().unwrap();
1✔
309
        let game_dir = tmp_dir.path();
1✔
310

1✔
311
        let settings = game_settings(GameId::Oblivion, game_dir);
1✔
312

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

1✔
316
        assert!(!plugin.is_active());
1✔
317
    }
1✔
318

319
    #[test]
320
    fn is_master_file_should_be_true_if_the_plugin_is_a_master_file() {
1✔
321
        let tmp_dir = tempdir().unwrap();
1✔
322
        let game_dir = tmp_dir.path();
1✔
323

1✔
324
        let settings = game_settings(GameId::Oblivion, game_dir);
1✔
325

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

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

332
    #[test]
333
    fn is_master_file_should_be_false_if_the_plugin_is_not_a_master_file() {
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 plugin = Plugin::new("Blank.esp", &settings).unwrap();
1✔
341

1✔
342
        assert!(!plugin.is_master_file());
1✔
343
    }
1✔
344

345
    #[test]
346
    fn is_light_plugin_should_be_true_for_esl_files_only() {
1✔
347
        let tmp_dir = tempdir().unwrap();
1✔
348
        let game_dir = tmp_dir.path();
1✔
349

1✔
350
        let settings = game_settings(GameId::SkyrimSE, game_dir);
1✔
351

1✔
352
        copy_to_test_dir("Blank.esp", "Blank.esp", &settings);
1✔
353
        let plugin = Plugin::new("Blank.esp", &settings).unwrap();
1✔
354

1✔
355
        assert!(!plugin.is_master_file());
1✔
356

357
        copy_to_test_dir("Blank.esm", "Blank.esm", &settings);
1✔
358
        let plugin = Plugin::new("Blank.esm", &settings).unwrap();
1✔
359

1✔
360
        assert!(!plugin.is_light_plugin());
1✔
361

362
        copy_to_test_dir("Blank.esm", "Blank.esl", &settings);
1✔
363
        let plugin = Plugin::new("Blank.esl", &settings).unwrap();
1✔
364

1✔
365
        assert!(plugin.is_light_plugin());
1✔
366

367
        copy_to_test_dir("Blank - Different.esp", "Blank - Different.esl", &settings);
1✔
368
        let plugin = Plugin::new("Blank - Different.esl", &settings).unwrap();
1✔
369

1✔
370
        assert!(plugin.is_light_plugin());
1✔
371
    }
1✔
372

373
    #[test]
374
    fn set_modification_time_should_update_the_file_modification_time() {
1✔
375
        let tmp_dir = tempdir().unwrap();
1✔
376
        let game_dir = tmp_dir.path();
1✔
377

1✔
378
        let settings = game_settings(GameId::Oblivion, game_dir);
1✔
379

1✔
380
        copy_to_test_dir("Blank.esp", "Blank.esp", &settings);
1✔
381

1✔
382
        let path = game_dir.join("Data").join("Blank.esp");
1✔
383
        let file_size = path.metadata().unwrap().len();
1✔
384

1✔
385
        let mut plugin = Plugin::new("Blank.esp", &settings).unwrap();
1✔
386

1✔
387
        assert_ne!(UNIX_EPOCH, plugin.modification_time());
1✔
388
        plugin.set_modification_time(UNIX_EPOCH).unwrap();
1✔
389

1✔
390
        let metadata = path.metadata().unwrap();
1✔
391
        let new_mtime = metadata.modified().unwrap();
1✔
392
        let new_size = metadata.len();
1✔
393

1✔
394
        assert_eq!(UNIX_EPOCH, plugin.modification_time());
1✔
395
        assert_eq!(UNIX_EPOCH, new_mtime);
1✔
396
        assert_eq!(file_size, new_size);
1✔
397
    }
1✔
398

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

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

1✔
406
        copy_to_test_dir("Blank.esp", "Blank.esp", &settings);
1✔
407
        let mut plugin = Plugin::new("Blank.esp", &settings).unwrap();
1✔
408
        let target_mtime = UNIX_EPOCH - Duration::from_secs(1);
1✔
409

1✔
410
        assert_ne!(target_mtime, plugin.modification_time());
1✔
411
        plugin.set_modification_time(target_mtime).unwrap();
1✔
412
        let new_mtime = game_dir
1✔
413
            .join("Data")
1✔
414
            .join("Blank.esp")
1✔
415
            .metadata()
1✔
416
            .unwrap()
1✔
417
            .modified()
1✔
418
            .unwrap();
1✔
419

1✔
420
        assert_eq!(target_mtime, plugin.modification_time());
1✔
421
        assert_eq!(target_mtime, new_mtime);
1✔
422
    }
1✔
423

424
    #[test]
425
    fn activate_should_unghost_a_ghosted_plugin() {
1✔
426
        let tmp_dir = tempdir().unwrap();
1✔
427
        let game_dir = tmp_dir.path();
1✔
428

1✔
429
        let settings = game_settings(GameId::Oblivion, game_dir);
1✔
430

1✔
431
        copy_to_test_dir("Blank.esp", "Blank.esp.ghost", &settings);
1✔
432
        let mut plugin = Plugin::new("Blank.esp", &settings).unwrap();
1✔
433

1✔
434
        plugin.activate().unwrap();
1✔
435

1✔
436
        assert!(plugin.is_active());
1✔
437
        assert_eq!("Blank.esp", plugin.name());
1✔
438
        assert!(game_dir.join("Data").join("Blank.esp").exists());
1✔
439
    }
1✔
440

441
    #[test]
442
    fn deactivate_should_not_ghost_a_plugin() {
1✔
443
        let tmp_dir = tempdir().unwrap();
1✔
444
        let game_dir = tmp_dir.path();
1✔
445

1✔
446
        let settings = game_settings(GameId::Oblivion, game_dir);
1✔
447

1✔
448
        copy_to_test_dir("Blank.esp", "Blank.esp", &settings);
1✔
449
        let mut plugin = Plugin::new("Blank.esp", &settings).unwrap();
1✔
450

1✔
451
        plugin.deactivate();
1✔
452

1✔
453
        assert!(!plugin.is_active());
1✔
454
        assert!(game_dir.join("Data").join("Blank.esp").exists());
1✔
455
    }
1✔
456
}
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