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

Ortham / libloadorder / 9666369648

25 Jun 2024 04:41PM UTC coverage: 91.779% (-0.007%) from 91.786%
9666369648

push

github

Ortham
Update esplugin

7402 of 8065 relevant lines covered (91.78%)

72762.87 hits per line

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

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

57✔
52
        find_plugins_in_dirs(&directories, self.game_settings().id())
57✔
53
    }
57✔
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> {
13✔
64
        active_plugin_names
13✔
65
            .par_iter()
13✔
66
            .map(|n| {
9,228✔
67
                self.plugins()
9,228✔
68
                    .par_iter()
9,228✔
69
                    .position_any(|p| p.name_matches(n))
21,297,077✔
70
                    .ok_or_else(|| Error::PluginNotFound(n.to_string()))
9,228✔
71
            })
9,228✔
72
            .collect()
13✔
73
    }
13✔
74

75
    fn move_or_insert_plugin_with_index(
16✔
76
        &mut self,
16✔
77
        plugin_name: &str,
16✔
78
        position: usize,
16✔
79
    ) -> Result<usize, Error> {
16✔
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) {
26✔
98
        for plugin in self.plugins_mut() {
11,180✔
99
            plugin.deactivate();
11,180✔
100
        }
11,180✔
101
    }
26✔
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(
40✔
124
        &mut self,
40✔
125
        plugin_name_tuples: Vec<(String, bool)>,
40✔
126
        installed_filenames: Vec<String>,
40✔
127
    ) {
40✔
128
        let plugins: Vec<_> = remove_duplicates_icase(plugin_name_tuples, installed_filenames)
40✔
129
            .into_par_iter()
40✔
130
            .filter_map(|(filename, active)| {
11,287✔
131
                Plugin::with_active(&filename, self.game_settings(), active).ok()
11,287✔
132
            })
11,287✔
133
            .collect();
40✔
134

135
        for plugin in plugins {
11,315✔
136
            insert(self, plugin);
11,275✔
137
        }
11,275✔
138
    }
40✔
139

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

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

147
        Ok(())
57✔
148
    }
57✔
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>
72✔
176
where
72✔
177
    F: FnMut(&str) -> Option<T> + Send + Sync,
72✔
178
    T: Send,
72✔
179
{
72✔
180
    if !file_path.exists() {
72✔
181
        return Ok(Vec::new());
30✔
182
    }
42✔
183

184
    let content =
42✔
185
        std::fs::read(file_path).map_err(|e| Error::IoError(file_path.to_path_buf(), e))?;
42✔
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
42✔
190
        .decode_without_bom_handling_and_without_replacement(&content)
42✔
191
        .ok_or_else(|| Error::DecodeError(content.clone()))?;
42✔
192

193
    Ok(decoded_content.lines().filter_map(line_mapper).collect())
42✔
194
}
72✔
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 a master that is lower down in the load order, the master will
205
/// be loaded directly before the ESM instead of in its usual position. This
206
/// function "hoists" such masters further up the load order to match that
207
/// behaviour.
208
pub fn hoist_masters(plugins: &mut Vec<Plugin>) -> Result<(), Error> {
57✔
209
    // Store plugins' current positions and where they need to move to.
57✔
210
    // Use a BTreeMap so that if a plugin needs to move for more than one ESM,
57✔
211
    // it will move for the earlier one and so also satisfy the later one, and
57✔
212
    // so that it's possible to iterate over content in order.
57✔
213
    let mut from_to_map: BTreeMap<usize, usize> = BTreeMap::new();
57✔
214

215
    for (index, plugin) in plugins.iter().enumerate() {
11,216✔
216
        if !plugin.is_master_file() {
11,216✔
217
            break;
53✔
218
        }
11,163✔
219

220
        for master in plugin.masters()? {
11,163✔
221
            let pos = plugins
5✔
222
                .iter()
5✔
223
                .position(|p| p.name_matches(&master))
29✔
224
                .unwrap_or(0);
5✔
225
            if pos > index {
5✔
226
                // Need to move the plugin to index, but can't do that while
5✔
227
                // iterating, so store it for later.
5✔
228
                from_to_map.entry(pos).or_insert(index);
5✔
229
            }
5✔
230
        }
231
    }
232

233
    move_elements(plugins, from_to_map);
57✔
234

57✔
235
    Ok(())
57✔
236
}
57✔
237

238
pub fn generic_insert_position(plugins: &[Plugin], plugin: &Plugin) -> Option<usize> {
12,533✔
239
    if plugin.is_master_file() {
12,533✔
240
        find_first_non_master_position(plugins)
11,110✔
241
    } else {
242
        // Check that there isn't a master that would hoist this plugin.
243
        plugins.iter().filter(|p| p.is_master_file()).position(|p| {
186,518✔
244
            p.masters()
23,611✔
245
                .map(|masters| masters.iter().any(|m| plugin.name_matches(m)))
23,611✔
246
                .unwrap_or(false)
23,611✔
247
        })
23,611✔
248
    }
249
}
12,533✔
250

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

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

151,554✔
272
        match m1.cmp(&m2) {
151,554✔
273
            Ordering::Equal if game == GameId::Starfield => e1.file_name().cmp(&e2.file_name()),
49,308✔
274
            Ordering::Equal => e1.file_name().cmp(&e2.file_name()).reverse(),
10✔
275
            x => x,
102,246✔
276
        }
277
    });
151,554✔
278

60✔
279
    let mut set = HashSet::new();
60✔
280

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

40✔
527
    unique_tuples.reverse();
40✔
528

40✔
529
    let unique_file_tuples_iter = filenames
40✔
530
        .into_iter()
40✔
531
        .filter(|string| set.insert(UniCase::new(trim_dot_ghost(string).to_string())))
11,279✔
532
        .map(|f| (f, false));
161✔
533

40✔
534
    unique_tuples.extend(unique_file_tuples_iter);
40✔
535

40✔
536
    unique_tuples
40✔
537
}
40✔
538

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

560
fn find_first_non_master_position(plugins: &[Plugin]) -> Option<usize> {
11,134✔
561
    plugins.iter().position(|p| !p.is_master_file())
30,509,213✔
562
}
11,134✔
563

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

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

573
    use tempfile::tempdir;
574

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

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

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

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

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

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

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

6✔
613
        settings
6✔
614
    }
6✔
615

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

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

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

5✔
640
        load_order
5✔
641
    }
5✔
642

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1✔
790
        let timestamp = 1321010051;
1✔
791
        let plugin_path = load_order
1✔
792
            .game_settings
1✔
793
            .plugins_directory()
1✔
794
            .join("Blank - Different.esp");
1✔
795
        set_file_timestamps(&plugin_path, timestamp);
1✔
796
        let plugin_path = load_order
1✔
797
            .game_settings
1✔
798
            .plugins_directory()
1✔
799
            .join("Blank - Master Dependent.esp");
1✔
800
        set_file_timestamps(&plugin_path, timestamp);
1✔
801

1✔
802
        let result = find_plugins_in_dirs(
1✔
803
            &[load_order.game_settings.plugins_directory()],
1✔
804
            load_order.game_settings.id(),
1✔
805
        );
1✔
806

1✔
807
        let plugin_names = [
1✔
808
            load_order.game_settings.master_file(),
1✔
809
            "Blank.esm",
1✔
810
            "Blank.esp",
1✔
811
            "Blank - Master Dependent.esp",
1✔
812
            "Blank - Different.esp",
1✔
813
            "Blàñk.esp",
1✔
814
        ];
1✔
815

1✔
816
        assert_eq!(plugin_names.as_slice(), result);
1✔
817
    }
1✔
818

819
    #[test]
820
    fn find_plugins_in_dirs_should_sort_files_by_ascending_filename_if_timestamps_are_equal_and_game_is_starfield(
1✔
821
    ) {
1✔
822
        let tmp_dir = tempdir().unwrap();
1✔
823
        let (game_settings, plugins) = mock_game_files(GameId::Starfield, &tmp_dir.path());
1✔
824
        let load_order = TestLoadOrder {
1✔
825
            game_settings,
1✔
826
            plugins,
1✔
827
        };
1✔
828

1✔
829
        let timestamp = 1321009991;
1✔
830
        let plugin_names = [
1✔
831
            "Blank - Override.esp",
1✔
832
            "Blank.esp",
1✔
833
            "Blank.full.esm",
1✔
834
            "Blank.medium.esm",
1✔
835
            "Blank.small.esm",
1✔
836
            "Starfield.esm",
1✔
837
        ];
1✔
838

839
        for plugin_name in plugin_names {
7✔
840
            let plugin_path = load_order
6✔
841
                .game_settings
6✔
842
                .plugins_directory()
6✔
843
                .join(plugin_name);
6✔
844
            set_file_timestamps(&plugin_path, timestamp);
6✔
845
        }
6✔
846

847
        let result = find_plugins_in_dirs(
1✔
848
            &[load_order.game_settings.plugins_directory()],
1✔
849
            load_order.game_settings.id(),
1✔
850
        );
1✔
851

1✔
852
        assert_eq!(plugin_names.as_slice(), result);
1✔
853
    }
1✔
854

855
    #[test]
856
    fn move_elements_should_correct_later_indices_to_account_for_earlier_moves() {
1✔
857
        let mut vec = vec![0, 1, 2, 3, 4, 5, 6, 7, 8];
1✔
858
        let mut from_to_indices = BTreeMap::new();
1✔
859
        from_to_indices.insert(6, 3);
1✔
860
        from_to_indices.insert(5, 2);
1✔
861
        from_to_indices.insert(7, 1);
1✔
862

1✔
863
        move_elements(&mut vec, from_to_indices);
1✔
864

1✔
865
        assert_eq!(vec![0, 7, 1, 5, 2, 6, 3, 4, 8], vec);
1✔
866
    }
1✔
867

868
    #[test]
869
    fn validate_load_order_should_be_ok_if_there_are_only_master_files() {
1✔
870
        let tmp_dir = tempdir().unwrap();
1✔
871
        let settings = prepare(&tmp_dir.path());
1✔
872

1✔
873
        let plugins = vec![
1✔
874
            Plugin::new(settings.master_file(), &settings).unwrap(),
1✔
875
            Plugin::new("Blank.esm", &settings).unwrap(),
1✔
876
        ];
1✔
877

1✔
878
        assert!(validate_load_order(&plugins).is_ok());
1✔
879
    }
1✔
880

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

1✔
886
        let plugins = vec![
1✔
887
            Plugin::new("Blank.esp", &settings).unwrap(),
1✔
888
            Plugin::new("Blank - Different.esp", &settings).unwrap(),
1✔
889
        ];
1✔
890

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

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

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

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

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

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

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

921
    #[test]
922
    fn validate_load_order_should_error_if_non_masters_are_hoisted_earlier_than_needed() {
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.esp", &settings).unwrap(),
1✔
928
            Plugin::new("Blank.esm", &settings).unwrap(),
1✔
929
            Plugin::new("Blank - Plugin Dependent.esm", &settings).unwrap(),
1✔
930
        ];
1✔
931

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

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

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

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

950
    #[test]
951
    fn find_first_non_master_should_find_a_normal_esp() {
1✔
952
        let tmp_dir = tempdir().unwrap();
1✔
953
        let plugins = prepare_plugins(&tmp_dir.path(), "Blank.esp");
1✔
954

1✔
955
        let first_non_master = super::find_first_non_master_position(&plugins);
1✔
956
        assert_eq!(1, first_non_master.unwrap());
1✔
957
    }
1✔
958

959
    #[test]
960
    fn find_first_non_master_should_find_a_light_flagged_esp() {
1✔
961
        let tmp_dir = tempdir().unwrap();
1✔
962
        let plugins = prepare_plugins(&tmp_dir.path(), "Blank.esl");
1✔
963

1✔
964
        let first_non_master = super::find_first_non_master_position(&plugins);
1✔
965
        assert_eq!(1, first_non_master.unwrap());
1✔
966
    }
1✔
967
}
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