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

Ortham / libloadorder / 6991119250

25 Nov 2023 08:03PM UTC coverage: 91.865% (+0.2%) from 91.646%
6991119250

push

github

Ortham
Add context to Error::DecodeError

Unfortunately esplugin doesn't expose similar context, so decode errors
it reports are handled as generic plugin parsing errors. A future
esplugin update will provide that context, at which point libloadorder
can be updated to use it.

1 of 3 new or added lines in 3 files covered. (33.33%)

20 existing lines in 3 files now uncovered.

7532 of 8199 relevant lines covered (91.86%)

64094.08 hits per line

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

98.94
/src/load_order/mutable.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

20
use std::cmp::Ordering;
21
use std::collections::{BTreeMap, HashSet};
22
use std::fs::read_dir;
23
use std::mem;
24
use std::path::{Path, PathBuf};
25

26
use encoding_rs::WINDOWS_1252;
27
use rayon::prelude::*;
28
use unicase::UniCase;
29

30
use super::readable::{ReadableLoadOrder, ReadableLoadOrderBase};
31
use crate::enums::Error;
32
use crate::game_settings::GameSettings;
33
use crate::plugin::{has_plugin_extension, trim_dot_ghost, Plugin};
34
use crate::GameId;
35

36
pub trait MutableLoadOrder: ReadableLoadOrder + ReadableLoadOrderBase + Sync {
37
    fn plugins_mut(&mut self) -> &mut Vec<Plugin>;
38

39
    fn insert_position(&self, plugin: &Plugin) -> Option<usize>;
40

41
    fn find_plugins(&self) -> Vec<String> {
55✔
42
        // A game might store some plugins outside of its main plugins directory
55✔
43
        // so look for those plugins. They override any of the same names that
55✔
44
        // appear in the main plugins directory, so check for the additional
55✔
45
        // paths first.
55✔
46
        let mut directories = self
55✔
47
            .game_settings()
55✔
48
            .additional_plugins_directories()
55✔
49
            .to_vec();
55✔
50
        directories.push(self.game_settings().plugins_directory());
55✔
51

55✔
52
        find_plugins_in_dirs(&directories, self.game_settings().id())
55✔
53
    }
55✔
54

55
    fn validate_index(&self, plugin: &Plugin, index: usize) -> Result<(), Error> {
33✔
56
        if plugin.is_master_file() {
33✔
57
            validate_master_file_index(self.plugins(), plugin, index)
16✔
58
        } else {
59
            validate_non_master_file_index(self.plugins(), plugin, index)
17✔
60
        }
61
    }
33✔
62

63
    fn lookup_plugins(&mut self, active_plugin_names: &[&str]) -> Result<Vec<usize>, Error> {
11✔
64
        active_plugin_names
11✔
65
            .par_iter()
11✔
66
            .map(|n| {
8,972✔
67
                self.plugins()
8,972✔
68
                    .par_iter()
8,972✔
69
                    .position_any(|p| p.name_matches(n))
18,984,075✔
70
                    .ok_or_else(|| Error::PluginNotFound(n.to_string()))
8,972✔
71
            })
8,972✔
72
            .collect()
11✔
73
    }
11✔
74

75
    fn move_or_insert_plugin_with_index(
76
        &mut self,
77
        plugin_name: &str,
78
        position: usize,
79
    ) -> Result<usize, Error> {
80
        if let Some(x) = self.index_of(plugin_name) {
16✔
81
            if x == position {
8✔
82
                return Ok(position);
×
83
            }
8✔
84
        }
8✔
85

86
        let plugin = get_plugin_to_insert_at(self, plugin_name, position)?;
16✔
87

88
        if position >= self.plugins().len() {
11✔
89
            self.plugins_mut().push(plugin);
3✔
90
            Ok(self.plugins().len() - 1)
3✔
91
        } else {
92
            self.plugins_mut().insert(position, plugin);
8✔
93
            Ok(position)
8✔
94
        }
95
    }
16✔
96

97
    fn deactivate_all(&mut self) {
27✔
98
        for plugin in self.plugins_mut() {
10,919✔
99
            plugin.deactivate();
10,919✔
100
        }
10,919✔
101
    }
27✔
102

103
    fn replace_plugins(&mut self, plugin_names: &[&str]) -> Result<(), Error> {
18✔
104
        let mut unique_plugin_names = HashSet::new();
18✔
105

18✔
106
        let non_unique_plugin = plugin_names
18✔
107
            .iter()
18✔
108
            .find(|n| !unique_plugin_names.insert(UniCase::new(*n)));
87✔
109

110
        if let Some(n) = non_unique_plugin {
18✔
111
            return Err(Error::DuplicatePlugin(n.to_string()));
1✔
112
        }
17✔
113

114
        let mut plugins = map_to_plugins(self, plugin_names)?;
17✔
115

116
        validate_load_order(&plugins)?;
16✔
117

118
        mem::swap(&mut plugins, self.plugins_mut());
15✔
119

15✔
120
        Ok(())
15✔
121
    }
18✔
122

123
    fn load_unique_plugins(
38✔
124
        &mut self,
38✔
125
        plugin_name_tuples: Vec<(String, bool)>,
38✔
126
        installed_filenames: Vec<String>,
38✔
127
    ) {
38✔
128
        let plugins: Vec<_> = remove_duplicates_icase(plugin_name_tuples, installed_filenames)
38✔
129
            .into_par_iter()
38✔
130
            .filter_map(|(filename, active)| {
10,750✔
131
                Plugin::with_active(&filename, self.game_settings(), active).ok()
10,750✔
132
            })
10,750✔
133
            .collect();
38✔
134

135
        for plugin in plugins {
10,778✔
136
            insert(self, plugin);
10,740✔
137
        }
10,740✔
138
    }
38✔
139

140
    fn add_implicitly_active_plugins(&mut self) -> Result<(), Error> {
55✔
141
        let plugin_names = self.game_settings().implicitly_active_plugins().to_vec();
55✔
142

143
        for plugin_name in plugin_names {
206✔
144
            activate_unvalidated(self, &plugin_name)?;
151✔
145
        }
146

147
        Ok(())
55✔
148
    }
55✔
149
}
150

151
pub fn load_active_plugins<T, F>(load_order: &mut T, line_mapper: F) -> Result<(), Error>
22✔
152
where
22✔
153
    T: MutableLoadOrder,
22✔
154
    F: Fn(&str) -> Option<String> + Send + Sync,
22✔
155
{
22✔
156
    load_order.deactivate_all();
22✔
157

158
    let plugin_names = read_plugin_names(
22✔
159
        load_order.game_settings().active_plugins_file(),
22✔
160
        line_mapper,
22✔
161
    )?;
22✔
162

163
    let plugin_indices: Vec<_> = plugin_names
22✔
164
        .par_iter()
22✔
165
        .filter_map(|p| load_order.index_of(p))
22✔
166
        .collect();
22✔
167

168
    for index in plugin_indices {
37✔
169
        load_order.plugins_mut()[index].activate()?;
15✔
170
    }
171

172
    Ok(())
22✔
173
}
22✔
174

175
pub fn read_plugin_names<F, T>(file_path: &Path, line_mapper: F) -> Result<Vec<T>, Error>
70✔
176
where
70✔
177
    F: FnMut(&str) -> Option<T> + Send + Sync,
70✔
178
    T: Send,
70✔
179
{
70✔
180
    if !file_path.exists() {
70✔
181
        return Ok(Vec::new());
30✔
182
    }
40✔
183

184
    let content =
40✔
185
        std::fs::read(file_path).map_err(|e| Error::IoError(file_path.to_path_buf(), e))?;
40✔
186

187
    // This should never fail, as although Windows-1252 has a few unused bytes
188
    // they get mapped to C1 control characters.
189
    let decoded_content = WINDOWS_1252
40✔
190
        .decode_without_bom_handling_and_without_replacement(&content)
40✔
191
        .ok_or_else(|| Error::DecodeError(content.clone()))?;
40✔
192

193
    Ok(decoded_content.lines().filter_map(line_mapper).collect())
40✔
194
}
70✔
195

196
pub fn plugin_line_mapper(line: &str) -> Option<String> {
103✔
197
    if line.is_empty() || line.starts_with('#') {
103✔
198
        None
1✔
199
    } else {
200
        Some(line.to_owned())
102✔
201
    }
202
}
103✔
203

204
/// If an ESM has an ESP as a master, the ESP will be loaded directly before the
205
/// ESM instead of in its usual position. This function "hoists" such ESPs
206
/// further up the load order.
207
pub fn hoist_masters(plugins: &mut Vec<Plugin>) -> Result<(), Error> {
55✔
208
    // Store plugins' current positions and where they need to move to.
55✔
209
    // Use a BTreeMap so that if a plugin needs to move for more than one ESM,
55✔
210
    // it will move for the earlier one and so also satisfy the later one, and
55✔
211
    // so that it's possible to iterate over content in order.
55✔
212
    let mut from_to_map: BTreeMap<usize, usize> = BTreeMap::new();
55✔
213

214
    for (index, plugin) in plugins.iter().enumerate() {
10,681✔
215
        if !plugin.is_master_file() {
10,681✔
216
            break;
51✔
217
        }
10,630✔
218

219
        for master in plugin.masters()? {
10,630✔
220
            let pos = plugins
2✔
221
                .iter()
2✔
222
                .position(|p| p.name_matches(&master))
14✔
223
                .unwrap_or(0);
2✔
224
            if pos > index && !plugins[pos].is_master_file() {
2✔
225
                // Need to move the plugin to index, but can't do that while
2✔
226
                // iterating, so store it for later.
2✔
227
                from_to_map.insert(pos, index);
2✔
228
            }
2✔
229
        }
230
    }
231

232
    move_elements(plugins, from_to_map);
55✔
233

55✔
234
    Ok(())
55✔
235
}
55✔
236

237
pub fn generic_insert_position(plugins: &[Plugin], plugin: &Plugin) -> Option<usize> {
11,999✔
238
    if plugin.is_master_file() {
11,999✔
239
        find_first_non_master_position(plugins)
10,576✔
240
    } else {
241
        // Check that there isn't a master that would hoist this plugin.
242
        plugins.iter().filter(|p| p.is_master_file()).position(|p| {
207,793✔
243
            p.masters()
43,607✔
244
                .map(|masters| masters.iter().any(|m| plugin.name_matches(m)))
43,607✔
245
                .unwrap_or(false)
43,607✔
246
        })
43,607✔
247
    }
248
}
11,999✔
249

250
fn find_plugins_in_dirs(directories: &[PathBuf], game: GameId) -> Vec<String> {
58✔
251
    let mut dir_entries: Vec<_> = directories
58✔
252
        .iter()
58✔
253
        .flat_map(read_dir)
58✔
254
        .flatten()
58✔
255
        .filter_map(Result::ok)
58✔
256
        .filter(|e| e.file_type().map(|f| f.is_file()).unwrap_or(false))
10,855✔
257
        .filter(|e| {
10,855✔
258
            e.file_name()
10,855✔
259
                .to_str()
10,855✔
260
                .map(|f| has_plugin_extension(f, game))
10,855✔
261
                .unwrap_or(false)
10,855✔
262
        })
10,855✔
263
        .collect();
58✔
264

58✔
265
    // Sort by file modification timestamps, in ascending order. If two timestamps are equal, sort
58✔
266
    // by filenames (in ascending order for Starfield, descending otherwise).
58✔
267
    dir_entries.sort_unstable_by(|e1, e2| {
143,668✔
268
        let m1 = e1.metadata().and_then(|m| m.modified()).ok();
143,668✔
269
        let m2 = e2.metadata().and_then(|m| m.modified()).ok();
143,668✔
270

143,668✔
271
        match m1.cmp(&m2) {
143,668✔
272
            Ordering::Equal if game == GameId::Starfield => e1.file_name().cmp(&e2.file_name()),
76,736✔
273
            Ordering::Equal => e1.file_name().cmp(&e2.file_name()).reverse(),
76,735✔
274
            x => x,
66,932✔
275
        }
276
    });
143,668✔
277

58✔
278
    let mut set = HashSet::new();
58✔
279

58✔
280
    dir_entries
58✔
281
        .into_iter()
58✔
282
        .filter_map(|e| e.file_name().to_str().map(str::to_owned))
10,855✔
283
        .filter(|filename| set.insert(UniCase::new(trim_dot_ghost(filename).to_string())))
10,855✔
284
        .collect()
58✔
285
}
58✔
286

287
fn to_plugin(
85✔
288
    plugin_name: &str,
85✔
289
    existing_plugins: &[Plugin],
85✔
290
    game_settings: &GameSettings,
85✔
291
) -> Result<Plugin, Error> {
85✔
292
    existing_plugins
85✔
293
        .par_iter()
85✔
294
        .find_any(|p| p.name_matches(plugin_name))
219✔
295
        .map_or_else(
85✔
296
            || Plugin::new(plugin_name, game_settings),
85✔
297
            |p| Ok(p.clone()),
85✔
298
        )
85✔
299
}
85✔
300

301
fn validate_master_file_index(
16✔
302
    plugins: &[Plugin],
16✔
303
    plugin: &Plugin,
16✔
304
    index: usize,
16✔
305
) -> Result<(), Error> {
16✔
306
    let preceding_plugins = if index < plugins.len() {
16✔
307
        &plugins[..index]
14✔
308
    } else {
309
        plugins
2✔
310
    };
311

312
    let previous_master_pos = preceding_plugins
16✔
313
        .iter()
16✔
314
        .rposition(|p| p.is_master_file())
22✔
315
        .unwrap_or(0);
16✔
316

317
    let masters = plugin.masters()?;
16✔
318
    let master_names: HashSet<_> = masters.iter().map(|m| UniCase::new(m.as_str())).collect();
16✔
319

320
    // Check that all of the plugins that load between this index and
321
    // the previous plugin are masters of this plugin.
322
    if let Some(n) = preceding_plugins
16✔
323
        .iter()
16✔
324
        .skip(previous_master_pos + 1)
16✔
325
        .find(|p| !master_names.contains(&UniCase::new(p.name())))
16✔
326
    {
327
        return Err(Error::NonMasterBeforeMaster {
3✔
328
            master: plugin.name().to_string(),
3✔
329
            non_master: n.name().to_string(),
3✔
330
        });
3✔
331
    }
13✔
332

333
    // Check that none of the non-masters that load after index are
334
    // masters of this plugin.
335
    if let Some(p) = plugins
13✔
336
        .iter()
13✔
337
        .skip(index)
13✔
338
        .filter(|p| !p.is_master_file())
27✔
339
        .find(|p| master_names.contains(&UniCase::new(p.name())))
26✔
340
    {
341
        Err(Error::UnrepresentedHoist {
2✔
342
            plugin: p.name().to_string(),
2✔
343
            master: plugin.name().to_string(),
2✔
344
        })
2✔
345
    } else {
346
        Ok(())
11✔
347
    }
348
}
16✔
349

350
fn validate_non_master_file_index(
17✔
351
    plugins: &[Plugin],
17✔
352
    plugin: &Plugin,
17✔
353
    index: usize,
17✔
354
) -> Result<(), Error> {
17✔
355
    // Check that there aren't any earlier master files that have this
356
    // plugin as a master.
357
    for master_file in plugins.iter().take(index).filter(|p| p.is_master_file()) {
25✔
358
        if master_file
15✔
359
            .masters()?
15✔
360
            .iter()
15✔
361
            .any(|m| plugin.name_matches(m))
15✔
362
        {
363
            return Err(Error::UnrepresentedHoist {
1✔
364
                plugin: plugin.name().to_string(),
1✔
365
                master: master_file.name().to_string(),
1✔
366
            });
1✔
367
        }
14✔
368
    }
369

370
    // Check that the next master file has this plugin as a master.
371
    let next_master = match plugins.iter().skip(index).find(|p| p.is_master_file()) {
18✔
372
        None => return Ok(()),
9✔
373
        Some(p) => p,
7✔
374
    };
7✔
375

7✔
376
    if next_master
7✔
377
        .masters()?
7✔
378
        .iter()
7✔
379
        .any(|m| plugin.name_matches(m))
7✔
380
    {
381
        Ok(())
4✔
382
    } else {
383
        Err(Error::NonMasterBeforeMaster {
3✔
384
            master: next_master.name().to_string(),
3✔
385
            non_master: plugin.name().to_string(),
3✔
386
        })
3✔
387
    }
388
}
17✔
389

390
fn map_to_plugins<T: ReadableLoadOrderBase + Sync + ?Sized>(
17✔
391
    load_order: &T,
17✔
392
    plugin_names: &[&str],
17✔
393
) -> Result<Vec<Plugin>, Error> {
17✔
394
    plugin_names
17✔
395
        .par_iter()
17✔
396
        .map(|n| to_plugin(n, load_order.plugins(), load_order.game_settings_base()))
85✔
397
        .collect()
17✔
398
}
17✔
399

400
fn insert<T: MutableLoadOrder + ?Sized>(load_order: &mut T, plugin: Plugin) -> usize {
10,740✔
401
    match load_order.insert_position(&plugin) {
10,740✔
402
        Some(position) => {
39✔
403
            load_order.plugins_mut().insert(position, plugin);
39✔
404
            position
39✔
405
        }
406
        None => {
407
            load_order.plugins_mut().push(plugin);
10,701✔
408
            load_order.plugins().len() - 1
10,701✔
409
        }
410
    }
411
}
10,740✔
412

413
fn move_elements<T>(vec: &mut Vec<T>, mut from_to_indices: BTreeMap<usize, usize>) {
56✔
414
    // Move elements around. Moving elements doesn't change from_index values,
415
    // as we're iterating from earliest index to latest, but to_index values can
416
    // become incorrect, e.g. (5, 2), (6, 3), (7, 1) will insert an element
417
    // before index 3 so that should become 4, but 1 is still correct.
418
    // Keeping track of what indices need offsets is probably not worth it as
419
    // this function is likely to be called with empty or very small maps, so
420
    // just loop through it after each move and increment any affected to_index
421
    // values.
422
    while let Some((from_index, to_index)) = from_to_indices.pop_first() {
61✔
423
        let element = vec.remove(from_index);
5✔
424
        vec.insert(to_index, element);
5✔
425

426
        for value in from_to_indices.values_mut() {
5✔
427
            if *value > to_index {
3✔
428
                *value += 1;
1✔
429
            }
2✔
430
        }
431
    }
432
}
56✔
433

434
fn get_plugin_to_insert_at<T: MutableLoadOrder + ?Sized>(
435
    load_order: &mut T,
436
    plugin_name: &str,
437
    insert_position: usize,
438
) -> Result<Plugin, Error> {
439
    if let Some(p) = load_order.index_of(plugin_name) {
16✔
440
        let plugin = &load_order.plugins()[p];
8✔
441
        load_order.validate_index(plugin, insert_position)?;
8✔
442

443
        Ok(load_order.plugins_mut().remove(p))
6✔
444
    } else {
445
        let plugin = Plugin::new(plugin_name, load_order.game_settings())?;
8✔
446

447
        load_order.validate_index(&plugin, insert_position)?;
7✔
448

449
        Ok(plugin)
5✔
450
    }
451
}
16✔
452

453
fn validate_load_order(plugins: &[Plugin]) -> Result<(), Error> {
22✔
454
    let first_non_master_pos = match find_first_non_master_position(plugins) {
22✔
455
        None => return Ok(()),
2✔
456
        Some(x) => x,
20✔
457
    };
458

459
    let last_master_pos = match plugins.iter().rposition(|p| p.is_master_file()) {
69✔
460
        None => return Ok(()),
1✔
461
        Some(x) => x,
19✔
462
    };
19✔
463

19✔
464
    let mut plugin_names: HashSet<_> = HashSet::new();
19✔
465

19✔
466
    // Add each plugin that isn't a master file to the hashset.
19✔
467
    // When a master file is encountered, remove its masters from the hashset.
19✔
468
    // If there are any plugins left in the hashset, they weren't hoisted there,
19✔
469
    // so fail the check.
19✔
470
    if first_non_master_pos < last_master_pos {
19✔
471
        for plugin in plugins
11✔
472
            .iter()
5✔
473
            .skip(first_non_master_pos)
5✔
474
            .take(last_master_pos - first_non_master_pos + 1)
5✔
475
        {
476
            if !plugin.is_master_file() {
11✔
477
                plugin_names.insert(UniCase::new(plugin.name().to_string()));
5✔
478
            } else {
5✔
479
                for master in plugin.masters()? {
6✔
480
                    plugin_names.remove(&UniCase::new(master.clone()));
3✔
481
                }
3✔
482

483
                if let Some(n) = plugin_names.iter().next() {
6✔
484
                    return Err(Error::NonMasterBeforeMaster {
2✔
485
                        master: plugin.name().to_string(),
2✔
486
                        non_master: n.to_string(),
2✔
487
                    });
2✔
488
                }
4✔
489
            }
490
        }
491
    }
14✔
492

493
    // Now check in reverse that no master file depends on a non-master that
494
    // loads after it.
495
    plugin_names.clear();
17✔
496
    for plugin in plugins.iter().rev() {
86✔
497
        if !plugin.is_master_file() {
86✔
498
            plugin_names.insert(UniCase::new(plugin.name().to_string()));
51✔
499
        } else if let Some(m) = plugin
51✔
500
            .masters()?
35✔
501
            .iter()
35✔
502
            .find(|m| plugin_names.contains(&UniCase::new(m.to_string())))
35✔
503
        {
504
            return Err(Error::UnrepresentedHoist {
1✔
505
                plugin: m.clone(),
1✔
506
                master: plugin.name().to_string(),
1✔
507
            });
1✔
508
        }
34✔
509
    }
510

511
    Ok(())
16✔
512
}
22✔
513

514
fn remove_duplicates_icase(
38✔
515
    plugin_tuples: Vec<(String, bool)>,
38✔
516
    filenames: Vec<String>,
38✔
517
) -> Vec<(String, bool)> {
38✔
518
    let mut set: HashSet<_> = HashSet::with_capacity(filenames.len());
38✔
519

38✔
520
    let mut unique_tuples: Vec<(String, bool)> = plugin_tuples
38✔
521
        .into_iter()
38✔
522
        .rev()
38✔
523
        .filter(|(string, _)| set.insert(UniCase::new(trim_dot_ghost(string).to_string())))
10,589✔
524
        .collect();
38✔
525

38✔
526
    unique_tuples.reverse();
38✔
527

38✔
528
    let unique_file_tuples_iter = filenames
38✔
529
        .into_iter()
38✔
530
        .filter(|string| set.insert(UniCase::new(trim_dot_ghost(string).to_string())))
10,744✔
531
        .map(|f| (f, false));
161✔
532

38✔
533
    unique_tuples.extend(unique_file_tuples_iter);
38✔
534

38✔
535
    unique_tuples
38✔
536
}
38✔
537

538
fn activate_unvalidated<T: MutableLoadOrder + ?Sized>(
539
    load_order: &mut T,
540
    filename: &str,
541
) -> Result<(), Error> {
542
    if let Some(plugin) = load_order
151✔
543
        .plugins_mut()
151✔
544
        .iter_mut()
151✔
545
        .find(|p| p.name_matches(filename))
42,764✔
546
    {
547
        plugin.activate()
40✔
548
    } else {
549
        // Ignore any errors trying to load the plugin to save checking if it's
550
        // valid and then loading it if it is.
551
        Plugin::with_active(filename, load_order.game_settings(), true)
111✔
552
            .map(|plugin| {
111✔
UNCOV
553
                insert(load_order, plugin);
×
554
            })
111✔
555
            .or(Ok(()))
111✔
556
    }
557
}
151✔
558

559
fn find_first_non_master_position(plugins: &[Plugin]) -> Option<usize> {
10,600✔
560
    plugins.iter().position(|p| !p.is_master_file())
27,683,522✔
561
}
10,600✔
562

563
#[cfg(test)]
564
mod tests {
565
    use super::*;
566

567
    use crate::enums::GameId;
568
    use crate::game_settings::GameSettings;
569
    use crate::load_order::tests::*;
570
    use crate::tests::copy_to_test_dir;
571

572
    use tempfile::tempdir;
573

574
    struct TestLoadOrder {
575
        game_settings: GameSettings,
576
        plugins: Vec<Plugin>,
577
    }
578

579
    impl ReadableLoadOrderBase for TestLoadOrder {
580
        fn game_settings_base(&self) -> &GameSettings {
28✔
581
            &self.game_settings
28✔
582
        }
28✔
583

584
        fn plugins(&self) -> &[Plugin] {
8✔
585
            &self.plugins
8✔
586
        }
8✔
587
    }
588

589
    impl MutableLoadOrder for TestLoadOrder {
UNCOV
590
        fn plugins_mut(&mut self) -> &mut Vec<Plugin> {
×
UNCOV
591
            &mut self.plugins
×
UNCOV
592
        }
×
593

594
        fn insert_position(&self, plugin: &Plugin) -> Option<usize> {
×
595
            generic_insert_position(self.plugins(), plugin)
×
596
        }
×
597
    }
598

599
    fn prepare(game_path: &Path) -> GameSettings {
6✔
600
        let settings = game_settings_for_test(GameId::SkyrimSE, game_path);
6✔
601

6✔
602
        copy_to_test_dir("Blank.esm", settings.master_file(), &settings);
6✔
603
        copy_to_test_dir("Blank.esm", "Blank.esm", &settings);
6✔
604
        copy_to_test_dir("Blank.esp", "Blank.esp", &settings);
6✔
605
        copy_to_test_dir("Blank - Different.esp", "Blank - Different.esp", &settings);
6✔
606
        copy_to_test_dir(
6✔
607
            "Blank - Plugin Dependent.esp",
6✔
608
            "Blank - Plugin Dependent.esm",
6✔
609
            &settings,
6✔
610
        );
6✔
611

6✔
612
        settings
6✔
613
    }
6✔
614

615
    fn prepare_load_order(game_dir: &Path) -> TestLoadOrder {
10✔
616
        let (game_settings, plugins) = mock_game_files(GameId::Oblivion, game_dir);
10✔
617
        TestLoadOrder {
10✔
618
            game_settings,
10✔
619
            plugins,
10✔
620
        }
10✔
621
    }
10✔
622

623
    fn prepare_hoisted_load_order(game_path: &Path) -> TestLoadOrder {
5✔
624
        let load_order = prepare_load_order(game_path);
5✔
625

5✔
626
        let plugins_dir = &load_order.game_settings().plugins_directory();
5✔
627
        copy_to_test_dir(
5✔
628
            "Blank - Different.esm",
5✔
629
            "Blank - Different.esm",
5✔
630
            load_order.game_settings(),
5✔
631
        );
5✔
632
        set_master_flag(&plugins_dir.join("Blank - Different.esm"), false).unwrap();
5✔
633
        copy_to_test_dir(
5✔
634
            "Blank - Different Master Dependent.esm",
5✔
635
            "Blank - Different Master Dependent.esm",
5✔
636
            load_order.game_settings(),
5✔
637
        );
5✔
638

5✔
639
        load_order
5✔
640
    }
5✔
641

642
    fn prepare_plugins(game_path: &Path, blank_esp_source: &str) -> Vec<Plugin> {
2✔
643
        let settings = game_settings_for_test(GameId::SkyrimSE, game_path);
2✔
644

2✔
645
        copy_to_test_dir("Blank.esm", settings.master_file(), &settings);
2✔
646
        copy_to_test_dir(blank_esp_source, "Blank.esp", &settings);
2✔
647

2✔
648
        vec![
2✔
649
            Plugin::new(settings.master_file(), &settings).unwrap(),
2✔
650
            Plugin::new("Blank.esp", &settings).unwrap(),
2✔
651
        ]
2✔
652
    }
2✔
653

654
    #[test]
1✔
655
    fn validate_index_should_succeed_for_a_master_plugin_and_index_directly_after_a_master() {
1✔
656
        let tmp_dir = tempdir().unwrap();
1✔
657
        let load_order = prepare_load_order(&tmp_dir.path());
1✔
658

1✔
659
        let plugin = Plugin::new("Blank.esm", load_order.game_settings()).unwrap();
1✔
660
        assert!(load_order.validate_index(&plugin, 1).is_ok());
1✔
661
    }
1✔
662

663
    #[test]
1✔
664
    fn validate_index_should_succeed_for_a_master_plugin_and_index_after_a_hoisted_non_master() {
1✔
665
        let tmp_dir = tempdir().unwrap();
1✔
666
        let mut load_order = prepare_hoisted_load_order(&tmp_dir.path());
1✔
667

1✔
668
        let plugin = Plugin::new("Blank - Different.esm", load_order.game_settings()).unwrap();
1✔
669
        load_order.plugins.insert(1, plugin);
1✔
670

1✔
671
        let plugin = Plugin::new(
1✔
672
            "Blank - Different Master Dependent.esm",
1✔
673
            load_order.game_settings(),
1✔
674
        )
1✔
675
        .unwrap();
1✔
676
        assert!(load_order.validate_index(&plugin, 2).is_ok());
1✔
677
    }
1✔
678

679
    #[test]
1✔
680
    fn validate_index_should_error_for_a_master_plugin_and_index_after_unrelated_non_masters() {
1✔
681
        let tmp_dir = tempdir().unwrap();
1✔
682
        let mut load_order = prepare_hoisted_load_order(&tmp_dir.path());
1✔
683

1✔
684
        let plugin = Plugin::new("Blank - Different.esm", load_order.game_settings()).unwrap();
1✔
685
        load_order.plugins.insert(1, plugin);
1✔
686

1✔
687
        let plugin = Plugin::new("Blank.esm", load_order.game_settings()).unwrap();
1✔
688
        assert!(load_order.validate_index(&plugin, 4).is_err());
1✔
689
    }
1✔
690

691
    #[test]
1✔
692
    fn validate_index_should_error_for_a_master_plugin_that_has_a_later_non_master_as_a_master() {
1✔
693
        let tmp_dir = tempdir().unwrap();
1✔
694
        let mut load_order = prepare_hoisted_load_order(&tmp_dir.path());
1✔
695

1✔
696
        let plugin = Plugin::new("Blank - Different.esm", load_order.game_settings()).unwrap();
1✔
697
        load_order.plugins.insert(2, plugin);
1✔
698

1✔
699
        let plugin = Plugin::new(
1✔
700
            "Blank - Different Master Dependent.esm",
1✔
701
            load_order.game_settings(),
1✔
702
        )
1✔
703
        .unwrap();
1✔
704
        assert!(load_order.validate_index(&plugin, 1).is_err());
1✔
705
    }
1✔
706

707
    #[test]
1✔
708
    fn validate_index_should_succeed_for_a_non_master_plugin_and_an_index_with_no_later_masters() {
1✔
709
        let tmp_dir = tempdir().unwrap();
1✔
710
        let load_order = prepare_load_order(&tmp_dir.path());
1✔
711

1✔
712
        let plugin =
1✔
713
            Plugin::new("Blank - Master Dependent.esp", load_order.game_settings()).unwrap();
1✔
714
        assert!(load_order.validate_index(&plugin, 2).is_ok());
1✔
715
    }
1✔
716

717
    #[test]
1✔
718
    fn validate_index_should_succeed_for_a_non_master_plugin_that_is_a_master_of_the_next_master_file(
1✔
719
    ) {
1✔
720
        let tmp_dir = tempdir().unwrap();
1✔
721
        let mut load_order = prepare_hoisted_load_order(&tmp_dir.path());
1✔
722

1✔
723
        let plugin = Plugin::new(
1✔
724
            "Blank - Different Master Dependent.esm",
1✔
725
            load_order.game_settings(),
1✔
726
        )
1✔
727
        .unwrap();
1✔
728
        load_order.plugins.insert(1, plugin);
1✔
729

1✔
730
        let plugin = Plugin::new("Blank - Different.esm", load_order.game_settings()).unwrap();
1✔
731
        assert!(load_order.validate_index(&plugin, 1).is_ok());
1✔
732
    }
1✔
733

734
    #[test]
1✔
735
    fn validate_index_should_error_for_a_non_master_plugin_that_is_not_a_master_of_the_next_master_file(
1✔
736
    ) {
1✔
737
        let tmp_dir = tempdir().unwrap();
1✔
738
        let load_order = prepare_load_order(&tmp_dir.path());
1✔
739

1✔
740
        let plugin =
1✔
741
            Plugin::new("Blank - Master Dependent.esp", load_order.game_settings()).unwrap();
1✔
742
        assert!(load_order.validate_index(&plugin, 0).is_err());
1✔
743
    }
1✔
744

745
    #[test]
1✔
746
    fn validate_index_should_error_for_a_non_master_plugin_and_an_index_not_before_a_master_that_depends_on_it(
1✔
747
    ) {
1✔
748
        let tmp_dir = tempdir().unwrap();
1✔
749
        let mut load_order = prepare_hoisted_load_order(&tmp_dir.path());
1✔
750

1✔
751
        let plugin = Plugin::new(
1✔
752
            "Blank - Different Master Dependent.esm",
1✔
753
            load_order.game_settings(),
1✔
754
        )
1✔
755
        .unwrap();
1✔
756
        load_order.plugins.insert(1, plugin);
1✔
757

1✔
758
        let plugin = Plugin::new("Blank - Different.esm", load_order.game_settings()).unwrap();
1✔
759
        assert!(load_order.validate_index(&plugin, 2).is_err());
1✔
760
    }
1✔
761

762
    #[test]
1✔
763
    fn find_plugins_in_dirs_should_sort_files_by_modification_timestamp() {
1✔
764
        let tmp_dir = tempdir().unwrap();
1✔
765
        let load_order = prepare_load_order(&tmp_dir.path());
1✔
766

1✔
767
        let result = find_plugins_in_dirs(
1✔
768
            &[load_order.game_settings.plugins_directory()],
1✔
769
            load_order.game_settings.id(),
1✔
770
        );
1✔
771

1✔
772
        let plugin_names = [
1✔
773
            load_order.game_settings.master_file(),
1✔
774
            "Blank.esm",
1✔
775
            "Blank.esp",
1✔
776
            "Blank - Different.esp",
1✔
777
            "Blank - Master Dependent.esp",
1✔
778
            "Blàñk.esp",
1✔
779
        ];
1✔
780

1✔
781
        assert_eq!(plugin_names.as_slice(), result);
1✔
782
    }
1✔
783

784
    #[test]
1✔
785
    fn find_plugins_in_dirs_should_sort_files_by_descending_filename_if_timestamps_are_equal() {
1✔
786
        let tmp_dir = tempdir().unwrap();
1✔
787
        let load_order = prepare_load_order(&tmp_dir.path());
1✔
788

1✔
789
        let timestamp = 1321010051;
1✔
790
        filetime::set_file_mtime(
1✔
791
            load_order
1✔
792
                .game_settings
1✔
793
                .plugins_directory()
1✔
794
                .join("Blank - Different.esp"),
1✔
795
            filetime::FileTime::from_unix_time(timestamp, 0),
1✔
796
        )
1✔
797
        .unwrap();
1✔
798
        filetime::set_file_mtime(
1✔
799
            load_order
1✔
800
                .game_settings
1✔
801
                .plugins_directory()
1✔
802
                .join("Blank - Master Dependent.esp"),
1✔
803
            filetime::FileTime::from_unix_time(timestamp, 0),
1✔
804
        )
1✔
805
        .unwrap();
1✔
806

1✔
807
        let result = find_plugins_in_dirs(
1✔
808
            &[load_order.game_settings.plugins_directory()],
1✔
809
            load_order.game_settings.id(),
1✔
810
        );
1✔
811

1✔
812
        let plugin_names = [
1✔
813
            load_order.game_settings.master_file(),
1✔
814
            "Blank.esm",
1✔
815
            "Blank.esp",
1✔
816
            "Blank - Master Dependent.esp",
1✔
817
            "Blank - Different.esp",
1✔
818
            "Blàñk.esp",
1✔
819
        ];
1✔
820

1✔
821
        assert_eq!(plugin_names.as_slice(), result);
1✔
822
    }
1✔
823

824
    #[test]
1✔
825
    fn find_plugins_in_dirs_should_sort_files_by_ascending_filename_if_timestamps_are_equal_and_game_is_starfield(
1✔
826
    ) {
1✔
827
        let tmp_dir = tempdir().unwrap();
1✔
828
        let (game_settings, plugins) = mock_game_files(GameId::Starfield, &tmp_dir.path());
1✔
829
        let load_order = TestLoadOrder {
1✔
830
            game_settings,
1✔
831
            plugins,
1✔
832
        };
1✔
833

1✔
834
        let timestamp = 1321009991;
1✔
835
        filetime::set_file_mtime(
1✔
836
            load_order
1✔
837
                .game_settings
1✔
838
                .plugins_directory()
1✔
839
                .join("Blank - Different.esp"),
1✔
840
            filetime::FileTime::from_unix_time(timestamp, 0),
1✔
841
        )
1✔
842
        .unwrap();
1✔
843
        filetime::set_file_mtime(
1✔
844
            load_order
1✔
845
                .game_settings
1✔
846
                .plugins_directory()
1✔
847
                .join("Blank.esp"),
1✔
848
            filetime::FileTime::from_unix_time(timestamp, 0),
1✔
849
        )
1✔
850
        .unwrap();
1✔
851

1✔
852
        let result = find_plugins_in_dirs(
1✔
853
            &[load_order.game_settings.plugins_directory()],
1✔
854
            load_order.game_settings.id(),
1✔
855
        );
1✔
856

1✔
857
        let plugin_names = [
1✔
858
            load_order.game_settings.master_file(),
1✔
859
            "Blank.esm",
1✔
860
            "Blank - Different.esp",
1✔
861
            "Blank.esp",
1✔
862
            "Blank - Master Dependent.esp",
1✔
863
            "Blàñk.esp",
1✔
864
        ];
1✔
865

1✔
866
        assert_eq!(plugin_names.as_slice(), result);
1✔
867
    }
1✔
868

869
    #[test]
1✔
870
    fn move_elements_should_correct_later_indices_to_account_for_earlier_moves() {
1✔
871
        let mut vec = vec![0, 1, 2, 3, 4, 5, 6, 7, 8];
1✔
872
        let mut from_to_indices = BTreeMap::new();
1✔
873
        from_to_indices.insert(6, 3);
1✔
874
        from_to_indices.insert(5, 2);
1✔
875
        from_to_indices.insert(7, 1);
1✔
876

1✔
877
        move_elements(&mut vec, from_to_indices);
1✔
878

1✔
879
        assert_eq!(vec![0, 7, 1, 5, 2, 6, 3, 4, 8], vec);
1✔
880
    }
1✔
881

882
    #[test]
1✔
883
    fn validate_load_order_should_be_ok_if_there_are_only_master_files() {
1✔
884
        let tmp_dir = tempdir().unwrap();
1✔
885
        let settings = prepare(&tmp_dir.path());
1✔
886

1✔
887
        let plugins = vec![
1✔
888
            Plugin::new(settings.master_file(), &settings).unwrap(),
1✔
889
            Plugin::new("Blank.esm", &settings).unwrap(),
1✔
890
        ];
1✔
891

1✔
892
        assert!(validate_load_order(&plugins).is_ok());
1✔
893
    }
1✔
894

895
    #[test]
1✔
896
    fn validate_load_order_should_be_ok_if_there_are_no_master_files() {
1✔
897
        let tmp_dir = tempdir().unwrap();
1✔
898
        let settings = prepare(&tmp_dir.path());
1✔
899

1✔
900
        let plugins = vec![
1✔
901
            Plugin::new("Blank.esp", &settings).unwrap(),
1✔
902
            Plugin::new("Blank - Different.esp", &settings).unwrap(),
1✔
903
        ];
1✔
904

1✔
905
        assert!(validate_load_order(&plugins).is_ok());
1✔
906
    }
1✔
907

908
    #[test]
1✔
909
    fn validate_load_order_should_be_ok_if_master_files_are_before_all_others() {
1✔
910
        let tmp_dir = tempdir().unwrap();
1✔
911
        let settings = prepare(&tmp_dir.path());
1✔
912

1✔
913
        let plugins = vec![
1✔
914
            Plugin::new("Blank.esm", &settings).unwrap(),
1✔
915
            Plugin::new("Blank.esp", &settings).unwrap(),
1✔
916
        ];
1✔
917

1✔
918
        assert!(validate_load_order(&plugins).is_ok());
1✔
919
    }
1✔
920

921
    #[test]
1✔
922
    fn validate_load_order_should_be_ok_if_hoisted_non_masters_load_before_masters() {
1✔
923
        let tmp_dir = tempdir().unwrap();
1✔
924
        let settings = prepare(&tmp_dir.path());
1✔
925

1✔
926
        let plugins = vec![
1✔
927
            Plugin::new("Blank.esm", &settings).unwrap(),
1✔
928
            Plugin::new("Blank.esp", &settings).unwrap(),
1✔
929
            Plugin::new("Blank - Plugin Dependent.esm", &settings).unwrap(),
1✔
930
        ];
1✔
931

1✔
932
        assert!(validate_load_order(&plugins).is_ok());
1✔
933
    }
1✔
934

935
    #[test]
1✔
936
    fn validate_load_order_should_error_if_non_masters_are_hoisted_earlier_than_needed() {
1✔
937
        let tmp_dir = tempdir().unwrap();
1✔
938
        let settings = prepare(&tmp_dir.path());
1✔
939

1✔
940
        let plugins = vec![
1✔
941
            Plugin::new("Blank.esp", &settings).unwrap(),
1✔
942
            Plugin::new("Blank.esm", &settings).unwrap(),
1✔
943
            Plugin::new("Blank - Plugin Dependent.esm", &settings).unwrap(),
1✔
944
        ];
1✔
945

1✔
946
        assert!(validate_load_order(&plugins).is_err());
1✔
947
    }
1✔
948

949
    #[test]
1✔
950
    fn validate_load_order_should_error_if_master_files_load_before_non_masters_they_have_as_masters(
1✔
951
    ) {
1✔
952
        let tmp_dir = tempdir().unwrap();
1✔
953
        let settings = prepare(&tmp_dir.path());
1✔
954

1✔
955
        let plugins = vec![
1✔
956
            Plugin::new("Blank.esm", &settings).unwrap(),
1✔
957
            Plugin::new("Blank - Plugin Dependent.esm", &settings).unwrap(),
1✔
958
            Plugin::new("Blank.esp", &settings).unwrap(),
1✔
959
        ];
1✔
960

1✔
961
        assert!(validate_load_order(&plugins).is_err());
1✔
962
    }
1✔
963

964
    #[test]
1✔
965
    fn find_first_non_master_should_find_a_normal_esp() {
1✔
966
        let tmp_dir = tempdir().unwrap();
1✔
967
        let plugins = prepare_plugins(&tmp_dir.path(), "Blank.esp");
1✔
968

1✔
969
        let first_non_master = super::find_first_non_master_position(&plugins);
1✔
970
        assert_eq!(1, first_non_master.unwrap());
1✔
971
    }
1✔
972

973
    #[test]
1✔
974
    fn find_first_non_master_should_find_a_light_flagged_esp() {
1✔
975
        let tmp_dir = tempdir().unwrap();
1✔
976
        let plugins = prepare_plugins(&tmp_dir.path(), "Blank.esl");
1✔
977

1✔
978
        let first_non_master = super::find_first_non_master_position(&plugins);
1✔
979
        assert_eq!(1, first_non_master.unwrap());
1✔
980
    }
1✔
981
}
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