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

Ortham / libloadorder / 14680640716

26 Apr 2025 11:08AM UTC coverage: 93.244% (+0.2%) from 93.009%
14680640716

push

github

Ortham
Deny a lot of extra lints and fix their errors

541 of 606 new or added lines in 21 files covered. (89.27%)

3 existing lines in 3 files now uncovered.

10599 of 11367 relevant lines covered (93.24%)

1119406.35 hits per line

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

95.57
/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::sync::LazyLock;
25
use std::time::{Duration, SystemTime, UNIX_EPOCH};
26

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

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

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

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

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

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

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

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

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

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

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

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

86
        let file = File::create(path).map_err(|e| Error::IoError(path.clone(), e))?;
6✔
87
        let mut writer = BufWriter::new(file);
6✔
88
        writer
6✔
89
            .write_all(&prelude)
6✔
90
            .map_err(|e| Error::IoError(path.clone(), e))?;
6✔
91
        for (index, plugin_name) in self.active_plugin_names().iter().enumerate() {
6✔
92
            if self.game_settings().id() == GameId::Morrowind {
5✔
93
                write!(writer, "GameFile{index}=").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] {
34✔
111
        &self.plugins
34✔
112
    }
34✔
113
}
114

115
impl MutableLoadOrder for TimestampBasedLoadOrder {
116
    fn plugins_mut(&mut self) -> &mut Vec<Plugin> {
61✔
117
        &mut self.plugins
61✔
118
    }
61✔
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 game_id = self.game_settings().id();
17✔
133
        let line_mapper = |line: &str| plugin_line_mapper(line, game_id);
20✔
134

135
        load_active_plugins(self, line_mapper)?;
17✔
136

137
        self.add_implicitly_active_plugins()?;
17✔
138

139
        hoist_masters(&mut self.plugins)?;
17✔
140

141
        Ok(())
17✔
142
    }
17✔
143

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

147
        self.save_active_plugins()
6✔
148
    }
6✔
149

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

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

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

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

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

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

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

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

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

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

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

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

216
fn plugin_line_mapper(mut line: &str, game_id: GameId) -> Option<String> {
20✔
217
    if game_id == GameId::Morrowind {
20✔
218
        #[expect(
7✔
219
            clippy::expect_used,
7✔
220
            reason = "Only panics if the hardcoded regex string is invalid"
7✔
221
        )]
7✔
222
        static MORROWIND_INI_REGEX: LazyLock<Regex> = LazyLock::new(|| {
7✔
223
            Regex::new(r"(?i)GameFile[0-9]{1,3}=(.+\.es(?:m|p))")
1✔
224
                .expect("Hardcoded GameFile ini entry regex should be valid")
1✔
225
        });
7✔
226

7✔
227
        line = MORROWIND_INI_REGEX
7✔
228
            .captures(line)
7✔
229
            .and_then(|c| c.get(1))
7✔
230
            .map_or("", |m| m.as_str());
7✔
231
    }
13✔
232

233
    if line.is_empty() || line.starts_with('#') {
20✔
234
        None
5✔
235
    } else {
236
        Some(line.to_owned())
15✔
237
    }
238
}
20✔
239

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

7✔
243
    timestamps.sort();
7✔
244
    timestamps.dedup();
7✔
245

246
    while timestamps.len() < plugins.len() {
7✔
247
        let timestamp = *timestamps.last().unwrap_or(&UNIX_EPOCH) + Duration::from_secs(60);
×
248
        timestamps.push(timestamp);
×
249
    }
×
250

251
    timestamps
7✔
252
}
7✔
253

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

6✔
257
    let path = game_settings.active_plugins_file();
6✔
258

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

263
        for line in buffered.split(b'\n') {
2✔
264
            let line = line.map_err(|e| Error::IoError(path.clone(), e))?;
2✔
265
            prelude.append(&mut line.clone());
2✔
266
            prelude.push(b'\n');
2✔
267

2✔
268
            if line.starts_with(GAME_FILES_HEADER) {
2✔
269
                break;
1✔
270
            }
1✔
271
        }
272
    }
5✔
273

274
    Ok(prelude)
6✔
275
}
6✔
276

277
#[cfg(test)]
278
mod tests {
279
    use super::*;
280

281
    use crate::load_order::tests::*;
282
    use crate::tests::{copy_to_test_dir, set_file_timestamps, set_timestamps, NON_ASCII};
283
    use std::fs::remove_dir_all;
284
    use std::io::Read;
285
    use std::path::Path;
286
    use tempfile::tempdir;
287

288
    fn prepare(game_id: GameId, game_dir: &Path) -> TimestampBasedLoadOrder {
22✔
289
        let mut game_settings = game_settings_for_test(game_id, game_dir);
22✔
290
        mock_game_files(&mut game_settings);
22✔
291

22✔
292
        let plugins = vec![
22✔
293
            Plugin::with_active("Blank.esp", &game_settings, true).unwrap(),
22✔
294
            Plugin::new("Blank - Different.esp", &game_settings).unwrap(),
22✔
295
        ];
22✔
296

22✔
297
        TimestampBasedLoadOrder {
22✔
298
            game_settings,
22✔
299
            plugins,
22✔
300
        }
22✔
301
    }
22✔
302

303
    fn write_file(path: &Path) {
2✔
304
        let mut file = File::create(path).unwrap();
2✔
305
        writeln!(file).unwrap();
2✔
306
    }
2✔
307

308
    #[test]
309
    fn load_should_reload_existing_plugins() {
1✔
310
        let tmp_dir = tempdir().unwrap();
1✔
311
        let mut load_order = prepare(GameId::Oblivion, tmp_dir.path());
1✔
312

1✔
313
        assert!(!load_order.plugins()[1].is_master_file());
1✔
314
        copy_to_test_dir("Blank.esm", "Blank.esp", load_order.game_settings());
1✔
315
        let plugin_path = load_order
1✔
316
            .game_settings()
1✔
317
            .plugins_directory()
1✔
318
            .join("Blank.esp");
1✔
319
        set_file_timestamps(&plugin_path, 0);
1✔
320

1✔
321
        load_order.load().unwrap();
1✔
322

1✔
323
        assert!(load_order.plugins()[1].is_master_file());
1✔
324
    }
1✔
325

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

1✔
331
        assert!(load_order.index_of("Blank.esp").is_some());
1✔
332
        assert!(load_order.index_of("Blank - Different.esp").is_some());
1✔
333

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

1✔
341
        let plugin_path = load_order
1✔
342
            .game_settings()
1✔
343
            .plugins_directory()
1✔
344
            .join("Blank - Different.esp");
1✔
345
        write_file(&plugin_path);
1✔
346
        set_file_timestamps(&plugin_path, 0);
1✔
347

1✔
348
        load_order.load().unwrap();
1✔
349
        assert!(load_order.index_of("Blank.esp").is_none());
1✔
350
        assert!(load_order.index_of("Blank - Different.esp").is_none());
1✔
351
    }
1✔
352

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

1✔
358
        set_timestamps(
1✔
359
            &load_order.game_settings().plugins_directory(),
1✔
360
            &[
1✔
361
                "Blank - Master Dependent.esp",
1✔
362
                "Blank.esm",
1✔
363
                "Blank - Different.esp",
1✔
364
                "Blank.esp",
1✔
365
            ],
1✔
366
        );
1✔
367

1✔
368
        load_order.load().unwrap();
1✔
369

1✔
370
        let expected_filenames = vec![
1✔
371
            "Blank.esm",
1✔
372
            "Blank - Master Dependent.esp",
1✔
373
            "Blank - Different.esp",
1✔
374
            "Blank.esp",
1✔
375
            NON_ASCII,
1✔
376
        ];
1✔
377

1✔
378
        assert_eq!(expected_filenames, load_order.plugin_names());
1✔
379
    }
1✔
380

381
    #[test]
382
    fn load_should_hoist_masters_that_masters_depend_on_to_load_before_their_dependents() {
1✔
383
        let tmp_dir = tempdir().unwrap();
1✔
384
        let mut load_order = prepare(GameId::Oblivion, tmp_dir.path());
1✔
385

1✔
386
        let master_dependent_master = "Blank - Master Dependent.esm";
1✔
387
        copy_to_test_dir(
1✔
388
            master_dependent_master,
1✔
389
            master_dependent_master,
1✔
390
            load_order.game_settings(),
1✔
391
        );
1✔
392

1✔
393
        let filenames = vec![
1✔
394
            "Blank - Master Dependent.esm",
1✔
395
            "Blank - Master Dependent.esp",
1✔
396
            "Blank.esm",
1✔
397
            "Blank - Different.esp",
1✔
398
            NON_ASCII,
1✔
399
            "Blank.esp",
1✔
400
        ];
1✔
401
        set_timestamps(&load_order.game_settings().plugins_directory(), &filenames);
1✔
402

1✔
403
        load_order.load().unwrap();
1✔
404

1✔
405
        let expected_filenames = vec![
1✔
406
            "Blank.esm",
1✔
407
            "Blank - Master Dependent.esm",
1✔
408
            "Blank - Master Dependent.esp",
1✔
409
            "Blank - Different.esp",
1✔
410
            NON_ASCII,
1✔
411
            "Blank.esp",
1✔
412
        ];
1✔
413

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

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

1✔
423
        load_order.load().unwrap();
1✔
424

1✔
425
        assert!(load_order.plugins().is_empty());
1✔
426
    }
1✔
427

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

1✔
433
        write_active_plugins_file(load_order.game_settings(), &[NON_ASCII, "Blank.esm"]);
1✔
434

1✔
435
        load_order.load().unwrap();
1✔
436
        let expected_filenames = vec!["Blank.esm", NON_ASCII];
1✔
437

1✔
438
        assert_eq!(expected_filenames, load_order.active_plugin_names());
1✔
439
    }
1✔
440

441
    #[test]
442
    fn load_should_handle_crlf_and_lf_in_active_plugins_file() {
1✔
443
        let tmp_dir = tempdir().unwrap();
1✔
444
        let mut load_order = prepare(GameId::Oblivion, tmp_dir.path());
1✔
445

1✔
446
        write_active_plugins_file(load_order.game_settings(), &[NON_ASCII, "Blank.esm\r"]);
1✔
447

1✔
448
        load_order.load().unwrap();
1✔
449
        let expected_filenames = vec!["Blank.esm", NON_ASCII];
1✔
450

1✔
451
        assert_eq!(expected_filenames, load_order.active_plugin_names());
1✔
452
    }
1✔
453

454
    #[test]
455
    fn load_should_ignore_active_plugins_file_lines_starting_with_a_hash_for_oblivion() {
1✔
456
        let tmp_dir = tempdir().unwrap();
1✔
457
        let mut load_order = prepare(GameId::Oblivion, tmp_dir.path());
1✔
458

1✔
459
        write_active_plugins_file(
1✔
460
            load_order.game_settings(),
1✔
461
            &["#Blank.esp", NON_ASCII, "Blank.esm"],
1✔
462
        );
1✔
463

1✔
464
        load_order.load().unwrap();
1✔
465
        let expected_filenames = vec!["Blank.esm", NON_ASCII];
1✔
466

1✔
467
        assert_eq!(expected_filenames, load_order.active_plugin_names());
1✔
468
    }
1✔
469

470
    #[test]
471
    fn load_should_ignore_plugins_in_active_plugins_file_that_are_not_installed() {
1✔
472
        let tmp_dir = tempdir().unwrap();
1✔
473
        let mut load_order = prepare(GameId::Oblivion, tmp_dir.path());
1✔
474

1✔
475
        write_active_plugins_file(
1✔
476
            load_order.game_settings(),
1✔
477
            &[NON_ASCII, "Blank.esm", "missing.esp"],
1✔
478
        );
1✔
479

1✔
480
        load_order.load().unwrap();
1✔
481
        let expected_filenames = vec!["Blank.esm", NON_ASCII];
1✔
482

1✔
483
        assert_eq!(expected_filenames, load_order.active_plugin_names());
1✔
484
    }
1✔
485

486
    #[test]
487
    fn load_should_load_plugin_states_from_active_plugins_file_for_oblivion() {
1✔
488
        let tmp_dir = tempdir().unwrap();
1✔
489
        let mut load_order = prepare(GameId::Oblivion, tmp_dir.path());
1✔
490

1✔
491
        write_active_plugins_file(load_order.game_settings(), &[NON_ASCII, "Blank.esm"]);
1✔
492

1✔
493
        load_order.load().unwrap();
1✔
494
        let expected_filenames = vec!["Blank.esm", NON_ASCII];
1✔
495

1✔
496
        assert_eq!(expected_filenames, load_order.active_plugin_names());
1✔
497
    }
1✔
498

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

1✔
504
        assert!(load_order.load().is_ok());
1✔
505
        assert!(load_order.active_plugin_names().is_empty());
1✔
506
    }
1✔
507

508
    #[test]
509
    fn load_should_load_plugin_states_from_active_plugins_file_for_morrowind() {
1✔
510
        let tmp_dir = tempdir().unwrap();
1✔
511
        let mut load_order = prepare(GameId::Morrowind, tmp_dir.path());
1✔
512

1✔
513
        write_active_plugins_file(load_order.game_settings(), &[NON_ASCII, "Blank.esm"]);
1✔
514

1✔
515
        load_order.load().unwrap();
1✔
516
        let expected_filenames = vec!["Blank.esm", NON_ASCII];
1✔
517

1✔
518
        assert_eq!(expected_filenames, load_order.active_plugin_names());
1✔
519
    }
1✔
520

521
    #[test]
522
    fn save_should_preserve_the_existing_set_of_timestamps() {
1✔
523
        let tmp_dir = tempdir().unwrap();
1✔
524
        let mut load_order = prepare(GameId::Oblivion, tmp_dir.path());
1✔
525

1✔
526
        let mapper = |p: &Plugin| {
10✔
527
            p.modification_time()
10✔
528
                .duration_since(UNIX_EPOCH)
10✔
529
                .unwrap()
10✔
530
                .as_secs()
10✔
531
        };
10✔
532

533
        set_timestamps(
1✔
534
            &load_order.game_settings().plugins_directory(),
1✔
535
            &[
1✔
536
                "Blank - Master Dependent.esp",
1✔
537
                "Blank.esm",
1✔
538
                "Blank - Different.esp",
1✔
539
                "Blank.esp",
1✔
540
            ],
1✔
541
        );
1✔
542

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

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

1✔
548
        load_order.save().unwrap();
1✔
549

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

1✔
552
        assert_eq!(old_timestamps, timestamps);
1✔
553
    }
1✔
554

555
    #[test]
556
    fn save_should_deduplicate_plugin_timestamps() {
1✔
557
        let tmp_dir = tempdir().unwrap();
1✔
558
        let mut load_order = prepare(GameId::Oblivion, tmp_dir.path());
1✔
559

1✔
560
        let mapper = |p: &Plugin| {
10✔
561
            p.modification_time()
10✔
562
                .duration_since(UNIX_EPOCH)
10✔
563
                .unwrap()
10✔
564
                .as_secs()
10✔
565
        };
10✔
566

567
        set_timestamps(
1✔
568
            &load_order.game_settings().plugins_directory(),
1✔
569
            &[
1✔
570
                "Blank - Master Dependent.esp",
1✔
571
                "Blank.esm",
1✔
572
                "Blank - Different.esp",
1✔
573
                "Blank.esp",
1✔
574
            ],
1✔
575
        );
1✔
576

1✔
577
        // Give two files the same timestamp.
1✔
578
        load_order.plugins_mut()[1]
1✔
579
            .set_modification_time(UNIX_EPOCH + Duration::new(2, 0))
1✔
580
            .unwrap();
1✔
581

1✔
582
        load_order.load().unwrap();
1✔
583

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

1✔
586
        load_order.save().unwrap();
1✔
587

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

1✔
590
        assert_ne!(old_timestamps, timestamps);
1✔
591

592
        old_timestamps.sort_unstable();
1✔
593
        old_timestamps.dedup_by_key(|t| *t);
8✔
594

1✔
595
        assert_eq!(old_timestamps, timestamps);
1✔
596
    }
1✔
597

598
    #[test]
599
    fn save_should_create_active_plugins_file_parent_directory_if_it_does_not_exist() {
1✔
600
        let tmp_dir = tempdir().unwrap();
1✔
601
        let mut load_order = prepare(GameId::Oblivion, tmp_dir.path());
1✔
602

1✔
603
        remove_dir_all(
1✔
604
            load_order
1✔
605
                .game_settings()
1✔
606
                .active_plugins_file()
1✔
607
                .parent()
1✔
608
                .unwrap(),
1✔
609
        )
1✔
610
        .unwrap();
1✔
611

1✔
612
        load_order.save().unwrap();
1✔
613

1✔
614
        assert!(load_order
1✔
615
            .game_settings()
1✔
616
            .active_plugins_file()
1✔
617
            .parent()
1✔
618
            .unwrap()
1✔
619
            .exists());
1✔
620
    }
1✔
621

622
    #[test]
623
    fn save_should_write_active_plugins_file_for_oblivion() {
1✔
624
        let tmp_dir = tempdir().unwrap();
1✔
625
        let mut load_order = prepare(GameId::Oblivion, tmp_dir.path());
1✔
626

1✔
627
        load_order.save().unwrap();
1✔
628

1✔
629
        load_order.load().unwrap();
1✔
630
        assert_eq!(vec!["Blank.esp"], load_order.active_plugin_names());
1✔
631
    }
1✔
632

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

1✔
638
        write_active_plugins_file(load_order.game_settings(), &[NON_ASCII, "Blank.esm"]);
1✔
639

1✔
640
        load_order.save().unwrap();
1✔
641

1✔
642
        load_order.load().unwrap();
1✔
643
        assert_eq!(vec!["Blank.esp"], load_order.active_plugin_names());
1✔
644

645
        let mut content = String::new();
1✔
646
        File::open(load_order.game_settings().active_plugins_file())
1✔
647
            .unwrap()
1✔
648
            .read_to_string(&mut content)
1✔
649
            .unwrap();
1✔
650
        assert!(content.contains("isrealmorrowindini=false\n[Game Files]\n"));
1✔
651
    }
1✔
652

653
    #[test]
654
    fn save_should_error_if_an_active_plugin_filename_cannot_be_encoded_in_windows_1252() {
1✔
655
        let tmp_dir = tempdir().unwrap();
1✔
656
        let mut load_order = prepare(GameId::Oblivion, tmp_dir.path());
1✔
657

1✔
658
        let filename = "Bl\u{0227}nk.esm";
1✔
659
        copy_to_test_dir(
1✔
660
            "Blank - Different.esm",
1✔
661
            filename,
1✔
662
            load_order.game_settings(),
1✔
663
        );
1✔
664
        let mut plugin = Plugin::new(filename, load_order.game_settings()).unwrap();
1✔
665
        plugin.activate().unwrap();
1✔
666
        load_order.plugins_mut().push(plugin);
1✔
667

1✔
668
        match load_order.save().unwrap_err() {
1✔
669
            Error::EncodeError(s) => assert_eq!("Bl\u{227}nk.esm", s),
1✔
NEW
670
            e => panic!("Expected encode error, got {e:?}"),
×
671
        }
672
    }
1✔
673

674
    #[test]
675
    fn is_self_consistent_should_return_true() {
1✔
676
        let tmp_dir = tempdir().unwrap();
1✔
677
        let load_order = prepare(GameId::Morrowind, tmp_dir.path());
1✔
678

1✔
679
        assert!(load_order.is_self_consistent().unwrap());
1✔
680
    }
1✔
681

682
    #[test]
683
    fn is_ambiguous_should_return_false_if_all_loaded_plugins_have_unique_timestamps() {
1✔
684
        let tmp_dir = tempdir().unwrap();
1✔
685
        let mut load_order = prepare(GameId::Morrowind, tmp_dir.path());
1✔
686

687
        for (index, plugin) in load_order.plugins_mut().iter_mut().enumerate() {
2✔
688
            plugin
2✔
689
                .set_modification_time(UNIX_EPOCH + Duration::new(index.try_into().unwrap(), 0))
2✔
690
                .unwrap();
2✔
691
        }
2✔
692

693
        assert!(!load_order.is_ambiguous().unwrap());
1✔
694
    }
1✔
695

696
    #[test]
697
    fn is_ambiguous_should_return_false_if_two_loaded_plugins_have_the_same_timestamp() {
1✔
698
        let tmp_dir = tempdir().unwrap();
1✔
699
        let mut load_order = prepare(GameId::Morrowind, tmp_dir.path());
1✔
700

1✔
701
        // Give two files the same timestamp.
1✔
702
        load_order.plugins_mut()[0]
1✔
703
            .set_modification_time(UNIX_EPOCH + Duration::new(2, 0))
1✔
704
            .unwrap();
1✔
705
        load_order.plugins_mut()[1]
1✔
706
            .set_modification_time(UNIX_EPOCH + Duration::new(2, 0))
1✔
707
            .unwrap();
1✔
708

1✔
709
        assert!(!load_order.is_ambiguous().unwrap());
1✔
710
    }
1✔
711

712
    #[test]
713
    fn plugin_sorter_should_sort_in_descending_filename_order_if_timestamps_are_equal() {
1✔
714
        let tmp_dir = tempdir().unwrap();
1✔
715
        let load_order = prepare(GameId::Morrowind, tmp_dir.path());
1✔
716

1✔
717
        let mut plugin1 = Plugin::new("Blank.esp", load_order.game_settings()).unwrap();
1✔
718
        let mut plugin2 = Plugin::new("Blank - Different.esp", load_order.game_settings()).unwrap();
1✔
719

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

1✔
724
        plugin2
1✔
725
            .set_modification_time(UNIX_EPOCH + Duration::new(2, 0))
1✔
726
            .unwrap();
1✔
727

1✔
728
        let ordering = plugin_sorter(&plugin1, &plugin2);
1✔
729

1✔
730
        assert_eq!(Ordering::Less, ordering);
1✔
731
    }
1✔
732
}
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