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

Ortham / libloadorder / 6552901722

17 Oct 2023 09:12PM UTC coverage: 91.543%. First build
6552901722

push

github

Ortham
Add context to more errors

Specifically paths and filenames.

154 of 154 new or added lines in 11 files covered. (100.0%)

7436 of 8123 relevant lines covered (91.54%)

64772.51 hits per line

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

98.42
/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,924✔
52
        Plugin::with_active(filename, game_settings, false)
1,924✔
53
    }
1,924✔
54

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

62
        let filepath = if active {
12,992✔
63
            filepath.unghost()?
10,875✔
64
        } else {
65
            filepath.resolve_path()?
2,117✔
66
        };
67

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

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

77
        if !has_plugin_extension(filename, game_id) {
13,005✔
78
            return Err(Error::InvalidPlugin(filename.to_owned()));
×
79
        }
13,005✔
80

81
        let file = File::open(path).map_err(|e| Error::IoError(path.to_path_buf(), e))?;
13,005✔
82
        let modification_time = file
12,882✔
83
            .metadata()
12,882✔
84
            .and_then(|m| m.modified())
12,882✔
85
            .map_err(|e| Error::IoError(path.to_path_buf(), e))?;
12,882✔
86

87
        let mut data = esplugin::Plugin::new(game_id.to_esplugin_id(), path);
12,882✔
88
        data.parse_open_file(file, true)
12,882✔
89
            .map_err(|e| file_error(path, e))?;
12,882✔
90

91
        Ok(Plugin {
12,876✔
92
            active,
12,876✔
93
            modification_time,
12,876✔
94
            data,
12,876✔
95
            name: trim_dot_ghost(filename).to_string(),
12,876✔
96
        })
12,876✔
97
    }
13,005✔
98

99
    pub fn name(&self) -> &str {
19,301,424✔
100
        &self.name
19,301,424✔
101
    }
19,301,424✔
102

103
    pub fn name_matches(&self, string: &str) -> bool {
19,285,479✔
104
        eq(self.name(), trim_dot_ghost(string))
19,285,479✔
105
    }
19,285,479✔
106

107
    pub fn modification_time(&self) -> SystemTime {
180✔
108
        self.modification_time
180✔
109
    }
180✔
110

111
    pub fn is_active(&self) -> bool {
205,693✔
112
        self.active
205,693✔
113
    }
205,693✔
114

115
    pub fn is_master_file(&self) -> bool {
27,914,528✔
116
        self.data.is_master_file()
27,914,528✔
117
    }
27,914,528✔
118

119
    pub fn is_light_plugin(&self) -> bool {
191,107✔
120
        self.data.is_light_plugin()
191,107✔
121
    }
191,107✔
122

123
    pub fn is_override_plugin(&self) -> bool {
166,528✔
124
        self.data.is_override_plugin()
166,528✔
125
    }
166,528✔
126

127
    pub fn masters(&self) -> Result<Vec<String>, Error> {
54,318✔
128
        self.data
54,318✔
129
            .masters()
54,318✔
130
            .map_err(|e| file_error(self.data.path(), e))
54,318✔
131
    }
54,318✔
132

133
    pub fn set_modification_time(&mut self, time: SystemTime) -> Result<(), Error> {
134
        // Always write the file time. This has a huge performance impact, but
135
        // is important for correctness, as otherwise external changes to plugin
136
        // timestamps between calls to WritableLoadOrder::load() and
137
        // WritableLoadOrder::save() could lead to libloadorder not setting all
138
        // the timestamps it needs to and producing an incorrect load order.
139
        set_file_times(
39✔
140
            self.data.path(),
39✔
141
            FileTime::from_system_time(SystemTime::now()),
39✔
142
            FileTime::from_system_time(time),
39✔
143
        )
39✔
144
        .map_err(|e| Error::IoError(self.data.path().to_path_buf(), e))?;
39✔
145

146
        self.modification_time = time;
39✔
147
        Ok(())
39✔
148
    }
39✔
149

150
    pub fn activate(&mut self) -> Result<(), Error> {
10,296✔
151
        if !self.is_active() {
10,296✔
152
            if self.data.path().is_ghosted() {
10,293✔
153
                let new_path = self.data.path().unghost()?;
1✔
154

155
                self.data = esplugin::Plugin::new(*self.data.game_id(), &new_path);
1✔
156
                self.data
1✔
157
                    .parse_file(true)
1✔
158
                    .map_err(|e| file_error(self.data.path(), e))?;
1✔
159
                let modification_time = self.modification_time();
1✔
160
                self.set_modification_time(modification_time)?;
1✔
161
            }
10,292✔
162

163
            self.active = true;
10,293✔
164
        }
3✔
165
        Ok(())
10,296✔
166
    }
10,296✔
167

168
    pub fn deactivate(&mut self) {
10,922✔
169
        self.active = false;
10,922✔
170
    }
10,922✔
171
}
172

173
pub fn has_plugin_extension(filename: &str, game: GameId) -> bool {
23,860✔
174
    let valid_extensions = if game.supports_light_plugins() {
23,860✔
175
        VALID_EXTENSIONS_WITH_ESL
22,442✔
176
    } else {
177
        VALID_EXTENSIONS
1,418✔
178
    };
179

180
    valid_extensions
23,860✔
181
        .iter()
23,860✔
182
        .any(|e| iends_with_ascii(filename, e))
105,576✔
183
}
23,860✔
184

185
fn iends_with_ascii(string: &str, suffix: &str) -> bool {
19,436,257✔
186
    // as_bytes().into_iter() is faster than bytes().
19,436,257✔
187
    string.len() >= suffix.len()
19,436,257✔
188
        && string
19,435,690✔
189
            .as_bytes()
19,435,690✔
190
            .iter()
19,435,690✔
191
            .rev()
19,435,690✔
192
            .zip(suffix.as_bytes().iter().rev())
19,435,690✔
193
            .all(|(string_byte, suffix_byte)| string_byte.eq_ignore_ascii_case(suffix_byte))
19,507,472✔
194
}
19,436,257✔
195

196
pub fn trim_dot_ghost(string: &str) -> &str {
19,330,681✔
197
    if iends_with_ascii(string, GHOST_FILE_EXTENSION) {
19,330,681✔
198
        &string[..(string.len() - GHOST_FILE_EXTENSION.len())]
14✔
199
    } else {
200
        string
19,330,667✔
201
    }
202
}
19,330,681✔
203

204
fn file_error(file_path: &Path, error: esplugin::Error) -> Error {
6✔
205
    match error {
6✔
206
        esplugin::Error::IoError(x) => Error::IoError(file_path.to_path_buf(), x),
6✔
207
        esplugin::Error::NoFilename => Error::NoFilename,
×
208
        esplugin::Error::ParsingIncomplete | esplugin::Error::ParsingError(_, _) => {
209
            Error::PluginParsingError(file_path.to_path_buf())
×
210
        }
211
        esplugin::Error::DecodeError => {
212
            Error::DecodeError("invalid byte sequence in plugin string".into())
×
213
        }
214
    }
215
}
6✔
216

217
#[cfg(test)]
218
mod tests {
219
    use super::*;
220

221
    use crate::tests::copy_to_test_dir;
222
    use std::path::{Path, PathBuf};
223
    use std::time::{Duration, UNIX_EPOCH};
224
    use tempfile::tempdir;
225

226
    fn game_settings(game_id: GameId, game_path: &Path) -> GameSettings {
12✔
227
        GameSettings::with_local_and_my_games_paths(
12✔
228
            game_id,
12✔
229
            game_path,
12✔
230
            &PathBuf::default(),
12✔
231
            PathBuf::default(),
12✔
232
        )
12✔
233
        .unwrap()
12✔
234
    }
12✔
235

236
    #[test]
1✔
237
    fn name_should_return_the_plugin_filename_without_any_ghost_extension() {
1✔
238
        let tmp_dir = tempdir().unwrap();
1✔
239
        let game_dir = tmp_dir.path();
1✔
240

1✔
241
        let settings = game_settings(GameId::Oblivion, &game_dir);
1✔
242

1✔
243
        copy_to_test_dir("Blank.esp", "Blank.esp", &settings);
1✔
244
        let plugin = Plugin::new("Blank.esp.ghost", &settings).unwrap();
1✔
245
        assert_eq!("Blank.esp", plugin.name());
1✔
246

247
        copy_to_test_dir("Blank.esp", "Blank.esp", &settings);
1✔
248
        let plugin = Plugin::new("Blank.esp", &settings).unwrap();
1✔
249
        assert_eq!("Blank.esp", plugin.name());
1✔
250

251
        copy_to_test_dir("Blank.esm", "Blank.esm.ghost", &settings);
1✔
252
        let plugin = Plugin::new("Blank.esm", &settings).unwrap();
1✔
253
        assert_eq!("Blank.esm", plugin.name());
1✔
254
    }
1✔
255

256
    #[test]
1✔
257
    fn name_matches_should_ignore_plugin_ghost_extension() {
1✔
258
        let tmp_dir = tempdir().unwrap();
1✔
259
        let settings = game_settings(GameId::Skyrim, tmp_dir.path());
1✔
260
        copy_to_test_dir("Blank.esp", "BlanK.esp.GHoSt", &settings);
1✔
261

1✔
262
        let plugin = Plugin::new("BlanK.esp.GHoSt", &settings).unwrap();
1✔
263
        assert!(plugin.name_matches("Blank.esp"));
1✔
264
    }
1✔
265

266
    #[test]
1✔
267
    fn name_matches_should_ignore_string_ghost_suffix() {
1✔
268
        let tmp_dir = tempdir().unwrap();
1✔
269
        let settings = game_settings(GameId::Skyrim, tmp_dir.path());
1✔
270
        copy_to_test_dir("Blank.esp", "BlanK.esp", &settings);
1✔
271

1✔
272
        let plugin = Plugin::new("BlanK.esp", &settings).unwrap();
1✔
273
        assert!(plugin.name_matches("Blank.esp.GHoSt"));
1✔
274
    }
1✔
275

276
    #[test]
1✔
277
    fn modification_time_should_return_the_plugin_modification_time_at_creation() {
1✔
278
        let tmp_dir = tempdir().unwrap();
1✔
279
        let game_dir = tmp_dir.path();
1✔
280

1✔
281
        let settings = game_settings(GameId::Oblivion, &game_dir);
1✔
282

1✔
283
        copy_to_test_dir("Blank.esp", "Blank.esp", &settings);
1✔
284
        let plugin_path = game_dir.join("Data").join("Blank.esp");
1✔
285
        let mtime = plugin_path.metadata().unwrap().modified().unwrap();
1✔
286

1✔
287
        let plugin = Plugin::new("Blank.esp", &settings).unwrap();
1✔
288
        assert_eq!(mtime, plugin.modification_time());
1✔
289
    }
1✔
290

291
    #[test]
1✔
292
    fn is_active_should_be_false() {
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_active());
1✔
302
    }
1✔
303

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

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

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

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

317
    #[test]
1✔
318
    fn is_master_file_should_be_false_if_the_plugin_is_not_a_master_file() {
1✔
319
        let tmp_dir = tempdir().unwrap();
1✔
320
        let game_dir = tmp_dir.path();
1✔
321

1✔
322
        let settings = game_settings(GameId::Oblivion, &game_dir);
1✔
323

1✔
324
        copy_to_test_dir("Blank.esp", "Blank.esp", &settings);
1✔
325
        let plugin = Plugin::new("Blank.esp", &settings).unwrap();
1✔
326

1✔
327
        assert!(!plugin.is_master_file());
1✔
328
    }
1✔
329

330
    #[test]
1✔
331
    fn is_light_plugin_should_be_true_for_esl_files_only() {
1✔
332
        let tmp_dir = tempdir().unwrap();
1✔
333
        let game_dir = tmp_dir.path();
1✔
334

1✔
335
        let settings = game_settings(GameId::SkyrimSE, &game_dir);
1✔
336

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

1✔
340
        assert!(!plugin.is_master_file());
1✔
341

342
        copy_to_test_dir("Blank.esm", "Blank.esm", &settings);
1✔
343
        let plugin = Plugin::new("Blank.esm", &settings).unwrap();
1✔
344

1✔
345
        assert!(!plugin.is_light_plugin());
1✔
346

347
        copy_to_test_dir("Blank.esm", "Blank.esl", &settings);
1✔
348
        let plugin = Plugin::new("Blank.esl", &settings).unwrap();
1✔
349

1✔
350
        assert!(plugin.is_light_plugin());
1✔
351

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

1✔
355
        assert!(plugin.is_light_plugin());
1✔
356
    }
1✔
357

358
    #[test]
1✔
359
    fn set_modification_time_should_update_the_file_modification_time() {
1✔
360
        let tmp_dir = tempdir().unwrap();
1✔
361
        let game_dir = tmp_dir.path();
1✔
362

1✔
363
        let settings = game_settings(GameId::Oblivion, &game_dir);
1✔
364

1✔
365
        copy_to_test_dir("Blank.esp", "Blank.esp", &settings);
1✔
366
        let mut plugin = Plugin::new("Blank.esp", &settings).unwrap();
1✔
367

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

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

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

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

1✔
389
        copy_to_test_dir("Blank.esp", "Blank.esp", &settings);
1✔
390
        let mut plugin = Plugin::new("Blank.esp", &settings).unwrap();
1✔
391
        let target_mtime = UNIX_EPOCH - Duration::from_secs(1);
1✔
392

1✔
393
        assert_ne!(target_mtime, plugin.modification_time());
1✔
394
        plugin.set_modification_time(target_mtime).unwrap();
1✔
395
        let new_mtime = game_dir
1✔
396
            .join("Data")
1✔
397
            .join("Blank.esp")
1✔
398
            .metadata()
1✔
399
            .unwrap()
1✔
400
            .modified()
1✔
401
            .unwrap();
1✔
402

1✔
403
        assert_eq!(target_mtime, plugin.modification_time());
1✔
404
        assert_eq!(target_mtime, new_mtime);
1✔
405
    }
1✔
406

407
    #[test]
1✔
408
    fn activate_should_unghost_a_ghosted_plugin() {
1✔
409
        let tmp_dir = tempdir().unwrap();
1✔
410
        let game_dir = tmp_dir.path();
1✔
411

1✔
412
        let settings = game_settings(GameId::Oblivion, &game_dir);
1✔
413

1✔
414
        copy_to_test_dir("Blank.esp", "Blank.esp.ghost", &settings);
1✔
415
        let mut plugin = Plugin::new("Blank.esp", &settings).unwrap();
1✔
416

1✔
417
        plugin.activate().unwrap();
1✔
418

1✔
419
        assert!(plugin.is_active());
1✔
420
        assert_eq!("Blank.esp", plugin.name());
1✔
421
        assert!(game_dir.join("Data").join("Blank.esp").exists());
1✔
422
    }
1✔
423

424
    #[test]
1✔
425
    fn deactivate_should_not_ghost_a_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", &settings);
1✔
432
        let mut plugin = Plugin::new("Blank.esp", &settings).unwrap();
1✔
433

1✔
434
        plugin.deactivate();
1✔
435

1✔
436
        assert!(!plugin.is_active());
1✔
437
        assert!(game_dir.join("Data").join("Blank.esp").exists());
1✔
438
    }
1✔
439
}
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