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

Ortham / libloadorder / 13091581402

01 Feb 2025 06:57PM UTC coverage: 92.365% (+0.5%) from 91.855%
13091581402

push

github

Ortham
Set versions and changelogs for 18.2.0

9473 of 10256 relevant lines covered (92.37%)

1568172.12 hits per line

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

95.52
/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::collections::HashSet;
21
use std::fs::File;
22
use std::io::{BufRead, BufReader, BufWriter, Write};
23
use std::path::PathBuf;
24
use std::time::{Duration, SystemTime, UNIX_EPOCH};
25

26
use rayon::prelude::*;
27
use regex::Regex;
28
use unicase::UniCase;
29

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

40
const GAME_FILES_HEADER: &[u8] = b"[Game Files]";
41

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

48
/// Retains the first occurrence for each unique filename that is valid Unicode.
49
fn get_unique_filenames(file_paths: Vec<PathBuf>, game_id: GameId) -> Vec<String> {
17✔
50
    let mut set = HashSet::new();
17✔
51

17✔
52
    file_paths
17✔
53
        .iter()
17✔
54
        .filter_map(|p| p.file_name().and_then(|n| n.to_str()))
76✔
55
        .filter(|n| set.insert(UniCase::new(trim_dot_ghost(n, game_id))))
76✔
56
        .map(|n| n.to_string())
76✔
57
        .collect()
17✔
58
}
17✔
59

60
impl TimestampBasedLoadOrder {
61
    pub fn new(game_settings: GameSettings) -> Self {
2✔
62
        Self {
2✔
63
            game_settings,
2✔
64
            plugins: Vec::new(),
2✔
65
        }
2✔
66
    }
2✔
67

68
    fn load_plugins_from_dir(&self) -> Vec<Plugin> {
17✔
69
        let paths = self.game_settings.find_plugins();
17✔
70

17✔
71
        let filenames = get_unique_filenames(paths, self.game_settings.id());
17✔
72

17✔
73
        filenames
17✔
74
            .par_iter()
17✔
75
            .filter_map(|f| Plugin::new(f, &self.game_settings).ok())
76✔
76
            .collect()
17✔
77
    }
17✔
78

79
    fn save_active_plugins(&mut self) -> Result<(), Error> {
6✔
80
        let path = self.game_settings().active_plugins_file();
6✔
81
        create_parent_dirs(path)?;
6✔
82

83
        let prelude = get_file_prelude(self.game_settings())?;
6✔
84

85
        let file = File::create(path).map_err(|e| Error::IoError(path.clone(), e))?;
6✔
86
        let mut writer = BufWriter::new(file);
6✔
87
        writer
6✔
88
            .write_all(&prelude)
6✔
89
            .map_err(|e| Error::IoError(path.clone(), e))?;
6✔
90
        for (index, plugin_name) in self.active_plugin_names().iter().enumerate() {
6✔
91
            if self.game_settings().id() == GameId::Morrowind {
5✔
92
                write!(writer, "GameFile{}=", index)
1✔
93
                    .map_err(|e| Error::IoError(path.clone(), e))?;
1✔
94
            }
4✔
95
            writer
5✔
96
                .write_all(&strict_encode(plugin_name)?)
5✔
97
                .map_err(|e| Error::IoError(path.clone(), e))?;
4✔
98
            writeln!(writer).map_err(|e| Error::IoError(path.clone(), e))?;
4✔
99
        }
100

101
        Ok(())
5✔
102
    }
6✔
103
}
104

105
impl ReadableLoadOrderBase for TimestampBasedLoadOrder {
106
    fn game_settings_base(&self) -> &GameSettings {
94✔
107
        &self.game_settings
94✔
108
    }
94✔
109

110
    fn plugins(&self) -> &[Plugin] {
49✔
111
        &self.plugins
49✔
112
    }
49✔
113
}
114

115
impl MutableLoadOrder for TimestampBasedLoadOrder {
116
    fn plugins_mut(&mut self) -> &mut Vec<Plugin> {
60✔
117
        &mut self.plugins
60✔
118
    }
60✔
119
}
120

121
impl WritableLoadOrder for TimestampBasedLoadOrder {
122
    fn game_settings_mut(&mut self) -> &mut GameSettings {
1✔
123
        &mut self.game_settings
1✔
124
    }
1✔
125

126
    fn load(&mut self) -> Result<(), Error> {
17✔
127
        self.plugins_mut().clear();
17✔
128

17✔
129
        self.plugins = self.load_plugins_from_dir();
17✔
130
        self.plugins.par_sort_by(plugin_sorter);
17✔
131

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

137
        load_active_plugins(self, line_mapper)?;
17✔
138

139
        self.add_implicitly_active_plugins()?;
17✔
140

141
        hoist_masters(&mut self.plugins)?;
17✔
142

143
        Ok(())
17✔
144
    }
17✔
145

146
    fn save(&mut self) -> Result<(), Error> {
6✔
147
        save_load_order_using_timestamps(self)?;
6✔
148

149
        self.save_active_plugins()
6✔
150
    }
6✔
151

152
    fn add(&mut self, plugin_name: &str) -> Result<usize, Error> {
×
153
        add(self, plugin_name)
×
154
    }
×
155

156
    fn remove(&mut self, plugin_name: &str) -> Result<(), Error> {
×
157
        remove(self, plugin_name)
×
158
    }
×
159

160
    fn set_load_order(&mut self, plugin_names: &[&str]) -> Result<(), Error> {
×
161
        self.replace_plugins(plugin_names)
×
162
    }
×
163

164
    fn set_plugin_index(&mut self, plugin_name: &str, position: usize) -> Result<usize, Error> {
×
165
        MutableLoadOrder::set_plugin_index(self, plugin_name, position)
×
166
    }
×
167

168
    fn is_self_consistent(&self) -> Result<bool, Error> {
3✔
169
        Ok(true)
3✔
170
    }
3✔
171

172
    /// A timestamp-based load order is never ambiguous, as even if two or more plugins share the
173
    /// same timestamp, they load in descending filename order.
174
    fn is_ambiguous(&self) -> Result<bool, Error> {
2✔
175
        Ok(false)
2✔
176
    }
2✔
177

178
    fn activate(&mut self, plugin_name: &str) -> Result<(), Error> {
×
179
        activate(self, plugin_name)
×
180
    }
×
181

182
    fn deactivate(&mut self, plugin_name: &str) -> Result<(), Error> {
×
183
        deactivate(self, plugin_name)
×
184
    }
×
185

186
    fn set_active_plugins(&mut self, active_plugin_names: &[&str]) -> Result<(), Error> {
×
187
        set_active_plugins(self, active_plugin_names)
×
188
    }
×
189
}
190

191
pub fn save_load_order_using_timestamps<T: MutableLoadOrder>(
7✔
192
    load_order: &mut T,
7✔
193
) -> Result<(), Error> {
7✔
194
    let timestamps = padded_unique_timestamps(load_order.plugins());
7✔
195

7✔
196
    load_order
7✔
197
        .plugins_mut()
7✔
198
        .par_iter_mut()
7✔
199
        .zip(timestamps.into_par_iter())
7✔
200
        .map(|(ref mut plugin, timestamp)| plugin.set_modification_time(timestamp))
22✔
201
        .collect::<Result<Vec<_>, Error>>()
7✔
202
        .map(|_| ())
7✔
203
}
7✔
204

205
fn plugin_sorter(a: &Plugin, b: &Plugin) -> Ordering {
65✔
206
    if a.is_master_file() == b.is_master_file() {
65✔
207
        match a.modification_time().cmp(&b.modification_time()) {
45✔
208
            Ordering::Equal => a.name().cmp(b.name()).reverse(),
1✔
209
            x => x,
44✔
210
        }
211
    } else if a.is_master_file() {
20✔
212
        Ordering::Less
5✔
213
    } else {
214
        Ordering::Greater
15✔
215
    }
216
}
65✔
217

218
fn plugin_line_mapper(mut line: &str, regex: &Regex, game_id: GameId) -> Option<String> {
20✔
219
    if game_id == GameId::Morrowind {
20✔
220
        line = regex
7✔
221
            .captures(line)
7✔
222
            .and_then(|c| c.get(1))
7✔
223
            .map_or("", |m| m.as_str());
7✔
224
    }
13✔
225

226
    if line.is_empty() || line.starts_with('#') {
20✔
227
        None
5✔
228
    } else {
229
        Some(line.to_owned())
15✔
230
    }
231
}
20✔
232

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

7✔
236
    timestamps.sort();
7✔
237
    timestamps.dedup();
7✔
238

239
    while timestamps.len() < plugins.len() {
7✔
240
        let timestamp = *timestamps.last().unwrap_or(&UNIX_EPOCH) + Duration::from_secs(60);
×
241
        timestamps.push(timestamp);
×
242
    }
×
243

244
    timestamps
7✔
245
}
7✔
246

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

6✔
250
    let path = game_settings.active_plugins_file();
6✔
251

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

256
        for line in buffered.split(b'\n') {
2✔
257
            let line = line.map_err(|e| Error::IoError(path.clone(), e))?;
2✔
258
            prelude.append(&mut line.clone());
2✔
259
            prelude.push(b'\n');
2✔
260

2✔
261
            if line.starts_with(GAME_FILES_HEADER) {
2✔
262
                break;
1✔
263
            }
1✔
264
        }
265
    }
5✔
266

267
    Ok(prelude)
6✔
268
}
6✔
269

270
#[cfg(test)]
271
mod tests {
272
    use super::*;
273

274
    use crate::enums::GameId;
275
    use crate::load_order::tests::*;
276
    use crate::tests::{copy_to_test_dir, set_file_timestamps, set_timestamps};
277
    use std::convert::TryInto;
278
    use std::fs::{remove_dir_all, File};
279
    use std::io::{Read, Write};
280
    use std::path::Path;
281
    use tempfile::tempdir;
282

283
    fn prepare(game_id: GameId, game_dir: &Path) -> TimestampBasedLoadOrder {
22✔
284
        let mut game_settings = game_settings_for_test(game_id, game_dir);
22✔
285
        mock_game_files(&mut game_settings);
22✔
286

22✔
287
        let plugins = vec![
22✔
288
            Plugin::with_active("Blank.esp", &game_settings, true).unwrap(),
22✔
289
            Plugin::new("Blank - Different.esp", &game_settings).unwrap(),
22✔
290
        ];
22✔
291

22✔
292
        TimestampBasedLoadOrder {
22✔
293
            game_settings,
22✔
294
            plugins,
22✔
295
        }
22✔
296
    }
22✔
297

298
    fn write_file(path: &Path) {
2✔
299
        let mut file = File::create(path).unwrap();
2✔
300
        writeln!(file).unwrap();
2✔
301
    }
2✔
302

303
    #[test]
304
    fn load_should_reload_existing_plugins() {
1✔
305
        let tmp_dir = tempdir().unwrap();
1✔
306
        let mut load_order = prepare(GameId::Oblivion, tmp_dir.path());
1✔
307

1✔
308
        assert!(!load_order.plugins()[1].is_master_file());
1✔
309
        copy_to_test_dir("Blank.esm", "Blank.esp", load_order.game_settings());
1✔
310
        let plugin_path = load_order
1✔
311
            .game_settings()
1✔
312
            .plugins_directory()
1✔
313
            .join("Blank.esp");
1✔
314
        set_file_timestamps(&plugin_path, 0);
1✔
315

1✔
316
        load_order.load().unwrap();
1✔
317

1✔
318
        assert!(load_order.plugins()[1].is_master_file());
1✔
319
    }
1✔
320

321
    #[test]
322
    fn load_should_remove_plugins_that_fail_to_load() {
1✔
323
        let tmp_dir = tempdir().unwrap();
1✔
324
        let mut load_order = prepare(GameId::Oblivion, tmp_dir.path());
1✔
325

1✔
326
        assert!(load_order.index_of("Blank.esp").is_some());
1✔
327
        assert!(load_order.index_of("Blank - Different.esp").is_some());
1✔
328

329
        let plugin_path = load_order
1✔
330
            .game_settings()
1✔
331
            .plugins_directory()
1✔
332
            .join("Blank.esp");
1✔
333
        write_file(&plugin_path);
1✔
334
        set_file_timestamps(&plugin_path, 0);
1✔
335

1✔
336
        let plugin_path = load_order
1✔
337
            .game_settings()
1✔
338
            .plugins_directory()
1✔
339
            .join("Blank - Different.esp");
1✔
340
        write_file(&plugin_path);
1✔
341
        set_file_timestamps(&plugin_path, 0);
1✔
342

1✔
343
        load_order.load().unwrap();
1✔
344
        assert!(load_order.index_of("Blank.esp").is_none());
1✔
345
        assert!(load_order.index_of("Blank - Different.esp").is_none());
1✔
346
    }
1✔
347

348
    #[test]
349
    fn load_should_sort_installed_plugins_into_their_timestamp_order_with_master_files_first() {
1✔
350
        let tmp_dir = tempdir().unwrap();
1✔
351
        let mut load_order = prepare(GameId::Oblivion, tmp_dir.path());
1✔
352

1✔
353
        set_timestamps(
1✔
354
            &load_order.game_settings().plugins_directory(),
1✔
355
            &[
1✔
356
                "Blank - Master Dependent.esp",
1✔
357
                "Blank.esm",
1✔
358
                "Blank - Different.esp",
1✔
359
                "Blank.esp",
1✔
360
            ],
1✔
361
        );
1✔
362

1✔
363
        load_order.load().unwrap();
1✔
364

1✔
365
        let expected_filenames = vec![
1✔
366
            "Blank.esm",
1✔
367
            "Blank - Master Dependent.esp",
1✔
368
            "Blank - Different.esp",
1✔
369
            "Blank.esp",
1✔
370
            "Blàñk.esp",
1✔
371
        ];
1✔
372

1✔
373
        assert_eq!(expected_filenames, load_order.plugin_names());
1✔
374
    }
1✔
375

376
    #[test]
377
    fn load_should_hoist_masters_that_masters_depend_on_to_load_before_their_dependents() {
1✔
378
        let tmp_dir = tempdir().unwrap();
1✔
379
        let mut load_order = prepare(GameId::Oblivion, tmp_dir.path());
1✔
380

1✔
381
        let master_dependent_master = "Blank - Master Dependent.esm";
1✔
382
        copy_to_test_dir(
1✔
383
            master_dependent_master,
1✔
384
            master_dependent_master,
1✔
385
            load_order.game_settings(),
1✔
386
        );
1✔
387

1✔
388
        let filenames = vec![
1✔
389
            "Blank - Master Dependent.esm",
1✔
390
            "Blank - Master Dependent.esp",
1✔
391
            "Blank.esm",
1✔
392
            "Blank - Different.esp",
1✔
393
            "Blàñk.esp",
1✔
394
            "Blank.esp",
1✔
395
        ];
1✔
396
        set_timestamps(&load_order.game_settings().plugins_directory(), &filenames);
1✔
397

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

1✔
400
        let expected_filenames = vec![
1✔
401
            "Blank.esm",
1✔
402
            "Blank - Master Dependent.esm",
1✔
403
            "Blank - Master Dependent.esp",
1✔
404
            "Blank - Different.esp",
1✔
405
            "Blàñk.esp",
1✔
406
            "Blank.esp",
1✔
407
        ];
1✔
408

1✔
409
        assert_eq!(expected_filenames, load_order.plugin_names());
1✔
410
    }
1✔
411

412
    #[test]
413
    fn load_should_empty_the_load_order_if_the_plugins_directory_does_not_exist() {
1✔
414
        let tmp_dir = tempdir().unwrap();
1✔
415
        let mut load_order = prepare(GameId::Oblivion, tmp_dir.path());
1✔
416
        tmp_dir.close().unwrap();
1✔
417

1✔
418
        load_order.load().unwrap();
1✔
419

1✔
420
        assert!(load_order.plugins().is_empty());
1✔
421
    }
1✔
422

423
    #[test]
424
    fn load_should_decode_active_plugins_file_from_windows_1252() {
1✔
425
        let tmp_dir = tempdir().unwrap();
1✔
426
        let mut load_order = prepare(GameId::Oblivion, tmp_dir.path());
1✔
427

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

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

1✔
433
        assert_eq!(expected_filenames, load_order.active_plugin_names());
1✔
434
    }
1✔
435

436
    #[test]
437
    fn load_should_handle_crlf_and_lf_in_active_plugins_file() {
1✔
438
        let tmp_dir = tempdir().unwrap();
1✔
439
        let mut load_order = prepare(GameId::Oblivion, tmp_dir.path());
1✔
440

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

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

1✔
446
        assert_eq!(expected_filenames, load_order.active_plugin_names());
1✔
447
    }
1✔
448

449
    #[test]
450
    fn load_should_ignore_active_plugins_file_lines_starting_with_a_hash_for_oblivion() {
1✔
451
        let tmp_dir = tempdir().unwrap();
1✔
452
        let mut load_order = prepare(GameId::Oblivion, tmp_dir.path());
1✔
453

1✔
454
        write_active_plugins_file(
1✔
455
            load_order.game_settings(),
1✔
456
            &["#Blank.esp", "Blàñk.esp", "Blank.esm"],
1✔
457
        );
1✔
458

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

1✔
462
        assert_eq!(expected_filenames, load_order.active_plugin_names());
1✔
463
    }
1✔
464

465
    #[test]
466
    fn load_should_ignore_plugins_in_active_plugins_file_that_are_not_installed() {
1✔
467
        let tmp_dir = tempdir().unwrap();
1✔
468
        let mut load_order = prepare(GameId::Oblivion, tmp_dir.path());
1✔
469

1✔
470
        write_active_plugins_file(
1✔
471
            load_order.game_settings(),
1✔
472
            &["Blàñk.esp", "Blank.esm", "missing.esp"],
1✔
473
        );
1✔
474

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

1✔
478
        assert_eq!(expected_filenames, load_order.active_plugin_names());
1✔
479
    }
1✔
480

481
    #[test]
482
    fn load_should_load_plugin_states_from_active_plugins_file_for_oblivion() {
1✔
483
        let tmp_dir = tempdir().unwrap();
1✔
484
        let mut load_order = prepare(GameId::Oblivion, tmp_dir.path());
1✔
485

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

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

1✔
491
        assert_eq!(expected_filenames, load_order.active_plugin_names());
1✔
492
    }
1✔
493

494
    #[test]
495
    fn load_should_succeed_when_active_plugins_file_is_missing() {
1✔
496
        let tmp_dir = tempdir().unwrap();
1✔
497
        let mut load_order = prepare(GameId::Oblivion, tmp_dir.path());
1✔
498

1✔
499
        assert!(load_order.load().is_ok());
1✔
500
        assert!(load_order.active_plugin_names().is_empty());
1✔
501
    }
1✔
502

503
    #[test]
504
    fn load_should_load_plugin_states_from_active_plugins_file_for_morrowind() {
1✔
505
        let tmp_dir = tempdir().unwrap();
1✔
506
        let mut load_order = prepare(GameId::Morrowind, tmp_dir.path());
1✔
507

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

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

1✔
513
        assert_eq!(expected_filenames, load_order.active_plugin_names());
1✔
514
    }
1✔
515

516
    #[test]
517
    fn save_should_preserve_the_existing_set_of_timestamps() {
1✔
518
        let tmp_dir = tempdir().unwrap();
1✔
519
        let mut load_order = prepare(GameId::Oblivion, tmp_dir.path());
1✔
520

1✔
521
        let mapper = |p: &Plugin| {
10✔
522
            p.modification_time()
10✔
523
                .duration_since(UNIX_EPOCH)
10✔
524
                .unwrap()
10✔
525
                .as_secs()
10✔
526
        };
10✔
527

528
        set_timestamps(
1✔
529
            &load_order.game_settings().plugins_directory(),
1✔
530
            &[
1✔
531
                "Blank - Master Dependent.esp",
1✔
532
                "Blank.esm",
1✔
533
                "Blank - Different.esp",
1✔
534
                "Blank.esp",
1✔
535
            ],
1✔
536
        );
1✔
537

1✔
538
        load_order.load().unwrap();
1✔
539

1✔
540
        let mut old_timestamps: Vec<u64> = load_order.plugins().iter().map(&mapper).collect();
1✔
541
        old_timestamps.sort();
1✔
542

1✔
543
        load_order.save().unwrap();
1✔
544

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

1✔
547
        assert_eq!(old_timestamps, timestamps);
1✔
548
    }
1✔
549

550
    #[test]
551
    fn save_should_deduplicate_plugin_timestamps() {
1✔
552
        let tmp_dir = tempdir().unwrap();
1✔
553
        let mut load_order = prepare(GameId::Oblivion, tmp_dir.path());
1✔
554

1✔
555
        let mapper = |p: &Plugin| {
10✔
556
            p.modification_time()
10✔
557
                .duration_since(UNIX_EPOCH)
10✔
558
                .unwrap()
10✔
559
                .as_secs()
10✔
560
        };
10✔
561

562
        set_timestamps(
1✔
563
            &load_order.game_settings().plugins_directory(),
1✔
564
            &[
1✔
565
                "Blank - Master Dependent.esp",
1✔
566
                "Blank.esm",
1✔
567
                "Blank - Different.esp",
1✔
568
                "Blank.esp",
1✔
569
            ],
1✔
570
        );
1✔
571

1✔
572
        // Give two files the same timestamp.
1✔
573
        load_order.plugins_mut()[1]
1✔
574
            .set_modification_time(UNIX_EPOCH + Duration::new(2, 0))
1✔
575
            .unwrap();
1✔
576

1✔
577
        load_order.load().unwrap();
1✔
578

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

1✔
581
        load_order.save().unwrap();
1✔
582

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

1✔
585
        assert_ne!(old_timestamps, timestamps);
1✔
586

587
        old_timestamps.sort();
1✔
588
        old_timestamps.dedup_by_key(|t| *t);
8✔
589

1✔
590
        assert_eq!(old_timestamps, timestamps);
1✔
591
    }
1✔
592

593
    #[test]
594
    fn save_should_create_active_plugins_file_parent_directory_if_it_does_not_exist() {
1✔
595
        let tmp_dir = tempdir().unwrap();
1✔
596
        let mut load_order = prepare(GameId::Oblivion, tmp_dir.path());
1✔
597

1✔
598
        remove_dir_all(
1✔
599
            load_order
1✔
600
                .game_settings()
1✔
601
                .active_plugins_file()
1✔
602
                .parent()
1✔
603
                .unwrap(),
1✔
604
        )
1✔
605
        .unwrap();
1✔
606

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

1✔
609
        assert!(load_order
1✔
610
            .game_settings()
1✔
611
            .active_plugins_file()
1✔
612
            .parent()
1✔
613
            .unwrap()
1✔
614
            .exists());
1✔
615
    }
1✔
616

617
    #[test]
618
    fn save_should_write_active_plugins_file_for_oblivion() {
1✔
619
        let tmp_dir = tempdir().unwrap();
1✔
620
        let mut load_order = prepare(GameId::Oblivion, tmp_dir.path());
1✔
621

1✔
622
        load_order.save().unwrap();
1✔
623

1✔
624
        load_order.load().unwrap();
1✔
625
        assert_eq!(vec!["Blank.esp"], load_order.active_plugin_names());
1✔
626
    }
1✔
627

628
    #[test]
629
    fn save_should_write_active_plugins_file_for_morrowind() {
1✔
630
        let tmp_dir = tempdir().unwrap();
1✔
631
        let mut load_order = prepare(GameId::Morrowind, tmp_dir.path());
1✔
632

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

1✔
635
        load_order.save().unwrap();
1✔
636

1✔
637
        load_order.load().unwrap();
1✔
638
        assert_eq!(vec!["Blank.esp"], load_order.active_plugin_names());
1✔
639

640
        let mut content = String::new();
1✔
641
        File::open(load_order.game_settings().active_plugins_file())
1✔
642
            .unwrap()
1✔
643
            .read_to_string(&mut content)
1✔
644
            .unwrap();
1✔
645
        assert!(content.contains("isrealmorrowindini=false\n[Game Files]\n"));
1✔
646
    }
1✔
647

648
    #[test]
649
    fn save_should_error_if_an_active_plugin_filename_cannot_be_encoded_in_windows_1252() {
1✔
650
        let tmp_dir = tempdir().unwrap();
1✔
651
        let mut load_order = prepare(GameId::Oblivion, tmp_dir.path());
1✔
652

1✔
653
        let filename = "Bl\u{0227}nk.esm";
1✔
654
        copy_to_test_dir(
1✔
655
            "Blank - Different.esm",
1✔
656
            filename,
1✔
657
            load_order.game_settings(),
1✔
658
        );
1✔
659
        let mut plugin = Plugin::new(filename, load_order.game_settings()).unwrap();
1✔
660
        plugin.activate().unwrap();
1✔
661
        load_order.plugins_mut().push(plugin);
1✔
662

1✔
663
        match load_order.save().unwrap_err() {
1✔
664
            Error::EncodeError(s) => assert_eq!("Blȧnk.esm", s),
1✔
665
            e => panic!("Expected encode error, got {:?}", e),
×
666
        };
667
    }
1✔
668

669
    #[test]
670
    fn is_self_consistent_should_return_true() {
1✔
671
        let tmp_dir = tempdir().unwrap();
1✔
672
        let load_order = prepare(GameId::Morrowind, tmp_dir.path());
1✔
673

1✔
674
        assert!(load_order.is_self_consistent().unwrap());
1✔
675
    }
1✔
676

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

682
        for (index, plugin) in load_order.plugins_mut().iter_mut().enumerate() {
2✔
683
            plugin
2✔
684
                .set_modification_time(UNIX_EPOCH + Duration::new(index.try_into().unwrap(), 0))
2✔
685
                .unwrap();
2✔
686
        }
2✔
687

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

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

1✔
696
        // Give two files the same timestamp.
1✔
697
        load_order.plugins_mut()[0]
1✔
698
            .set_modification_time(UNIX_EPOCH + Duration::new(2, 0))
1✔
699
            .unwrap();
1✔
700
        load_order.plugins_mut()[1]
1✔
701
            .set_modification_time(UNIX_EPOCH + Duration::new(2, 0))
1✔
702
            .unwrap();
1✔
703

1✔
704
        assert!(!load_order.is_ambiguous().unwrap());
1✔
705
    }
1✔
706

707
    #[test]
708
    fn plugin_sorter_should_sort_in_descending_filename_order_if_timestamps_are_equal() {
1✔
709
        let tmp_dir = tempdir().unwrap();
1✔
710
        let load_order = prepare(GameId::Morrowind, tmp_dir.path());
1✔
711

1✔
712
        let mut plugin1 = Plugin::new("Blank.esp", load_order.game_settings()).unwrap();
1✔
713
        let mut plugin2 = Plugin::new("Blank - Different.esp", load_order.game_settings()).unwrap();
1✔
714

1✔
715
        plugin1
1✔
716
            .set_modification_time(UNIX_EPOCH + Duration::new(2, 0))
1✔
717
            .unwrap();
1✔
718

1✔
719
        plugin2
1✔
720
            .set_modification_time(UNIX_EPOCH + Duration::new(2, 0))
1✔
721
            .unwrap();
1✔
722

1✔
723
        let ordering = plugin_sorter(&plugin1, &plugin2);
1✔
724

1✔
725
        assert_eq!(Ordering::Less, ordering);
1✔
726
    }
1✔
727
}
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