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

Ortham / libloadorder / 9669712859

25 Jun 2024 09:15PM UTC coverage: 91.599% (+4.7%) from 86.942%
9669712859

push

github

Ortham
Use Starfield plugins in Starfield tests

65 of 65 new or added lines in 5 files covered. (100.0%)

163 existing lines in 11 files now uncovered.

7218 of 7880 relevant lines covered (91.6%)

66616.03 hits per line

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

98.72
/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 unicase::eq;
25

26
use crate::enums::{Error, GameId};
27
use crate::game_settings::GameSettings;
28
use crate::ghostable_path::{GhostablePath, GHOST_FILE_EXTENSION};
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
#[derive(Clone, Debug)]
42
pub struct Plugin {
43
    active: bool,
44
    modification_time: SystemTime,
45
    data: esplugin::Plugin,
46
    name: String,
47
}
48

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

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

61
        let filepath = if active {
13,002✔
62
            filepath.unghost()?
10,890✔
63
        } else {
64
            filepath.resolve_path()?
2,112✔
65
        };
66

67
        Plugin::with_path(&filepath, game_settings.id(), active)
12,996✔
68
    }
13,002✔
69

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

76
        if !has_plugin_extension(filename, game_id) {
13,015✔
UNCOV
77
            return Err(Error::InvalidPath(path.to_path_buf()));
×
78
        }
13,015✔
79

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

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

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

98
    pub fn name(&self) -> &str {
19,276,481✔
99
        &self.name
19,276,481✔
100
    }
19,276,481✔
101

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

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

110
    pub fn is_active(&self) -> bool {
171,007✔
111
        self.active
171,007✔
112
    }
171,007✔
113

114
    pub fn is_master_file(&self) -> bool {
27,913,789✔
115
        self.data.is_master_file()
27,913,789✔
116
    }
27,913,789✔
117

118
    pub fn is_light_plugin(&self) -> bool {
157,954✔
119
        self.data.is_light_plugin()
157,954✔
120
    }
157,954✔
121

122
    pub fn is_override_plugin(&self) -> bool {
133,375✔
123
        self.data.is_override_plugin()
133,375✔
124
    }
133,375✔
125

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

132
    pub fn set_modification_time(&mut self, time: SystemTime) -> Result<(), Error> {
39✔
133
        // Always write the file time. This has a huge performance impact, but
39✔
134
        // is important for correctness, as otherwise external changes to plugin
39✔
135
        // timestamps between calls to WritableLoadOrder::load() and
39✔
136
        // WritableLoadOrder::save() could lead to libloadorder not setting all
39✔
137
        // the timestamps it needs to and producing an incorrect load order.
39✔
138
        let times = FileTimes::new()
39✔
139
            .set_accessed(SystemTime::now())
39✔
140
            .set_modified(time);
39✔
141

39✔
142
        File::options()
39✔
143
            .write(true)
39✔
144
            .open(self.data.path())
39✔
145
            .and_then(|f| f.set_times(times))
39✔
146
            .map_err(|e| Error::IoError(self.data.path().to_path_buf(), e))?;
39✔
147

148
        self.modification_time = time;
39✔
149
        Ok(())
39✔
150
    }
39✔
151

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

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

165
            self.active = true;
10,036✔
166
        }
4✔
167
        Ok(())
10,040✔
168
    }
10,040✔
169

170
    pub fn deactivate(&mut self) {
10,920✔
171
        self.active = false;
10,920✔
172
    }
10,920✔
173
}
174

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

182
    valid_extensions
23,878✔
183
        .iter()
23,878✔
184
        .any(|e| iends_with_ascii(filename, e))
105,607✔
185
}
23,878✔
186

187
fn iends_with_ascii(string: &str, suffix: &str) -> bool {
19,411,361✔
188
    // as_bytes().into_iter() is faster than bytes().
19,411,361✔
189
    string.len() >= suffix.len()
19,411,361✔
190
        && string
19,410,893✔
191
            .as_bytes()
19,410,893✔
192
            .iter()
19,410,893✔
193
            .rev()
19,410,893✔
194
            .zip(suffix.as_bytes().iter().rev())
19,410,893✔
195
            .all(|(string_byte, suffix_byte)| string_byte.eq_ignore_ascii_case(suffix_byte))
19,482,729✔
196
}
19,411,361✔
197

198
pub fn trim_dot_ghost(string: &str) -> &str {
19,305,754✔
199
    if iends_with_ascii(string, GHOST_FILE_EXTENSION) {
19,305,754✔
200
        &string[..(string.len() - GHOST_FILE_EXTENSION.len())]
14✔
201
    } else {
202
        string
19,305,740✔
203
    }
204
}
19,305,754✔
205

206
fn file_error(file_path: &Path, error: esplugin::Error) -> Error {
6✔
207
    match error {
6✔
208
        esplugin::Error::IoError(x) => Error::IoError(file_path.to_path_buf(), x),
6✔
UNCOV
209
        esplugin::Error::NoFilename(_) => Error::NoFilename(file_path.to_path_buf()),
×
UNCOV
210
        e => Error::PluginParsingError(file_path.to_path_buf(), Box::new(e)),
×
211
    }
212
}
6✔
213

214
#[cfg(test)]
215
mod tests {
216
    use super::*;
217

218
    use crate::tests::copy_to_test_dir;
219
    use std::path::{Path, PathBuf};
220
    use std::time::{Duration, UNIX_EPOCH};
221
    use tempfile::tempdir;
222

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

233
    #[test]
234
    fn name_should_return_the_plugin_filename_without_any_ghost_extension() {
1✔
235
        let tmp_dir = tempdir().unwrap();
1✔
236
        let game_dir = tmp_dir.path();
1✔
237

1✔
238
        let settings = game_settings(GameId::Oblivion, &game_dir);
1✔
239

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

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

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

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

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

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

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

273
    #[test]
274
    fn modification_time_should_return_the_plugin_modification_time_at_creation() {
1✔
275
        let tmp_dir = tempdir().unwrap();
1✔
276
        let game_dir = tmp_dir.path();
1✔
277

1✔
278
        let settings = game_settings(GameId::Oblivion, &game_dir);
1✔
279

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

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

288
    #[test]
289
    fn is_active_should_be_false() {
1✔
290
        let tmp_dir = tempdir().unwrap();
1✔
291
        let game_dir = tmp_dir.path();
1✔
292

1✔
293
        let settings = game_settings(GameId::Oblivion, &game_dir);
1✔
294

1✔
295
        copy_to_test_dir("Blank.esp", "Blank.esp", &settings);
1✔
296
        let plugin = Plugin::new("Blank.esp", &settings).unwrap();
1✔
297

1✔
298
        assert!(!plugin.is_active());
1✔
299
    }
1✔
300

301
    #[test]
302
    fn is_master_file_should_be_true_if_the_plugin_is_a_master_file() {
1✔
303
        let tmp_dir = tempdir().unwrap();
1✔
304
        let game_dir = tmp_dir.path();
1✔
305

1✔
306
        let settings = game_settings(GameId::Oblivion, &game_dir);
1✔
307

1✔
308
        copy_to_test_dir("Blank.esm", "Blank.esm", &settings);
1✔
309
        let plugin = Plugin::new("Blank.esm", &settings).unwrap();
1✔
310

1✔
311
        assert!(plugin.is_master_file());
1✔
312
    }
1✔
313

314
    #[test]
315
    fn is_master_file_should_be_false_if_the_plugin_is_not_a_master_file() {
1✔
316
        let tmp_dir = tempdir().unwrap();
1✔
317
        let game_dir = tmp_dir.path();
1✔
318

1✔
319
        let settings = game_settings(GameId::Oblivion, &game_dir);
1✔
320

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

1✔
324
        assert!(!plugin.is_master_file());
1✔
325
    }
1✔
326

327
    #[test]
328
    fn is_light_plugin_should_be_true_for_esl_files_only() {
1✔
329
        let tmp_dir = tempdir().unwrap();
1✔
330
        let game_dir = tmp_dir.path();
1✔
331

1✔
332
        let settings = game_settings(GameId::SkyrimSE, &game_dir);
1✔
333

1✔
334
        copy_to_test_dir("Blank.esp", "Blank.esp", &settings);
1✔
335
        let plugin = Plugin::new("Blank.esp", &settings).unwrap();
1✔
336

1✔
337
        assert!(!plugin.is_master_file());
1✔
338

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

1✔
342
        assert!(!plugin.is_light_plugin());
1✔
343

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

1✔
347
        assert!(plugin.is_light_plugin());
1✔
348

349
        copy_to_test_dir("Blank - Different.esp", "Blank - Different.esl", &settings);
1✔
350
        let plugin = Plugin::new("Blank - Different.esl", &settings).unwrap();
1✔
351

1✔
352
        assert!(plugin.is_light_plugin());
1✔
353
    }
1✔
354

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

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

1✔
362
        copy_to_test_dir("Blank.esp", "Blank.esp", &settings);
1✔
363

1✔
364
        let path = game_dir.join("Data").join("Blank.esp");
1✔
365
        let file_size = path.metadata().unwrap().len();
1✔
366

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

1✔
369
        assert_ne!(UNIX_EPOCH, plugin.modification_time());
1✔
370
        plugin.set_modification_time(UNIX_EPOCH).unwrap();
1✔
371

1✔
372
        let metadata = path.metadata().unwrap();
1✔
373
        let new_mtime = metadata.modified().unwrap();
1✔
374
        let new_size = metadata.len();
1✔
375

1✔
376
        assert_eq!(UNIX_EPOCH, plugin.modification_time());
1✔
377
        assert_eq!(UNIX_EPOCH, new_mtime);
1✔
378
        assert_eq!(file_size, new_size);
1✔
379
    }
1✔
380

381
    #[test]
382
    fn set_modification_time_should_be_able_to_handle_pre_unix_timestamps() {
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", &settings);
1✔
389
        let mut plugin = Plugin::new("Blank.esp", &settings).unwrap();
1✔
390
        let target_mtime = UNIX_EPOCH - Duration::from_secs(1);
1✔
391

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

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

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

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

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

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

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

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

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

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

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

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

© 2025 Coveralls, Inc