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

Ortham / libloadorder / 12999690240

27 Jan 2025 08:50PM UTC coverage: 92.257% (+0.1%) from 92.119%
12999690240

push

github

Ortham
Refactor getting local path and my games path

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

20 existing lines in 2 files now uncovered.

8245 of 8937 relevant lines covered (92.26%)

1325214.39 hits per line

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

98.88
/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
#[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> {
20,133✔
51
        Plugin::with_active(filename, game_settings, false)
20,133✔
52
    }
20,133✔
53

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

61
        use crate::ghostable_path::GhostablePath;
62
        let filepath = if active {
20,682✔
63
            filepath.unghost()?
366✔
64
        } else {
65
            filepath.resolve_path()?
20,316✔
66
        };
67

68
        Plugin::with_path(&filepath, game_settings.id(), active)
20,676✔
69
    }
20,682✔
70

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

77
        if !has_plugin_extension(filename, game_id) {
20,695✔
78
            return Err(Error::InvalidPath(path.to_path_buf()));
×
79
        }
20,695✔
80

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

87
        let mut data = esplugin::Plugin::new(game_id.to_esplugin_id(), path);
20,580✔
88
        data.parse_reader(file, ParseOptions::header_only())
20,580✔
89
            .map_err(|e| file_error(path, e))?;
20,580✔
90

91
        Ok(Plugin {
20,574✔
92
            active,
20,574✔
93
            modification_time,
20,574✔
94
            data,
20,574✔
95
            name: trim_dot_ghost(filename).to_string(),
20,574✔
96
        })
20,574✔
97
    }
20,695✔
98

99
    pub fn name(&self) -> &str {
377,554,160✔
100
        &self.name
377,554,160✔
101
    }
377,554,160✔
102

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

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

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

115
    pub fn is_master_file(&self) -> bool {
86,904,642✔
116
        self.data.is_master_file()
86,904,642✔
117
    }
86,904,642✔
118

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

123
    pub fn is_medium_plugin(&self) -> bool {
236,644✔
124
        self.data.is_medium_plugin()
236,644✔
125
    }
236,644✔
126

127
    pub fn is_blueprint_master(&self) -> bool {
694,056,705✔
128
        self.data.is_blueprint_plugin() && self.is_master_file()
694,056,705✔
129
    }
694,056,705✔
130

131
    pub fn masters(&self) -> Result<Vec<String>, Error> {
43,422,662✔
132
        self.data
43,422,662✔
133
            .masters()
43,422,662✔
134
            .map_err(|e| file_error(self.data.path(), e))
43,422,662✔
135
    }
43,422,662✔
136

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

39✔
147
        File::options()
39✔
148
            .write(true)
39✔
149
            .open(self.data.path())
39✔
150
            .and_then(|f| f.set_times(times))
39✔
151
            .map_err(|e| Error::IoError(self.data.path().to_path_buf(), e))?;
39✔
152

153
        self.modification_time = time;
39✔
154
        Ok(())
39✔
155
    }
39✔
156

157
    pub fn activate(&mut self) -> Result<(), Error> {
11,814✔
158
        if !self.is_active() {
11,814✔
159
            use crate::ghostable_path::GhostablePath;
11,813✔
160

161
            if self.data.path().has_ghost_extension() {
11,813✔
162
                let new_path = self.data.path().unghost()?;
1✔
163

164
                self.data = esplugin::Plugin::new(self.data.game_id(), &new_path);
1✔
165
                self.data
1✔
166
                    .parse_file(ParseOptions::header_only())
1✔
167
                    .map_err(|e| file_error(self.data.path(), e))?;
1✔
168
                let modification_time = self.modification_time();
1✔
169
                self.set_modification_time(modification_time)?;
1✔
170
            }
11,812✔
171

172
            self.active = true;
11,813✔
173
        }
1✔
174
        Ok(())
11,814✔
175
    }
11,814✔
176

177
    pub fn deactivate(&mut self) {
11,963✔
178
        self.active = false;
11,963✔
179
    }
11,963✔
180
}
181

182
pub fn has_plugin_extension(filename: &str, game: GameId) -> bool {
21,016✔
183
    let valid_extensions = if game.supports_light_plugins() {
21,016✔
184
        VALID_EXTENSIONS_WITH_ESL
19,642✔
185
    } else {
186
        VALID_EXTENSIONS
1,374✔
187
    };
188

189
    valid_extensions
21,016✔
190
        .iter()
21,016✔
191
        .any(|e| iends_with_ascii(filename, e))
41,170✔
192
}
21,016✔
193

194
fn iends_with_ascii(string: &str, suffix: &str) -> bool {
377,438,947✔
195
    // as_bytes().into_iter() is faster than bytes().
377,438,947✔
196
    string.len() >= suffix.len()
377,438,947✔
197
        && string
377,438,943✔
198
            .as_bytes()
377,438,943✔
199
            .iter()
377,438,943✔
200
            .rev()
377,438,943✔
201
            .zip(suffix.as_bytes().iter().rev())
377,438,943✔
202
            .all(|(string_byte, suffix_byte)| string_byte.eq_ignore_ascii_case(suffix_byte))
377,502,175✔
203
}
377,438,947✔
204

205
pub fn trim_dot_ghost(string: &str) -> &str {
377,397,777✔
206
    use crate::ghostable_path::GHOST_FILE_EXTENSION;
207

208
    if iends_with_ascii(string, GHOST_FILE_EXTENSION) {
377,397,777✔
209
        &string[..(string.len() - GHOST_FILE_EXTENSION.len())]
14✔
210
    } else {
211
        string
377,397,763✔
212
    }
213
}
377,397,777✔
214

215
fn file_error(file_path: &Path, error: esplugin::Error) -> Error {
6✔
216
    match error {
6✔
217
        esplugin::Error::IoError(x) => Error::IoError(file_path.to_path_buf(), x),
6✔
UNCOV
218
        esplugin::Error::NoFilename(_) => Error::NoFilename(file_path.to_path_buf()),
×
UNCOV
219
        e => Error::PluginParsingError(file_path.to_path_buf(), Box::new(e)),
×
220
    }
221
}
6✔
222

223
#[cfg(test)]
224
mod tests {
225
    use super::*;
226

227
    use crate::tests::copy_to_test_dir;
228
    use std::path::{Path, PathBuf};
229
    use std::time::{Duration, UNIX_EPOCH};
230
    use tempfile::tempdir;
231

232
    fn game_settings(game_id: GameId, game_path: &Path) -> GameSettings {
14✔
233
        GameSettings::with_local_and_my_games_paths(
14✔
234
            game_id,
14✔
235
            game_path,
14✔
236
            &PathBuf::default(),
14✔
237
            PathBuf::default(),
14✔
238
        )
14✔
239
        .unwrap()
14✔
240
    }
14✔
241

242
    #[test]
243
    fn with_active_should_unghost_active_ghosted_plugin_paths() {
1✔
244
        let tmp_dir = tempdir().unwrap();
1✔
245
        let game_dir = tmp_dir.path();
1✔
246

1✔
247
        let settings = game_settings(GameId::Oblivion, game_dir);
1✔
248

1✔
249
        let name = "Blank.esp";
1✔
250
        let ghosted_name = "Blank.esp.ghost";
1✔
251

1✔
252
        copy_to_test_dir(name, ghosted_name, &settings);
1✔
253
        let plugin = Plugin::with_active(ghosted_name, &settings, true).unwrap();
1✔
254

1✔
255
        assert_eq!(name, plugin.name());
1✔
256
        assert!(game_dir.join("Data").join(name).exists());
1✔
257
        assert!(!game_dir.join("Data").join(ghosted_name).exists());
1✔
258
    }
1✔
259

260
    #[test]
261
    fn with_active_should_resolve_inactive_ghosted_plugin_paths() {
1✔
262
        let tmp_dir = tempdir().unwrap();
1✔
263
        let game_dir = tmp_dir.path();
1✔
264

1✔
265
        let settings = game_settings(GameId::Oblivion, game_dir);
1✔
266

1✔
267
        let name = "Blank.esp";
1✔
268
        let ghosted_name = "Blank.esp.ghost";
1✔
269

1✔
270
        copy_to_test_dir(name, ghosted_name, &settings);
1✔
271
        let plugin = Plugin::with_active(ghosted_name, &settings, false).unwrap();
1✔
272

1✔
273
        assert_eq!(name, plugin.name());
1✔
274
        assert!(!game_dir.join("Data").join(name).exists());
1✔
275
        assert!(game_dir.join("Data").join(ghosted_name).exists());
1✔
276
    }
1✔
277

278
    #[test]
279
    fn name_should_return_the_plugin_filename_without_any_ghost_extension() {
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.esp", "Blank.esp", &settings);
1✔
286
        let plugin = Plugin::new("Blank.esp.ghost", &settings).unwrap();
1✔
287
        assert_eq!("Blank.esp", plugin.name());
1✔
288

289
        copy_to_test_dir("Blank.esp", "Blank.esp", &settings);
1✔
290
        let plugin = Plugin::new("Blank.esp", &settings).unwrap();
1✔
291
        assert_eq!("Blank.esp", plugin.name());
1✔
292

293
        copy_to_test_dir("Blank.esm", "Blank.esm.ghost", &settings);
1✔
294
        let plugin = Plugin::new("Blank.esm", &settings).unwrap();
1✔
295
        assert_eq!("Blank.esm", plugin.name());
1✔
296
    }
1✔
297

298
    #[test]
299
    fn name_matches_should_ignore_plugin_ghost_extension() {
1✔
300
        let tmp_dir = tempdir().unwrap();
1✔
301
        let settings = game_settings(GameId::Skyrim, tmp_dir.path());
1✔
302
        copy_to_test_dir("Blank.esp", "BlanK.esp.GHoSt", &settings);
1✔
303

1✔
304
        let plugin = Plugin::new("BlanK.esp.GHoSt", &settings).unwrap();
1✔
305
        assert!(plugin.name_matches("Blank.esp"));
1✔
306
    }
1✔
307

308
    #[test]
309
    fn name_matches_should_ignore_string_ghost_suffix() {
1✔
310
        let tmp_dir = tempdir().unwrap();
1✔
311
        let settings = game_settings(GameId::Skyrim, tmp_dir.path());
1✔
312
        copy_to_test_dir("Blank.esp", "BlanK.esp", &settings);
1✔
313

1✔
314
        let plugin = Plugin::new("BlanK.esp", &settings).unwrap();
1✔
315
        assert!(plugin.name_matches("Blank.esp.GHoSt"));
1✔
316
    }
1✔
317

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

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

1✔
325
        copy_to_test_dir("Blank.esp", "Blank.esp", &settings);
1✔
326
        let plugin_path = game_dir.join("Data").join("Blank.esp");
1✔
327
        let mtime = plugin_path.metadata().unwrap().modified().unwrap();
1✔
328

1✔
329
        let plugin = Plugin::new("Blank.esp", &settings).unwrap();
1✔
330
        assert_eq!(mtime, plugin.modification_time());
1✔
331
    }
1✔
332

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

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

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

1✔
343
        assert!(!plugin.is_active());
1✔
344
    }
1✔
345

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

1✔
351
        let settings = game_settings(GameId::Oblivion, game_dir);
1✔
352

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

1✔
356
        assert!(plugin.is_master_file());
1✔
357
    }
1✔
358

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

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

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

1✔
369
        assert!(!plugin.is_master_file());
1✔
370
    }
1✔
371

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

1✔
377
        let settings = game_settings(GameId::SkyrimSE, game_dir);
1✔
378

1✔
379
        copy_to_test_dir("Blank.esp", "Blank.esp", &settings);
1✔
380
        let plugin = Plugin::new("Blank.esp", &settings).unwrap();
1✔
381

1✔
382
        assert!(!plugin.is_master_file());
1✔
383

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

1✔
387
        assert!(!plugin.is_light_plugin());
1✔
388

389
        copy_to_test_dir("Blank.esm", "Blank.esl", &settings);
1✔
390
        let plugin = Plugin::new("Blank.esl", &settings).unwrap();
1✔
391

1✔
392
        assert!(plugin.is_light_plugin());
1✔
393

394
        copy_to_test_dir("Blank - Different.esp", "Blank - Different.esl", &settings);
1✔
395
        let plugin = Plugin::new("Blank - Different.esl", &settings).unwrap();
1✔
396

1✔
397
        assert!(plugin.is_light_plugin());
1✔
398
    }
1✔
399

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

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

1✔
407
        copy_to_test_dir("Blank.esp", "Blank.esp", &settings);
1✔
408

1✔
409
        let path = game_dir.join("Data").join("Blank.esp");
1✔
410
        let file_size = path.metadata().unwrap().len();
1✔
411

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

1✔
414
        assert_ne!(UNIX_EPOCH, plugin.modification_time());
1✔
415
        plugin.set_modification_time(UNIX_EPOCH).unwrap();
1✔
416

1✔
417
        let metadata = path.metadata().unwrap();
1✔
418
        let new_mtime = metadata.modified().unwrap();
1✔
419
        let new_size = metadata.len();
1✔
420

1✔
421
        assert_eq!(UNIX_EPOCH, plugin.modification_time());
1✔
422
        assert_eq!(UNIX_EPOCH, new_mtime);
1✔
423
        assert_eq!(file_size, new_size);
1✔
424
    }
1✔
425

426
    #[test]
427
    fn set_modification_time_should_be_able_to_handle_pre_unix_timestamps() {
1✔
428
        let tmp_dir = tempdir().unwrap();
1✔
429
        let game_dir = tmp_dir.path();
1✔
430

1✔
431
        let settings = game_settings(GameId::Oblivion, game_dir);
1✔
432

1✔
433
        copy_to_test_dir("Blank.esp", "Blank.esp", &settings);
1✔
434
        let mut plugin = Plugin::new("Blank.esp", &settings).unwrap();
1✔
435
        let target_mtime = UNIX_EPOCH - Duration::from_secs(1);
1✔
436

1✔
437
        assert_ne!(target_mtime, plugin.modification_time());
1✔
438
        plugin.set_modification_time(target_mtime).unwrap();
1✔
439
        let new_mtime = game_dir
1✔
440
            .join("Data")
1✔
441
            .join("Blank.esp")
1✔
442
            .metadata()
1✔
443
            .unwrap()
1✔
444
            .modified()
1✔
445
            .unwrap();
1✔
446

1✔
447
        assert_eq!(target_mtime, plugin.modification_time());
1✔
448
        assert_eq!(target_mtime, new_mtime);
1✔
449
    }
1✔
450

451
    #[test]
452
    fn activate_should_unghost_a_ghosted_plugin() {
1✔
453
        let tmp_dir = tempdir().unwrap();
1✔
454
        let game_dir = tmp_dir.path();
1✔
455

1✔
456
        let settings = game_settings(GameId::Oblivion, game_dir);
1✔
457

1✔
458
        copy_to_test_dir("Blank.esp", "Blank.esp.ghost", &settings);
1✔
459
        let mut plugin = Plugin::new("Blank.esp", &settings).unwrap();
1✔
460

1✔
461
        plugin.activate().unwrap();
1✔
462

1✔
463
        assert!(plugin.is_active());
1✔
464
        assert_eq!("Blank.esp", plugin.name());
1✔
465
        assert!(game_dir.join("Data").join("Blank.esp").exists());
1✔
466
    }
1✔
467

468
    #[test]
469
    fn deactivate_should_not_ghost_a_plugin() {
1✔
470
        let tmp_dir = tempdir().unwrap();
1✔
471
        let game_dir = tmp_dir.path();
1✔
472

1✔
473
        let settings = game_settings(GameId::Oblivion, game_dir);
1✔
474

1✔
475
        copy_to_test_dir("Blank.esp", "Blank.esp", &settings);
1✔
476
        let mut plugin = Plugin::new("Blank.esp", &settings).unwrap();
1✔
477

1✔
478
        plugin.deactivate();
1✔
479

1✔
480
        assert!(!plugin.is_active());
1✔
481
        assert!(game_dir.join("Data").join("Blank.esp").exists());
1✔
482
    }
1✔
483

484
    #[test]
485
    fn trim_dot_ghost_should_trim_the_ghost_extension() {
1✔
486
        let ghosted = "plugin.esp.ghost";
1✔
487
        let unghosted = "plugin.esp";
1✔
488

1✔
489
        assert_eq!(unghosted, trim_dot_ghost(ghosted));
1✔
490
        assert_eq!(unghosted, trim_dot_ghost(unghosted));
1✔
491
    }
1✔
492
}
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