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

Ortham / libloadorder / 9669712859

25 Jun 2024 09:15PM UTC coverage: 91.599% (+4.7%) from 86.942%
9669712859

push

github

Ortham
Use Starfield plugins in Starfield tests

65 of 65 new or added lines in 5 files covered. (100.0%)

163 existing lines in 11 files now uncovered.

7218 of 7880 relevant lines covered (91.6%)

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

56✔
52
        find_plugins_in_dirs(&directories, self.game_settings().id())
56✔
53
    }
56✔
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,971✔
67
                self.plugins()
8,971✔
68
                    .par_iter()
8,971✔
69
                    .position_any(|p| p.name_matches(n))
19,022,035✔
70
                    .ok_or_else(|| Error::PluginNotFound(n.to_string()))
8,971✔
71
            })
8,971✔
72
            .collect()
11✔
73
    }
11✔
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✔
UNCOV
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,917✔
99
            plugin.deactivate();
10,917✔
100
        }
10,917✔
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(
39✔
124
        &mut self,
39✔
125
        plugin_name_tuples: Vec<(String, bool)>,
39✔
126
        installed_filenames: Vec<String>,
39✔
127
    ) {
39✔
128
        let plugins: Vec<_> = remove_duplicates_icase(plugin_name_tuples, installed_filenames)
39✔
129
            .into_par_iter()
39✔
130
            .filter_map(|(filename, active)| {
10,760✔
131
                Plugin::with_active(&filename, self.game_settings(), active).ok()
10,760✔
132
            })
10,760✔
133
            .collect();
39✔
134

135
        for plugin in plugins {
10,787✔
136
            insert(self, plugin);
10,748✔
137
        }
10,748✔
138
    }
39✔
139

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

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

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

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

193
    Ok(decoded_content.lines().filter_map(line_mapper).collect())
41✔
194
}
71✔
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> {
56✔
209
    // Store plugins' current positions and where they need to move to.
56✔
210
    // Use a BTreeMap so that if a plugin needs to move for more than one ESM,
56✔
211
    // it will move for the earlier one and so also satisfy the later one, and
56✔
212
    // so that it's possible to iterate over content in order.
56✔
213
    let mut from_to_map: BTreeMap<usize, usize> = BTreeMap::new();
56✔
214

215
    for (index, plugin) in plugins.iter().enumerate() {
10,686✔
216
        if !plugin.is_master_file() {
10,686✔
217
            break;
52✔
218
        }
10,634✔
219

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

233
    move_elements(plugins, from_to_map);
56✔
234

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

238
pub fn generic_insert_position(plugins: &[Plugin], plugin: &Plugin) -> Option<usize> {
12,006✔
239
    if plugin.is_master_file() {
12,006✔
240
        find_first_non_master_position(plugins)
10,579✔
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| {
207,037✔
244
            p.masters()
43,611✔
245
                .map(|masters| masters.iter().any(|m| plugin.name_matches(m)))
43,611✔
246
                .unwrap_or(false)
43,611✔
247
        })
43,611✔
248
    }
249
}
12,006✔
250

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

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

143,412✔
272
        match m1.cmp(&m2) {
143,412✔
273
            Ordering::Equal if game == GameId::Starfield => e1.file_name().cmp(&e2.file_name()),
56,337✔
274
            Ordering::Equal => e1.file_name().cmp(&e2.file_name()).reverse(),
56,326✔
275
            x => x,
87,075✔
276
        }
277
    });
143,412✔
278

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

59✔
281
    dir_entries
59✔
282
        .into_iter()
59✔
283
        .filter_map(|e| e.file_name().to_str().map(str::to_owned))
10,863✔
284
        .filter(|filename| set.insert(UniCase::new(trim_dot_ghost(filename).to_string())))
10,863✔
285
        .collect()
59✔
286
}
59✔
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))
219✔
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 {
10,748✔
402
    match load_order.insert_position(&plugin) {
10,748✔
403
        Some(position) => {
42✔
404
            load_order.plugins_mut().insert(position, plugin);
42✔
405
            position
42✔
406
        }
407
        None => {
408
            load_order.plugins_mut().push(plugin);
10,706✔
409
            load_order.plugins().len() - 1
10,706✔
410
        }
411
    }
412
}
10,748✔
413

414
fn move_elements<T>(vec: &mut Vec<T>, mut from_to_indices: BTreeMap<usize, usize>) {
57✔
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() {
63✔
424
        let element = vec.remove(from_index);
6✔
425
        vec.insert(to_index, element);
6✔
426

427
        for value in from_to_indices.values_mut() {
6✔
428
            if *value > to_index {
3✔
429
                *value += 1;
1✔
430
            }
2✔
431
        }
432
    }
433
}
57✔
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(
39✔
516
    plugin_tuples: Vec<(String, bool)>,
39✔
517
    filenames: Vec<String>,
39✔
518
) -> Vec<(String, bool)> {
39✔
519
    let mut set: HashSet<_> = HashSet::with_capacity(filenames.len());
39✔
520

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

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

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

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

39✔
536
    unique_tuples
39✔
537
}
39✔
538

539
fn activate_unvalidated<T: MutableLoadOrder + ?Sized>(
156✔
540
    load_order: &mut T,
156✔
541
    filename: &str,
156✔
542
) -> Result<(), Error> {
156✔
543
    if let Some(plugin) = load_order
156✔
544
        .plugins_mut()
156✔
545
        .iter_mut()
156✔
546
        .find(|p| p.name_matches(filename))
42,797✔
547
    {
548
        plugin.activate()
41✔
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)
115✔
553
            .map(|plugin| {
115✔
UNCOV
554
                insert(load_order, plugin);
×
555
            })
115✔
556
            .or(Ok(()))
115✔
557
    }
558
}
156✔
559

560
fn find_first_non_master_position(plugins: &[Plugin]) -> Option<usize> {
10,603✔
561
    plugins.iter().position(|p| !p.is_master_file())
27,683,529✔
562
}
10,603✔
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 {
UNCOV
591
        fn plugins_mut(&mut self) -> &mut Vec<Plugin> {
×
UNCOV
592
            &mut self.plugins
×
UNCOV
593
        }
×
594

UNCOV
595
        fn insert_position(&self, plugin: &Plugin) -> Option<usize> {
×
UNCOV
596
            generic_insert_position(self.plugins(), plugin)
×
UNCOV
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

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

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

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

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

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

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

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

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

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

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

882
    #[test]
883
    fn validate_load_order_should_be_ok_if_there_are_no_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("Blank.esp", &settings).unwrap(),
1✔
889
            Plugin::new("Blank - Different.esp", &settings).unwrap(),
1✔
890
        ];
1✔
891

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

895
    #[test]
896
    fn validate_load_order_should_be_ok_if_master_files_are_before_all_others() {
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.esm", &settings).unwrap(),
1✔
902
            Plugin::new("Blank.esp", &settings).unwrap(),
1✔
903
        ];
1✔
904

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

908
    #[test]
909
    fn validate_load_order_should_be_ok_if_hoisted_non_masters_load_before_masters() {
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
            Plugin::new("Blank - Plugin Dependent.esm", &settings).unwrap(),
1✔
917
        ];
1✔
918

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

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

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

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

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

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

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

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

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

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

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

© 2025 Coveralls, Inc