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

Ortham / libloadorder / 9726811617

29 Jun 2024 08:15PM UTC coverage: 91.407% (-0.4%) from 91.807%
9726811617

push

github

Ortham
Set versions and changelogs for 17.0.1

7350 of 8041 relevant lines covered (91.41%)

172485.52 hits per line

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

95.99
/src/load_order/timestamp_based.rs
1
/*
2
 * This file is part of libloadorder
3
 *
4
 * Copyright (C) 2017 Oliver Hamlet
5
 *
6
 * libloadorder is free software: you can redistribute it and/or modify
7
 * it under the terms of the GNU General Public License as published by
8
 * the Free Software Foundation, either version 3 of the License, or
9
 * (at your option) any later version.
10
 *
11
 * libloadorder is distributed in the hope that it will be useful,
12
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
 * GNU General Public License for more details.
15
 *
16
 * You should have received a copy of the GNU General Public License
17
 * along with libloadorder. If not, see <http://www.gnu.org/licenses/>.
18
 */
19
use std::cmp::Ordering;
20
use std::fs::File;
21
use std::io::{BufRead, BufReader, BufWriter, Write};
22
use std::time::{Duration, SystemTime, UNIX_EPOCH};
23

24
use rayon::prelude::*;
25
use regex::Regex;
26

27
use super::mutable::{hoist_masters, load_active_plugins, MutableLoadOrder};
28
use super::readable::{ReadableLoadOrder, ReadableLoadOrderBase};
29
use super::strict_encode;
30
use super::writable::{
31
    activate, add, create_parent_dirs, deactivate, remove, set_active_plugins, WritableLoadOrder,
32
};
33
use crate::enums::{Error, GameId};
34
use crate::game_settings::GameSettings;
35
use crate::plugin::Plugin;
36

37
const GAME_FILES_HEADER: &[u8] = b"[Game Files]";
38

39
#[derive(Clone, Debug)]
40
pub struct TimestampBasedLoadOrder {
41
    game_settings: GameSettings,
42
    plugins: Vec<Plugin>,
43
}
44

45
impl TimestampBasedLoadOrder {
46
    pub fn new(game_settings: GameSettings) -> Self {
2✔
47
        Self {
2✔
48
            game_settings,
2✔
49
            plugins: Vec::new(),
2✔
50
        }
2✔
51
    }
2✔
52

53
    fn load_plugins_from_dir(&self) -> Vec<Plugin> {
17✔
54
        let filenames = self.find_plugins();
17✔
55
        let game_settings = self.game_settings();
17✔
56

17✔
57
        filenames
17✔
58
            .par_iter()
17✔
59
            .filter_map(|f| Plugin::new(f, game_settings).ok())
91✔
60
            .collect()
17✔
61
    }
17✔
62

63
    fn save_active_plugins(&mut self) -> Result<(), Error> {
6✔
64
        let path = self.game_settings().active_plugins_file();
6✔
65
        create_parent_dirs(path)?;
6✔
66

67
        let prelude = get_file_prelude(self.game_settings())?;
6✔
68

69
        let file = File::create(path).map_err(|e| Error::IoError(path.clone(), e))?;
6✔
70
        let mut writer = BufWriter::new(file);
6✔
71
        writer
6✔
72
            .write_all(&prelude)
6✔
73
            .map_err(|e| Error::IoError(path.clone(), e))?;
6✔
74
        for (index, plugin_name) in self.active_plugin_names().iter().enumerate() {
6✔
75
            if self.game_settings().id() == GameId::Morrowind {
5✔
76
                write!(writer, "GameFile{}=", index)
1✔
77
                    .map_err(|e| Error::IoError(path.clone(), e))?;
1✔
78
            }
4✔
79
            writer
5✔
80
                .write_all(&strict_encode(plugin_name)?)
5✔
81
                .map_err(|e| Error::IoError(path.clone(), e))?;
4✔
82
            writeln!(writer).map_err(|e| Error::IoError(path.clone(), e))?;
4✔
83
        }
84

85
        Ok(())
5✔
86
    }
6✔
87
}
88

89
impl ReadableLoadOrderBase for TimestampBasedLoadOrder {
90
    fn game_settings_base(&self) -> &GameSettings {
166✔
91
        &self.game_settings
166✔
92
    }
166✔
93

94
    fn plugins(&self) -> &[Plugin] {
49✔
95
        &self.plugins
49✔
96
    }
49✔
97
}
98

99
impl MutableLoadOrder for TimestampBasedLoadOrder {
100
    fn plugins_mut(&mut self) -> &mut Vec<Plugin> {
60✔
101
        &mut self.plugins
60✔
102
    }
60✔
103
}
104

105
impl WritableLoadOrder for TimestampBasedLoadOrder {
106
    fn game_settings_mut(&mut self) -> &mut GameSettings {
1✔
107
        &mut self.game_settings
1✔
108
    }
1✔
109

110
    fn load(&mut self) -> Result<(), Error> {
17✔
111
        self.plugins_mut().clear();
17✔
112

17✔
113
        self.plugins = self.load_plugins_from_dir();
17✔
114
        self.plugins.par_sort_by(plugin_sorter);
17✔
115

17✔
116
        let regex = Regex::new(r"(?i)GameFile[0-9]{1,3}=(.+\.es(?:m|p))")
17✔
117
            .expect("Hardcoded GameFile ini entry regex should be valid");
17✔
118
        let game_id = self.game_settings().id();
17✔
119
        let line_mapper = |line: &str| plugin_line_mapper(line, &regex, game_id);
20✔
120

121
        load_active_plugins(self, line_mapper)?;
17✔
122

123
        self.add_implicitly_active_plugins()?;
17✔
124

125
        hoist_masters(&mut self.plugins)?;
17✔
126

127
        Ok(())
17✔
128
    }
17✔
129

130
    fn save(&mut self) -> Result<(), Error> {
6✔
131
        save_load_order_using_timestamps(self)?;
6✔
132

133
        self.save_active_plugins()
6✔
134
    }
6✔
135

136
    fn add(&mut self, plugin_name: &str) -> Result<usize, Error> {
×
137
        add(self, plugin_name)
×
138
    }
×
139

140
    fn remove(&mut self, plugin_name: &str) -> Result<(), Error> {
×
141
        remove(self, plugin_name)
×
142
    }
×
143

144
    fn set_load_order(&mut self, plugin_names: &[&str]) -> Result<(), Error> {
×
145
        self.replace_plugins(plugin_names)
×
146
    }
×
147

148
    fn set_plugin_index(&mut self, plugin_name: &str, position: usize) -> Result<usize, Error> {
×
149
        MutableLoadOrder::set_plugin_index(self, plugin_name, position)
×
150
    }
×
151

152
    fn is_self_consistent(&self) -> Result<bool, Error> {
3✔
153
        Ok(true)
3✔
154
    }
3✔
155

156
    /// A timestamp-based load order is never ambiguous, as even if two or more plugins share the
157
    /// same timestamp, they load in descending filename order.
158
    fn is_ambiguous(&self) -> Result<bool, Error> {
2✔
159
        Ok(false)
2✔
160
    }
2✔
161

162
    fn activate(&mut self, plugin_name: &str) -> Result<(), Error> {
×
163
        activate(self, plugin_name)
×
164
    }
×
165

166
    fn deactivate(&mut self, plugin_name: &str) -> Result<(), Error> {
×
167
        deactivate(self, plugin_name)
×
168
    }
×
169

170
    fn set_active_plugins(&mut self, active_plugin_names: &[&str]) -> Result<(), Error> {
×
171
        set_active_plugins(self, active_plugin_names)
×
172
    }
×
173
}
174

175
pub fn save_load_order_using_timestamps<T: MutableLoadOrder>(
7✔
176
    load_order: &mut T,
7✔
177
) -> Result<(), Error> {
7✔
178
    let timestamps = padded_unique_timestamps(load_order.plugins());
7✔
179

7✔
180
    load_order
7✔
181
        .plugins_mut()
7✔
182
        .par_iter_mut()
7✔
183
        .zip(timestamps.into_par_iter())
7✔
184
        .map(|(ref mut plugin, timestamp)| plugin.set_modification_time(timestamp))
28✔
185
        .collect::<Result<Vec<_>, Error>>()
7✔
186
        .map(|_| ())
7✔
187
}
7✔
188

189
fn plugin_sorter(a: &Plugin, b: &Plugin) -> Ordering {
91✔
190
    if a.is_master_file() == b.is_master_file() {
91✔
191
        match a.modification_time().cmp(&b.modification_time()) {
60✔
192
            Ordering::Equal => a.name().cmp(b.name()).reverse(),
2✔
193
            x => x,
58✔
194
        }
195
    } else if a.is_master_file() {
31✔
196
        Ordering::Less
17✔
197
    } else {
198
        Ordering::Greater
14✔
199
    }
200
}
91✔
201

202
fn plugin_line_mapper(mut line: &str, regex: &Regex, game_id: GameId) -> Option<String> {
20✔
203
    if game_id == GameId::Morrowind {
20✔
204
        line = regex
7✔
205
            .captures(line)
7✔
206
            .and_then(|c| c.get(1))
7✔
207
            .map_or("", |m| m.as_str());
7✔
208
    }
13✔
209

210
    if line.is_empty() || line.starts_with('#') {
20✔
211
        None
5✔
212
    } else {
213
        Some(line.to_owned())
15✔
214
    }
215
}
20✔
216

217
fn padded_unique_timestamps(plugins: &[Plugin]) -> Vec<SystemTime> {
7✔
218
    let mut timestamps: Vec<SystemTime> = plugins.iter().map(Plugin::modification_time).collect();
7✔
219

7✔
220
    timestamps.sort();
7✔
221
    timestamps.dedup();
7✔
222

223
    while timestamps.len() < plugins.len() {
8✔
224
        let timestamp = *timestamps.last().unwrap_or(&UNIX_EPOCH) + Duration::from_secs(60);
1✔
225
        timestamps.push(timestamp);
1✔
226
    }
1✔
227

228
    timestamps
7✔
229
}
7✔
230

231
fn get_file_prelude(game_settings: &GameSettings) -> Result<Vec<u8>, Error> {
6✔
232
    let mut prelude: Vec<u8> = Vec::new();
6✔
233

6✔
234
    let path = game_settings.active_plugins_file();
6✔
235

6✔
236
    if game_settings.id() == GameId::Morrowind && path.exists() {
6✔
237
        let input = File::open(path).map_err(|e| Error::IoError(path.clone(), e))?;
1✔
238
        let buffered = BufReader::new(input);
1✔
239

240
        for line in buffered.split(b'\n') {
2✔
241
            let line = line.map_err(|e| Error::IoError(path.clone(), e))?;
2✔
242
            prelude.append(&mut line.clone());
2✔
243
            prelude.push(b'\n');
2✔
244

2✔
245
            if line.starts_with(GAME_FILES_HEADER) {
2✔
246
                break;
1✔
247
            }
1✔
248
        }
249
    }
5✔
250

251
    Ok(prelude)
6✔
252
}
6✔
253

254
#[cfg(test)]
255
mod tests {
256
    use super::*;
257

258
    use crate::enums::GameId;
259
    use crate::load_order::tests::*;
260
    use crate::tests::copy_to_test_dir;
261
    use std::convert::TryInto;
262
    use std::fs::{remove_dir_all, File};
263
    use std::io::{Read, Write};
264
    use std::path::Path;
265
    use tempfile::tempdir;
266

267
    fn prepare(game_id: GameId, game_dir: &Path) -> TimestampBasedLoadOrder {
22✔
268
        let (game_settings, plugins) = mock_game_files(game_id, game_dir);
22✔
269
        TimestampBasedLoadOrder {
22✔
270
            game_settings,
22✔
271
            plugins,
22✔
272
        }
22✔
273
    }
22✔
274

275
    fn write_file(path: &Path) {
2✔
276
        let mut file = File::create(&path).unwrap();
2✔
277
        writeln!(file).unwrap();
2✔
278
    }
2✔
279

280
    #[test]
281
    fn load_should_reload_existing_plugins() {
1✔
282
        let tmp_dir = tempdir().unwrap();
1✔
283
        let mut load_order = prepare(GameId::Oblivion, &tmp_dir.path());
1✔
284

1✔
285
        assert!(!load_order.plugins()[1].is_master_file());
1✔
286
        copy_to_test_dir("Blank.esm", "Blank.esp", &load_order.game_settings());
1✔
287
        let plugin_path = load_order
1✔
288
            .game_settings()
1✔
289
            .plugins_directory()
1✔
290
            .join("Blank.esp");
1✔
291
        set_file_timestamps(&plugin_path, 0);
1✔
292

1✔
293
        load_order.load().unwrap();
1✔
294

1✔
295
        assert!(load_order.plugins()[1].is_master_file());
1✔
296
    }
1✔
297

298
    #[test]
299
    fn load_should_remove_plugins_that_fail_to_load() {
1✔
300
        let tmp_dir = tempdir().unwrap();
1✔
301
        let mut load_order = prepare(GameId::Oblivion, &tmp_dir.path());
1✔
302

1✔
303
        assert!(load_order.index_of("Blank.esp").is_some());
1✔
304
        assert!(load_order.index_of("Blank - Different.esp").is_some());
1✔
305

306
        let plugin_path = load_order
1✔
307
            .game_settings()
1✔
308
            .plugins_directory()
1✔
309
            .join("Blank.esp");
1✔
310
        write_file(&plugin_path);
1✔
311
        set_file_timestamps(&plugin_path, 0);
1✔
312

1✔
313
        let plugin_path = load_order
1✔
314
            .game_settings()
1✔
315
            .plugins_directory()
1✔
316
            .join("Blank - Different.esp");
1✔
317
        write_file(&plugin_path);
1✔
318
        set_file_timestamps(&plugin_path, 0);
1✔
319

1✔
320
        load_order.load().unwrap();
1✔
321
        assert!(load_order.index_of("Blank.esp").is_none());
1✔
322
        assert!(load_order.index_of("Blank - Different.esp").is_none());
1✔
323
    }
1✔
324

325
    #[test]
326
    fn load_should_sort_installed_plugins_into_their_timestamp_order_with_master_files_first() {
1✔
327
        let tmp_dir = tempdir().unwrap();
1✔
328
        let mut load_order = prepare(GameId::Oblivion, &tmp_dir.path());
1✔
329

1✔
330
        set_timestamps(
1✔
331
            &load_order.game_settings().plugins_directory(),
1✔
332
            &[
1✔
333
                "Blank - Master Dependent.esp",
1✔
334
                "Blank.esm",
1✔
335
                "Blank - Different.esp",
1✔
336
                "Blank.esp",
1✔
337
                load_order.game_settings().master_file(),
1✔
338
            ],
1✔
339
        );
1✔
340

1✔
341
        load_order.load().unwrap();
1✔
342

1✔
343
        let expected_filenames = vec![
1✔
344
            "Blank.esm",
1✔
345
            load_order.game_settings().master_file(),
1✔
346
            "Blank - Master Dependent.esp",
1✔
347
            "Blank - Different.esp",
1✔
348
            "Blank.esp",
1✔
349
            "Blàñk.esp",
1✔
350
        ];
1✔
351

1✔
352
        assert_eq!(expected_filenames, load_order.plugin_names());
1✔
353
    }
1✔
354

355
    #[test]
356
    fn load_should_hoist_masters_that_masters_depend_on_to_load_before_their_dependents() {
1✔
357
        let tmp_dir = tempdir().unwrap();
1✔
358
        let mut load_order = prepare(GameId::Oblivion, &tmp_dir.path());
1✔
359

1✔
360
        let master_dependent_master = "Blank - Master Dependent.esm";
1✔
361
        copy_to_test_dir(
1✔
362
            master_dependent_master,
1✔
363
            master_dependent_master,
1✔
364
            load_order.game_settings(),
1✔
365
        );
1✔
366

1✔
367
        let filenames = vec![
1✔
368
            "Blank - Master Dependent.esm",
1✔
369
            "Blank - Master Dependent.esp",
1✔
370
            "Blank.esm",
1✔
371
            "Blank - Different.esp",
1✔
372
            "Blàñk.esp",
1✔
373
            "Blank.esp",
1✔
374
            "Oblivion.esm",
1✔
375
        ];
1✔
376
        set_timestamps(&load_order.game_settings().plugins_directory(), &filenames);
1✔
377

1✔
378
        load_order.load().unwrap();
1✔
379

1✔
380
        let expected_filenames = vec![
1✔
381
            "Blank.esm",
1✔
382
            "Blank - Master Dependent.esm",
1✔
383
            "Oblivion.esm",
1✔
384
            "Blank - Master Dependent.esp",
1✔
385
            "Blank - Different.esp",
1✔
386
            "Blàñk.esp",
1✔
387
            "Blank.esp",
1✔
388
        ];
1✔
389

1✔
390
        assert_eq!(expected_filenames, load_order.plugin_names());
1✔
391
    }
1✔
392

393
    #[test]
394
    fn load_should_empty_the_load_order_if_the_plugins_directory_does_not_exist() {
1✔
395
        let tmp_dir = tempdir().unwrap();
1✔
396
        let mut load_order = prepare(GameId::Oblivion, &tmp_dir.path());
1✔
397
        tmp_dir.close().unwrap();
1✔
398

1✔
399
        load_order.load().unwrap();
1✔
400

1✔
401
        assert!(load_order.plugins().is_empty());
1✔
402
    }
1✔
403

404
    #[test]
405
    fn load_should_decode_active_plugins_file_from_windows_1252() {
1✔
406
        let tmp_dir = tempdir().unwrap();
1✔
407
        let mut load_order = prepare(GameId::Oblivion, &tmp_dir.path());
1✔
408

1✔
409
        write_active_plugins_file(load_order.game_settings(), &["Blàñk.esp", "Blank.esm"]);
1✔
410

1✔
411
        load_order.load().unwrap();
1✔
412
        let expected_filenames = vec!["Blank.esm", "Blàñk.esp"];
1✔
413

1✔
414
        assert_eq!(expected_filenames, load_order.active_plugin_names());
1✔
415
    }
1✔
416

417
    #[test]
418
    fn load_should_handle_crlf_and_lf_in_active_plugins_file() {
1✔
419
        let tmp_dir = tempdir().unwrap();
1✔
420
        let mut load_order = prepare(GameId::Oblivion, &tmp_dir.path());
1✔
421

1✔
422
        write_active_plugins_file(load_order.game_settings(), &["Blàñk.esp", "Blank.esm\r"]);
1✔
423

1✔
424
        load_order.load().unwrap();
1✔
425
        let expected_filenames = vec!["Blank.esm", "Blàñk.esp"];
1✔
426

1✔
427
        assert_eq!(expected_filenames, load_order.active_plugin_names());
1✔
428
    }
1✔
429

430
    #[test]
431
    fn load_should_ignore_active_plugins_file_lines_starting_with_a_hash_for_oblivion() {
1✔
432
        let tmp_dir = tempdir().unwrap();
1✔
433
        let mut load_order = prepare(GameId::Oblivion, &tmp_dir.path());
1✔
434

1✔
435
        write_active_plugins_file(
1✔
436
            load_order.game_settings(),
1✔
437
            &["#Blank.esp", "Blàñk.esp", "Blank.esm"],
1✔
438
        );
1✔
439

1✔
440
        load_order.load().unwrap();
1✔
441
        let expected_filenames = vec!["Blank.esm", "Blàñk.esp"];
1✔
442

1✔
443
        assert_eq!(expected_filenames, load_order.active_plugin_names());
1✔
444
    }
1✔
445

446
    #[test]
447
    fn load_should_ignore_plugins_in_active_plugins_file_that_are_not_installed() {
1✔
448
        let tmp_dir = tempdir().unwrap();
1✔
449
        let mut load_order = prepare(GameId::Oblivion, &tmp_dir.path());
1✔
450

1✔
451
        write_active_plugins_file(
1✔
452
            load_order.game_settings(),
1✔
453
            &["Blàñk.esp", "Blank.esm", "missing.esp"],
1✔
454
        );
1✔
455

1✔
456
        load_order.load().unwrap();
1✔
457
        let expected_filenames = vec!["Blank.esm", "Blàñk.esp"];
1✔
458

1✔
459
        assert_eq!(expected_filenames, load_order.active_plugin_names());
1✔
460
    }
1✔
461

462
    #[test]
463
    fn load_should_load_plugin_states_from_active_plugins_file_for_oblivion() {
1✔
464
        let tmp_dir = tempdir().unwrap();
1✔
465
        let mut load_order = prepare(GameId::Oblivion, &tmp_dir.path());
1✔
466

1✔
467
        write_active_plugins_file(load_order.game_settings(), &["Blàñk.esp", "Blank.esm"]);
1✔
468

1✔
469
        load_order.load().unwrap();
1✔
470
        let expected_filenames = vec!["Blank.esm", "Blàñk.esp"];
1✔
471

1✔
472
        assert_eq!(expected_filenames, load_order.active_plugin_names());
1✔
473
    }
1✔
474

475
    #[test]
476
    fn load_should_succeed_when_active_plugins_file_is_missing() {
1✔
477
        let tmp_dir = tempdir().unwrap();
1✔
478
        let mut load_order = prepare(GameId::Oblivion, &tmp_dir.path());
1✔
479

1✔
480
        assert!(load_order.load().is_ok());
1✔
481
        assert!(load_order.active_plugin_names().is_empty());
1✔
482
    }
1✔
483

484
    #[test]
485
    fn load_should_load_plugin_states_from_active_plugins_file_for_morrowind() {
1✔
486
        let tmp_dir = tempdir().unwrap();
1✔
487
        let mut load_order = prepare(GameId::Morrowind, &tmp_dir.path());
1✔
488

1✔
489
        write_active_plugins_file(load_order.game_settings(), &["Blàñk.esp", "Blank.esm"]);
1✔
490

1✔
491
        load_order.load().unwrap();
1✔
492
        let expected_filenames = vec!["Blank.esm", "Blàñk.esp"];
1✔
493

1✔
494
        assert_eq!(expected_filenames, load_order.active_plugin_names());
1✔
495
    }
1✔
496

497
    #[test]
498
    fn save_should_preserve_the_existing_set_of_timestamps() {
1✔
499
        let tmp_dir = tempdir().unwrap();
1✔
500
        let mut load_order = prepare(GameId::Oblivion, &tmp_dir.path());
1✔
501

1✔
502
        let mapper = |p: &Plugin| {
12✔
503
            p.modification_time()
12✔
504
                .duration_since(UNIX_EPOCH)
12✔
505
                .unwrap()
12✔
506
                .as_secs()
12✔
507
        };
12✔
508

509
        set_timestamps(
1✔
510
            &load_order.game_settings().plugins_directory(),
1✔
511
            &[
1✔
512
                "Blank - Master Dependent.esp",
1✔
513
                "Blank.esm",
1✔
514
                "Blank - Different.esp",
1✔
515
                "Blank.esp",
1✔
516
                load_order.game_settings().master_file(),
1✔
517
            ],
1✔
518
        );
1✔
519

1✔
520
        load_order.load().unwrap();
1✔
521

1✔
522
        let mut old_timestamps: Vec<u64> = load_order.plugins().iter().map(&mapper).collect();
1✔
523
        old_timestamps.sort();
1✔
524

1✔
525
        load_order.save().unwrap();
1✔
526

1✔
527
        let timestamps: Vec<u64> = load_order.plugins().iter().map(&mapper).collect();
1✔
528

1✔
529
        assert_eq!(old_timestamps, timestamps);
1✔
530
    }
1✔
531

532
    #[test]
533
    fn save_should_deduplicate_plugin_timestamps() {
1✔
534
        let tmp_dir = tempdir().unwrap();
1✔
535
        let mut load_order = prepare(GameId::Oblivion, &tmp_dir.path());
1✔
536

1✔
537
        let mapper = |p: &Plugin| {
12✔
538
            p.modification_time()
12✔
539
                .duration_since(UNIX_EPOCH)
12✔
540
                .unwrap()
12✔
541
                .as_secs()
12✔
542
        };
12✔
543

544
        set_timestamps(
1✔
545
            &load_order.game_settings().plugins_directory(),
1✔
546
            &[
1✔
547
                "Blank - Master Dependent.esp",
1✔
548
                "Blank.esm",
1✔
549
                "Blank - Different.esp",
1✔
550
                "Blank.esp",
1✔
551
                load_order.game_settings().master_file(),
1✔
552
            ],
1✔
553
        );
1✔
554

1✔
555
        // Give two files the same timestamp.
1✔
556
        load_order.plugins_mut()[1]
1✔
557
            .set_modification_time(UNIX_EPOCH + Duration::new(2, 0))
1✔
558
            .unwrap();
1✔
559

1✔
560
        load_order.load().unwrap();
1✔
561

1✔
562
        let mut old_timestamps: Vec<u64> = load_order.plugins().iter().map(&mapper).collect();
1✔
563

1✔
564
        load_order.save().unwrap();
1✔
565

1✔
566
        let timestamps: Vec<u64> = load_order.plugins().iter().map(&mapper).collect();
1✔
567

1✔
568
        assert_ne!(old_timestamps, timestamps);
1✔
569

570
        old_timestamps.sort();
1✔
571
        old_timestamps.dedup_by_key(|t| *t);
10✔
572
        let last_timestamp = *old_timestamps.last().unwrap();
1✔
573
        old_timestamps.push(last_timestamp + 60);
1✔
574

1✔
575
        assert_eq!(old_timestamps, timestamps);
1✔
576
    }
1✔
577

578
    #[test]
579
    fn save_should_create_active_plugins_file_parent_directory_if_it_does_not_exist() {
1✔
580
        let tmp_dir = tempdir().unwrap();
1✔
581
        let mut load_order = prepare(GameId::Oblivion, &tmp_dir.path());
1✔
582

1✔
583
        remove_dir_all(
1✔
584
            load_order
1✔
585
                .game_settings()
1✔
586
                .active_plugins_file()
1✔
587
                .parent()
1✔
588
                .unwrap(),
1✔
589
        )
1✔
590
        .unwrap();
1✔
591

1✔
592
        load_order.save().unwrap();
1✔
593

1✔
594
        assert!(load_order
1✔
595
            .game_settings()
1✔
596
            .active_plugins_file()
1✔
597
            .parent()
1✔
598
            .unwrap()
1✔
599
            .exists());
1✔
600
    }
1✔
601

602
    #[test]
603
    fn save_should_write_active_plugins_file_for_oblivion() {
1✔
604
        let tmp_dir = tempdir().unwrap();
1✔
605
        let mut load_order = prepare(GameId::Oblivion, &tmp_dir.path());
1✔
606

1✔
607
        load_order.save().unwrap();
1✔
608

1✔
609
        load_order.load().unwrap();
1✔
610
        assert_eq!(vec!["Blank.esp"], load_order.active_plugin_names());
1✔
611
    }
1✔
612

613
    #[test]
614
    fn save_should_write_active_plugins_file_for_morrowind() {
1✔
615
        let tmp_dir = tempdir().unwrap();
1✔
616
        let mut load_order = prepare(GameId::Morrowind, &tmp_dir.path());
1✔
617

1✔
618
        write_active_plugins_file(load_order.game_settings(), &["Blàñk.esp", "Blank.esm"]);
1✔
619

1✔
620
        load_order.save().unwrap();
1✔
621

1✔
622
        load_order.load().unwrap();
1✔
623
        assert_eq!(vec!["Blank.esp"], load_order.active_plugin_names());
1✔
624

625
        let mut content = String::new();
1✔
626
        File::open(load_order.game_settings().active_plugins_file())
1✔
627
            .unwrap()
1✔
628
            .read_to_string(&mut content)
1✔
629
            .unwrap();
1✔
630
        assert!(content.contains("isrealmorrowindini=false\n[Game Files]\n"));
1✔
631
    }
1✔
632

633
    #[test]
634
    fn save_should_error_if_an_active_plugin_filename_cannot_be_encoded_in_windows_1252() {
1✔
635
        let tmp_dir = tempdir().unwrap();
1✔
636
        let mut load_order = prepare(GameId::Oblivion, &tmp_dir.path());
1✔
637

1✔
638
        let filename = "Bl\u{0227}nk.esm";
1✔
639
        copy_to_test_dir(
1✔
640
            "Blank - Different.esm",
1✔
641
            filename,
1✔
642
            &load_order.game_settings(),
1✔
643
        );
1✔
644
        let mut plugin = Plugin::new(filename, &load_order.game_settings()).unwrap();
1✔
645
        plugin.activate().unwrap();
1✔
646
        load_order.plugins_mut().push(plugin);
1✔
647

1✔
648
        match load_order.save().unwrap_err() {
1✔
649
            Error::EncodeError(s) => assert_eq!("Blȧnk.esm", s),
1✔
650
            e => panic!("Expected encode error, got {:?}", e),
×
651
        };
652
    }
1✔
653

654
    #[test]
655
    fn is_self_consistent_should_return_true() {
1✔
656
        let tmp_dir = tempdir().unwrap();
1✔
657
        let load_order = prepare(GameId::Morrowind, &tmp_dir.path());
1✔
658

1✔
659
        assert!(load_order.is_self_consistent().unwrap());
1✔
660
    }
1✔
661

662
    #[test]
663
    fn is_ambiguous_should_return_false_if_all_loaded_plugins_have_unique_timestamps() {
1✔
664
        let tmp_dir = tempdir().unwrap();
1✔
665
        let mut load_order = prepare(GameId::Morrowind, &tmp_dir.path());
1✔
666

667
        for (index, plugin) in load_order.plugins_mut().iter_mut().enumerate() {
3✔
668
            plugin
3✔
669
                .set_modification_time(UNIX_EPOCH + Duration::new(index.try_into().unwrap(), 0))
3✔
670
                .unwrap();
3✔
671
        }
3✔
672

673
        assert!(!load_order.is_ambiguous().unwrap());
1✔
674
    }
1✔
675

676
    #[test]
677
    fn is_ambiguous_should_return_false_if_two_loaded_plugins_have_the_same_timestamp() {
1✔
678
        let tmp_dir = tempdir().unwrap();
1✔
679
        let mut load_order = prepare(GameId::Morrowind, &tmp_dir.path());
1✔
680

1✔
681
        // Give two files the same timestamp.
1✔
682
        load_order.plugins_mut()[0]
1✔
683
            .set_modification_time(UNIX_EPOCH + Duration::new(2, 0))
1✔
684
            .unwrap();
1✔
685
        load_order.plugins_mut()[1]
1✔
686
            .set_modification_time(UNIX_EPOCH + Duration::new(2, 0))
1✔
687
            .unwrap();
1✔
688

1✔
689
        assert!(!load_order.is_ambiguous().unwrap());
1✔
690
    }
1✔
691

692
    #[test]
693
    fn plugin_sorter_should_sort_in_descending_filename_order_if_timestamps_are_equal() {
1✔
694
        let tmp_dir = tempdir().unwrap();
1✔
695
        let load_order = prepare(GameId::Morrowind, &tmp_dir.path());
1✔
696

1✔
697
        let mut plugin1 = Plugin::new("Blank.esp", &load_order.game_settings()).unwrap();
1✔
698
        let mut plugin2 =
1✔
699
            Plugin::new("Blank - Different.esp", &load_order.game_settings()).unwrap();
1✔
700

1✔
701
        plugin1
1✔
702
            .set_modification_time(UNIX_EPOCH + Duration::new(2, 0))
1✔
703
            .unwrap();
1✔
704

1✔
705
        plugin2
1✔
706
            .set_modification_time(UNIX_EPOCH + Duration::new(2, 0))
1✔
707
            .unwrap();
1✔
708

1✔
709
        let ordering = plugin_sorter(&plugin1, &plugin2);
1✔
710

1✔
711
        assert_eq!(Ordering::Less, ordering);
1✔
712
    }
1✔
713
}
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