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

Ortham / esplugin / 18200553123

02 Oct 2025 05:23PM UTC coverage: 84.367% (-0.02%) from 84.388%
18200553123

push

github

Ortham
Check the hash of file downloaded in CI

Also remove unnecessary download in clippy job.

3103 of 3678 relevant lines covered (84.37%)

71.7 hits per line

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

98.02
/src/plugin.rs
1
/*
2
 * This file is part of esplugin
3
 *
4
 * Copyright (C) 2017 Oliver Hamlet
5
 *
6
 * esplugin 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
 * esplugin 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 esplugin. If not, see <http://www.gnu.org/licenses/>.
18
 */
19

20
use std::collections::HashSet;
21
use std::ffi::OsStr;
22
use std::fs::File;
23
use std::io::{BufRead, BufReader, Seek};
24
use std::ops::RangeInclusive;
25
use std::path::{Path, PathBuf};
26

27
use encoding_rs::WINDOWS_1252;
28

29
use crate::error::{Error, ParsingErrorKind};
30
use crate::game_id::GameId;
31
use crate::group::Group;
32
use crate::record::{Record, MAX_RECORD_HEADER_LENGTH};
33
use crate::record_id::{NamespacedId, ObjectIndexMask, RecordId, ResolvedRecordId, SourcePlugin};
34

35
#[derive(Copy, Clone, PartialEq, Eq)]
36
enum FileExtension {
37
    Esm,
38
    Esl,
39
    Ghost,
40
    Unrecognised,
41
}
42

43
impl From<&OsStr> for FileExtension {
44
    fn from(value: &OsStr) -> Self {
114✔
45
        if value.eq_ignore_ascii_case("esm") {
114✔
46
            FileExtension::Esm
68✔
47
        } else if value.eq_ignore_ascii_case("esl") {
46✔
48
            FileExtension::Esl
20✔
49
        } else if value.eq_ignore_ascii_case("ghost") {
26✔
50
            FileExtension::Ghost
6✔
51
        } else {
52
            FileExtension::Unrecognised
20✔
53
        }
54
    }
114✔
55
}
56

57
#[derive(Clone, Default, PartialEq, Eq, Debug, Hash)]
58
enum RecordIds {
59
    #[default]
60
    None,
61
    FormIds(Vec<u32>),
62
    NamespacedIds(Vec<NamespacedId>),
63
    Resolved(Vec<ResolvedRecordId>),
64
}
65

66
impl From<Vec<NamespacedId>> for RecordIds {
67
    fn from(record_ids: Vec<NamespacedId>) -> RecordIds {
38✔
68
        RecordIds::NamespacedIds(record_ids)
38✔
69
    }
38✔
70
}
71

72
impl From<Vec<u32>> for RecordIds {
73
    fn from(form_ids: Vec<u32>) -> RecordIds {
112✔
74
        RecordIds::FormIds(form_ids)
112✔
75
    }
112✔
76
}
77

78
#[derive(Clone, PartialEq, Eq, Debug, Hash, Default)]
79
struct PluginData {
80
    header_record: Record,
81
    record_ids: RecordIds,
82
}
83

84
#[derive(Copy, Clone, PartialEq, Eq, Debug, Hash)]
85
enum PluginScale {
86
    Full,
87
    Medium,
88
    Small,
89
}
90

91
#[derive(Clone, PartialEq, Eq, Debug, Hash)]
92
pub struct Plugin {
93
    game_id: GameId,
94
    path: PathBuf,
95
    data: PluginData,
96
}
97

98
#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
99
pub struct ParseOptions {
100
    header_only: bool,
101
}
102

103
impl ParseOptions {
104
    pub fn header_only() -> Self {
98✔
105
        Self { header_only: true }
98✔
106
    }
98✔
107

108
    pub fn whole_plugin() -> Self {
156✔
109
        Self { header_only: false }
156✔
110
    }
156✔
111
}
112

113
impl Plugin {
114
    pub fn new(game_id: GameId, filepath: &Path) -> Plugin {
390✔
115
        Plugin {
390✔
116
            game_id,
390✔
117
            path: filepath.to_path_buf(),
390✔
118
            data: PluginData::default(),
390✔
119
        }
390✔
120
    }
390✔
121

122
    pub fn parse_reader<R: std::io::Read + std::io::Seek>(
252✔
123
        &mut self,
252✔
124
        reader: R,
252✔
125
        options: ParseOptions,
252✔
126
    ) -> Result<(), Error> {
252✔
127
        let mut reader = BufReader::new(reader);
252✔
128

129
        self.data = read_plugin(&mut reader, self.game_id, options, self.header_type())?;
252✔
130

131
        if self.game_id != GameId::Morrowind && self.game_id != GameId::Starfield {
244✔
132
            self.resolve_record_ids(&[])?;
100✔
133
        }
144✔
134

135
        Ok(())
244✔
136
    }
252✔
137

138
    pub fn parse_file(&mut self, options: ParseOptions) -> Result<(), Error> {
234✔
139
        let file = File::open(&self.path)?;
234✔
140

141
        self.parse_reader(file, options)
232✔
142
    }
234✔
143

144
    /// plugins_metadata can be empty for all games other than Starfield, and for Starfield plugins with no masters.
145
    pub fn resolve_record_ids(&mut self, plugins_metadata: &[PluginMetadata]) -> Result<(), Error> {
134✔
146
        match &self.data.record_ids {
134✔
147
            RecordIds::FormIds(form_ids) => {
92✔
148
                let filename = self
92✔
149
                    .filename()
92✔
150
                    .ok_or_else(|| Error::NoFilename(self.path.clone()))?;
92✔
151
                let parent_metadata = PluginMetadata {
92✔
152
                    filename,
92✔
153
                    scale: self.scale(),
92✔
154
                    record_ids: Box::new([]),
92✔
155
                };
92✔
156
                let masters = self.masters()?;
92✔
157

158
                let form_ids = resolve_form_ids(
92✔
159
                    self.game_id,
92✔
160
                    form_ids,
92✔
161
                    &parent_metadata,
92✔
162
                    &masters,
92✔
163
                    plugins_metadata,
92✔
164
                )?;
×
165

166
                self.data.record_ids = RecordIds::Resolved(form_ids);
92✔
167
            }
168
            RecordIds::NamespacedIds(namespaced_ids) => {
2✔
169
                let masters = self.masters()?;
2✔
170

171
                let record_ids =
2✔
172
                    resolve_namespaced_ids(namespaced_ids, &masters, plugins_metadata)?;
2✔
173

174
                self.data.record_ids = RecordIds::Resolved(record_ids);
2✔
175
            }
176
            RecordIds::None | RecordIds::Resolved(_) => {
40✔
177
                // Do nothing.
40✔
178
            }
40✔
179
        }
180

181
        Ok(())
134✔
182
    }
134✔
183

184
    pub fn game_id(&self) -> GameId {
4✔
185
        self.game_id
4✔
186
    }
4✔
187

188
    pub fn path(&self) -> &Path {
16✔
189
        &self.path
16✔
190
    }
16✔
191

192
    pub fn filename(&self) -> Option<String> {
114✔
193
        self.path
114✔
194
            .file_name()
114✔
195
            .and_then(std::ffi::OsStr::to_str)
114✔
196
            .map(std::string::ToString::to_string)
114✔
197
    }
114✔
198

199
    pub fn masters(&self) -> Result<Vec<String>, Error> {
104✔
200
        masters(&self.data.header_record)
104✔
201
    }
104✔
202

203
    fn file_extension(&self) -> FileExtension {
108✔
204
        if let Some(p) = self.path.extension() {
108✔
205
            match FileExtension::from(p) {
108✔
206
                FileExtension::Ghost => self
6✔
207
                    .path
6✔
208
                    .file_stem()
6✔
209
                    .map(Path::new)
6✔
210
                    .and_then(Path::extension)
6✔
211
                    .map_or(FileExtension::Unrecognised, FileExtension::from),
6✔
212
                e => e,
102✔
213
            }
214
        } else {
215
            FileExtension::Unrecognised
×
216
        }
217
    }
108✔
218

219
    pub fn is_master_file(&self) -> bool {
44✔
220
        match self.game_id {
44✔
221
            GameId::Fallout4 | GameId::SkyrimSE | GameId::Starfield => {
222
                // The .esl extension implies the master flag, but the light and
223
                // medium flags do not.
224
                self.is_master_flag_set()
34✔
225
                    || matches!(
14✔
226
                        self.file_extension(),
28✔
227
                        FileExtension::Esm | FileExtension::Esl
228
                    )
229
            }
230
            _ => self.is_master_flag_set(),
10✔
231
        }
232
    }
44✔
233

234
    fn scale(&self) -> PluginScale {
114✔
235
        if self.is_light_plugin() {
114✔
236
            PluginScale::Small
4✔
237
        } else if self.is_medium_flag_set() {
110✔
238
            PluginScale::Medium
6✔
239
        } else {
240
            PluginScale::Full
104✔
241
        }
242
    }
114✔
243

244
    pub fn is_light_plugin(&self) -> bool {
182✔
245
        if self.game_id.supports_light_plugins() {
182✔
246
            if self.game_id == GameId::Starfield {
104✔
247
                // If the inject flag is set, it prevents the .esl extension from
248
                // causing the light flag to be forcibly set on load.
249
                self.is_light_flag_set()
68✔
250
                    || (!self.is_update_flag_set() && self.file_extension() == FileExtension::Esl)
60✔
251
            } else {
252
                self.is_light_flag_set() || self.file_extension() == FileExtension::Esl
36✔
253
            }
254
        } else {
255
            false
78✔
256
        }
257
    }
182✔
258

259
    pub fn is_medium_plugin(&self) -> bool {
54✔
260
        // If the medium flag is set in a light plugin then the medium flag is ignored.
261
        self.is_medium_flag_set() && !self.is_light_plugin()
54✔
262
    }
54✔
263

264
    pub fn is_update_plugin(&self) -> bool {
8✔
265
        // The update flag is unset by the game if the plugin has no masters or
266
        // if the plugin's light or medium flags are set.
267
        self.is_update_flag_set()
8✔
268
            && !self.is_light_flag_set()
8✔
269
            && !self.is_medium_flag_set()
6✔
270
            && self.masters().map(|m| !m.is_empty()).unwrap_or(false)
4✔
271
    }
8✔
272

273
    pub fn is_blueprint_plugin(&self) -> bool {
4✔
274
        match self.game_id {
4✔
275
            GameId::Starfield => self.data.header_record.header().flags() & 0x800 != 0,
4✔
276
            _ => false,
×
277
        }
278
    }
4✔
279

280
    pub fn is_valid(game_id: GameId, filepath: &Path, options: ParseOptions) -> bool {
4✔
281
        let mut plugin = Plugin::new(game_id, filepath);
4✔
282

283
        plugin.parse_file(options).is_ok()
4✔
284
    }
4✔
285

286
    pub fn description(&self) -> Result<Option<String>, Error> {
12✔
287
        let (target_subrecord_type, description_offset) = match self.game_id {
12✔
288
            GameId::Morrowind => (b"HEDR", 40),
4✔
289
            _ => (b"SNAM", 0),
8✔
290
        };
291

292
        for subrecord in self.data.header_record.subrecords() {
28✔
293
            if subrecord.subrecord_type() == target_subrecord_type {
28✔
294
                let data = subrecord
12✔
295
                    .data()
12✔
296
                    .get(description_offset..)
12✔
297
                    .map(until_first_null)
12✔
298
                    .ok_or_else(|| {
12✔
299
                        Error::ParsingError(
2✔
300
                            subrecord.data().into(),
2✔
301
                            ParsingErrorKind::SubrecordDataTooShort(description_offset),
2✔
302
                        )
2✔
303
                    })?;
2✔
304

305
                return WINDOWS_1252
10✔
306
                    .decode_without_bom_handling_and_without_replacement(data)
10✔
307
                    .map(|s| Some(s.to_string()))
10✔
308
                    .ok_or(Error::DecodeError(data.into()));
10✔
309
            }
16✔
310
        }
311

312
        Ok(None)
×
313
    }
12✔
314

315
    pub fn header_version(&self) -> Option<f32> {
26✔
316
        self.data
26✔
317
            .header_record
26✔
318
            .subrecords()
26✔
319
            .iter()
26✔
320
            .find(|s| s.subrecord_type() == b"HEDR")
26✔
321
            .and_then(|s| crate::le_slice_to_f32(s.data()).ok())
26✔
322
    }
26✔
323

324
    pub fn record_and_group_count(&self) -> Option<u32> {
14✔
325
        let count_offset = match self.game_id {
14✔
326
            GameId::Morrowind => 296,
10✔
327
            _ => 4,
4✔
328
        };
329

330
        self.data
14✔
331
            .header_record
14✔
332
            .subrecords()
14✔
333
            .iter()
14✔
334
            .find(|s| s.subrecord_type() == b"HEDR")
14✔
335
            .and_then(|s| s.data().get(count_offset..))
14✔
336
            .and_then(|d| crate::le_slice_to_u32(d).ok())
14✔
337
    }
14✔
338

339
    /// This needs records to be resolved first if run for Morrowind or Starfield.
340
    pub fn count_override_records(&self) -> Result<usize, Error> {
10✔
341
        match &self.data.record_ids {
10✔
342
            RecordIds::None => Ok(0),
×
343
            RecordIds::FormIds(_) | RecordIds::NamespacedIds(_) => {
344
                Err(Error::UnresolvedRecordIds(self.path.clone()))
4✔
345
            }
346
            RecordIds::Resolved(form_ids) => {
6✔
347
                let count = form_ids.iter().filter(|f| f.is_overridden_record()).count();
42✔
348
                Ok(count)
6✔
349
            }
350
        }
351
    }
10✔
352

353
    pub fn overlaps_with(&self, other: &Self) -> Result<bool, Error> {
18✔
354
        use RecordIds::{FormIds, NamespacedIds, Resolved};
355
        match (&self.data.record_ids, &other.data.record_ids) {
18✔
356
            (FormIds(_), _) => Err(Error::UnresolvedRecordIds(self.path.clone())),
2✔
357
            (_, FormIds(_)) => Err(Error::UnresolvedRecordIds(other.path.clone())),
2✔
358
            (Resolved(left), Resolved(right)) => Ok(sorted_slices_intersect(left, right)),
8✔
359
            (NamespacedIds(left), NamespacedIds(right)) => Ok(sorted_slices_intersect(left, right)),
6✔
360
            _ => Ok(false),
×
361
        }
362
    }
18✔
363

364
    /// Count the number of records that appear in this plugin and one or more
365
    /// the others passed. If more than one other contains the same record, it
366
    /// is only counted once.
367
    pub fn overlap_size(&self, others: &[&Self]) -> Result<usize, Error> {
30✔
368
        use RecordIds::{FormIds, NamespacedIds, None, Resolved};
369

370
        match &self.data.record_ids {
30✔
371
            FormIds(_) => Err(Error::UnresolvedRecordIds(self.path.clone())),
2✔
372
            Resolved(ids) => {
14✔
373
                let mut count = 0;
14✔
374
                for id in ids {
134✔
375
                    for other in others {
252✔
376
                        match &other.data.record_ids {
132✔
377
                            FormIds(_) => {
378
                                return Err(Error::UnresolvedRecordIds(other.path.clone()))
2✔
379
                            }
380
                            Resolved(master_ids) if master_ids.binary_search(id).is_ok() => {
132✔
381
                                count += 1;
22✔
382
                                break;
22✔
383
                            }
384
                            _ => {
130✔
385
                                // Do nothing.
130✔
386
                            }
130✔
387
                        }
388
                    }
389
                }
390

391
                Ok(count)
12✔
392
            }
393
            NamespacedIds(ids) => {
10✔
394
                let count = ids
10✔
395
                    .iter()
10✔
396
                    .filter(|id| {
100✔
397
                        others.iter().any(|other| match &other.data.record_ids {
132✔
398
                            NamespacedIds(master_ids) => master_ids.binary_search(id).is_ok(),
112✔
399
                            _ => false,
20✔
400
                        })
132✔
401
                    })
100✔
402
                    .count();
10✔
403
                Ok(count)
10✔
404
            }
405
            None => Ok(0),
4✔
406
        }
407
    }
30✔
408

409
    pub fn is_valid_as_light_plugin(&self) -> Result<bool, Error> {
22✔
410
        if self.game_id.supports_light_plugins() {
22✔
411
            match &self.data.record_ids {
12✔
412
                RecordIds::None => Ok(true),
×
413
                RecordIds::FormIds(_) => Err(Error::UnresolvedRecordIds(self.path.clone())),
2✔
414
                RecordIds::Resolved(form_ids) => {
10✔
415
                    let valid_range = self.valid_light_form_id_range();
10✔
416

417
                    let is_valid = form_ids
10✔
418
                        .iter()
10✔
419
                        .filter(|f| !f.is_overridden_record())
84✔
420
                        .all(|f| f.is_object_index_in(&valid_range));
52✔
421

422
                    Ok(is_valid)
10✔
423
                }
424
                RecordIds::NamespacedIds(_) => Ok(false),
×
425
            }
426
        } else {
427
            Ok(false)
10✔
428
        }
429
    }
22✔
430

431
    pub fn is_valid_as_medium_plugin(&self) -> Result<bool, Error> {
4✔
432
        if self.game_id.supports_medium_plugins() {
4✔
433
            match &self.data.record_ids {
4✔
434
                RecordIds::None => Ok(true),
×
435
                RecordIds::FormIds(_) => Err(Error::UnresolvedRecordIds(self.path.clone())),
2✔
436
                RecordIds::Resolved(form_ids) => {
2✔
437
                    let valid_range = self.valid_medium_form_id_range();
2✔
438

439
                    let is_valid = form_ids
2✔
440
                        .iter()
2✔
441
                        .filter(|f| !f.is_overridden_record())
20✔
442
                        .all(|f| f.is_object_index_in(&valid_range));
20✔
443

444
                    Ok(is_valid)
2✔
445
                }
446
                RecordIds::NamespacedIds(_) => Ok(false), // this should never happen.
×
447
            }
448
        } else {
449
            Ok(false)
×
450
        }
451
    }
4✔
452

453
    pub fn is_valid_as_update_plugin(&self) -> Result<bool, Error> {
6✔
454
        if self.game_id == GameId::Starfield {
6✔
455
            // If an update plugin has a record that does not override an existing record, that
456
            // record is placed into the mod index of the plugin's first master, which risks
457
            // overwriting an unrelated record with the same object index, so treat that case as
458
            // invalid.
459
            match &self.data.record_ids {
6✔
460
                RecordIds::None => Ok(true),
×
461
                RecordIds::FormIds(_) => Err(Error::UnresolvedRecordIds(self.path.clone())),
2✔
462
                RecordIds::Resolved(form_ids) => {
4✔
463
                    Ok(form_ids.iter().all(ResolvedRecordId::is_overridden_record))
4✔
464
                }
465
                RecordIds::NamespacedIds(_) => Ok(false), // this should never happen.
×
466
            }
467
        } else {
468
            Ok(false)
×
469
        }
470
    }
6✔
471

472
    fn header_type(&self) -> &'static [u8] {
252✔
473
        match self.game_id {
252✔
474
            GameId::Morrowind => b"TES3",
58✔
475
            _ => b"TES4",
194✔
476
        }
477
    }
252✔
478

479
    fn is_master_flag_set(&self) -> bool {
44✔
480
        match self.game_id {
44✔
481
            GameId::Morrowind => self
6✔
482
                .data
6✔
483
                .header_record
6✔
484
                .subrecords()
6✔
485
                .iter()
6✔
486
                .find(|s| s.subrecord_type() == b"HEDR")
6✔
487
                .and_then(|s| s.data().get(4))
6✔
488
                .is_some_and(|b| b & 0x1 != 0),
6✔
489
            _ => self.data.header_record.header().flags() & 0x1 != 0,
38✔
490
        }
491
    }
44✔
492

493
    fn is_light_flag_set(&self) -> bool {
112✔
494
        let flag = match self.game_id {
112✔
495
            GameId::Starfield => 0x100,
76✔
496
            GameId::SkyrimSE | GameId::Fallout4 => 0x200,
36✔
497
            _ => return false,
×
498
        };
499

500
        self.data.header_record.header().flags() & flag != 0
112✔
501
    }
112✔
502

503
    fn is_medium_flag_set(&self) -> bool {
170✔
504
        let flag = match self.game_id {
170✔
505
            GameId::Starfield => 0x400,
64✔
506
            _ => return false,
106✔
507
        };
508

509
        self.data.header_record.header().flags() & flag != 0
64✔
510
    }
170✔
511

512
    fn is_update_flag_set(&self) -> bool {
68✔
513
        match self.game_id {
68✔
514
            GameId::Starfield => self.data.header_record.header().flags() & 0x200 != 0,
68✔
515
            _ => false,
×
516
        }
517
    }
68✔
518

519
    fn valid_light_form_id_range(&self) -> RangeInclusive<u32> {
30✔
520
        match self.game_id {
30✔
521
            GameId::SkyrimSE => match self.header_version() {
10✔
522
                Some(v) if v < 1.71 => 0x800..=0xFFF,
10✔
523
                Some(_) => 0..=0xFFF,
2✔
524
                None => 0..=0,
×
525
            },
526
            GameId::Fallout4 => match self.header_version() {
6✔
527
                Some(v) if v < 1.0 => 0x800..=0xFFF,
6✔
528
                Some(_) => 0x001..=0xFFF,
2✔
529
                None => 0..=0,
×
530
            },
531
            GameId::Starfield => 0..=0xFFF,
4✔
532
            _ => 0..=0,
10✔
533
        }
534
    }
30✔
535

536
    fn valid_medium_form_id_range(&self) -> RangeInclusive<u32> {
4✔
537
        match self.game_id {
4✔
538
            GameId::Starfield => 0..=0xFFFF,
4✔
539
            _ => 0..=0,
×
540
        }
541
    }
4✔
542
}
543

544
#[derive(Clone, Debug, PartialEq, Eq)]
545
pub struct PluginMetadata {
546
    filename: String,
547
    scale: PluginScale,
548
    record_ids: Box<[NamespacedId]>,
549
}
550

551
// Get PluginMetadata objects for a collection of loaded plugins.
552
pub fn plugins_metadata(plugins: &[&Plugin]) -> Result<Vec<PluginMetadata>, Error> {
4✔
553
    let mut vec = Vec::new();
4✔
554

555
    for plugin in plugins {
12✔
556
        let filename = plugin
8✔
557
            .filename()
8✔
558
            .ok_or_else(|| Error::NoFilename(plugin.path().to_path_buf()))?;
8✔
559

560
        let record_ids = if plugin.game_id == GameId::Morrowind {
8✔
561
            match &plugin.data.record_ids {
2✔
562
                RecordIds::NamespacedIds(ids) => ids.clone(),
2✔
563
                _ => Vec::new(), // This should never happen.
×
564
            }
565
        } else {
566
            Vec::new()
6✔
567
        };
568

569
        let metadata = PluginMetadata {
8✔
570
            filename,
8✔
571
            scale: plugin.scale(),
8✔
572
            record_ids: record_ids.into_boxed_slice(),
8✔
573
        };
8✔
574

575
        vec.push(metadata);
8✔
576
    }
577

578
    Ok(vec)
4✔
579
}
4✔
580

581
fn sorted_slices_intersect<T: PartialOrd>(left: &[T], right: &[T]) -> bool {
14✔
582
    let mut left_iter = left.iter();
14✔
583
    let mut right_iter = right.iter();
14✔
584

585
    let mut left_element = left_iter.next();
14✔
586
    let mut right_element = right_iter.next();
14✔
587

588
    while let (Some(left_value), Some(right_value)) = (left_element, right_element) {
92✔
589
        if left_value < right_value {
84✔
590
            left_element = left_iter.next();
38✔
591
        } else if left_value > right_value {
46✔
592
            right_element = right_iter.next();
40✔
593
        } else {
40✔
594
            return true;
6✔
595
        }
596
    }
597

598
    false
8✔
599
}
14✔
600

601
fn resolve_form_ids(
96✔
602
    game_id: GameId,
96✔
603
    form_ids: &[u32],
96✔
604
    plugin_metadata: &PluginMetadata,
96✔
605
    masters: &[String],
96✔
606
    other_plugins_metadata: &[PluginMetadata],
96✔
607
) -> Result<Vec<ResolvedRecordId>, Error> {
96✔
608
    let hashed_parent = hashed_parent(game_id, plugin_metadata);
96✔
609
    let hashed_masters = match game_id {
96✔
610
        GameId::Starfield => hashed_masters_for_starfield(masters, other_plugins_metadata)?,
30✔
611
        _ => hashed_masters(masters),
66✔
612
    };
613

614
    let mut form_ids: Vec<_> = form_ids
96✔
615
        .iter()
96✔
616
        .map(|form_id| ResolvedRecordId::from_form_id(hashed_parent, &hashed_masters, *form_id))
718✔
617
        .collect();
96✔
618

619
    form_ids.sort();
96✔
620

621
    Ok(form_ids)
96✔
622
}
96✔
623

624
fn resolve_namespaced_ids(
2✔
625
    namespaced_ids: &[NamespacedId],
2✔
626
    masters: &[String],
2✔
627
    other_plugins_metadata: &[PluginMetadata],
2✔
628
) -> Result<Vec<ResolvedRecordId>, Error> {
2✔
629
    let mut record_ids: HashSet<NamespacedId> = HashSet::new();
2✔
630

631
    for master in masters {
4✔
632
        let master_record_ids = other_plugins_metadata
2✔
633
            .iter()
2✔
634
            .find(|m| unicase::eq(&m.filename, master))
2✔
635
            .map(|m| &m.record_ids)
2✔
636
            .ok_or_else(|| Error::PluginMetadataNotFound(master.clone()))?;
2✔
637

638
        record_ids.extend(master_record_ids.iter().cloned());
2✔
639
    }
640

641
    let mut resolved_ids: Vec<_> = namespaced_ids
2✔
642
        .iter()
2✔
643
        .map(|id| ResolvedRecordId::from_namespaced_id(id, &record_ids))
16✔
644
        .collect();
2✔
645

646
    resolved_ids.sort();
2✔
647

648
    Ok(resolved_ids)
2✔
649
}
2✔
650

651
fn hashed_parent(game_id: GameId, parent_metadata: &PluginMetadata) -> SourcePlugin {
108✔
652
    match game_id {
108✔
653
        GameId::Starfield => {
654
            // The Creation Kit can create plugins that contain new records that use mod indexes that don't match the plugin's scale (full/medium/small), e.g. a medium plugin might have new records with FormIDs that don't start with 0xFD. However, at runtime the mod index part is replaced with the mod index of the plugin, according to the plugin's scale, so the plugin's scale is what matters when resolving FormIDs for comparison between plugins.
655
            let object_index_mask = match parent_metadata.scale {
36✔
656
                PluginScale::Full => ObjectIndexMask::Full,
30✔
657
                PluginScale::Medium => ObjectIndexMask::Medium,
4✔
658
                PluginScale::Small => ObjectIndexMask::Small,
2✔
659
            };
660
            SourcePlugin::parent(&parent_metadata.filename, object_index_mask)
36✔
661
        }
662
        // The full object index mask is used for all plugin scales in other games.
663
        _ => SourcePlugin::parent(&parent_metadata.filename, ObjectIndexMask::Full),
72✔
664
    }
665
}
108✔
666

667
fn hashed_masters(masters: &[String]) -> Vec<SourcePlugin> {
68✔
668
    masters
68✔
669
        .iter()
68✔
670
        .enumerate()
68✔
671
        .filter_map(|(i, m)| {
68✔
672
            // If the index is somehow > 256 then this isn't a valid master so skip it.
673
            let i = u8::try_from(i).ok()?;
52✔
674
            let mod_index_mask = u32::from(i) << 24u8;
52✔
675
            Some(SourcePlugin::master(
52✔
676
                m,
52✔
677
                mod_index_mask,
52✔
678
                ObjectIndexMask::Full,
52✔
679
            ))
52✔
680
        })
52✔
681
        .collect()
68✔
682
}
68✔
683

684
// Get HashedMaster objects for the current plugin.
685
fn hashed_masters_for_starfield(
36✔
686
    masters: &[String],
36✔
687
    masters_metadata: &[PluginMetadata],
36✔
688
) -> Result<Vec<SourcePlugin>, Error> {
36✔
689
    let mut hashed_masters = Vec::new();
36✔
690
    let mut full_mask = 0;
36✔
691
    let mut medium_mask = 0xFD00_0000;
36✔
692
    let mut small_mask = 0xFE00_0000;
36✔
693

694
    for master in masters {
66✔
695
        let master_scale = masters_metadata
32✔
696
            .iter()
32✔
697
            .find(|m| unicase::eq(&m.filename, master))
78✔
698
            .map(|m| m.scale)
32✔
699
            .ok_or_else(|| Error::PluginMetadataNotFound(master.clone()))?;
32✔
700

701
        match master_scale {
30✔
702
            PluginScale::Full => {
20✔
703
                hashed_masters.push(SourcePlugin::master(
20✔
704
                    master,
20✔
705
                    full_mask,
20✔
706
                    ObjectIndexMask::Full,
20✔
707
                ));
20✔
708

20✔
709
                full_mask += 0x0100_0000;
20✔
710
            }
20✔
711
            PluginScale::Medium => {
4✔
712
                hashed_masters.push(SourcePlugin::master(
4✔
713
                    master,
4✔
714
                    medium_mask,
4✔
715
                    ObjectIndexMask::Medium,
4✔
716
                ));
4✔
717

4✔
718
                medium_mask += 0x0001_0000;
4✔
719
            }
4✔
720
            PluginScale::Small => {
6✔
721
                hashed_masters.push(SourcePlugin::master(
6✔
722
                    master,
6✔
723
                    small_mask,
6✔
724
                    ObjectIndexMask::Small,
6✔
725
                ));
6✔
726

6✔
727
                small_mask += 0x0000_1000;
6✔
728
            }
6✔
729
        }
730
    }
731

732
    Ok(hashed_masters)
34✔
733
}
36✔
734

735
fn masters(header_record: &Record) -> Result<Vec<String>, Error> {
104✔
736
    header_record
104✔
737
        .subrecords()
104✔
738
        .iter()
104✔
739
        .filter(|s| s.subrecord_type() == b"MAST")
468✔
740
        .map(|s| until_first_null(s.data()))
104✔
741
        .map(|d| {
104✔
742
            WINDOWS_1252
60✔
743
                .decode_without_bom_handling_and_without_replacement(d)
60✔
744
                .map(|s| s.to_string())
60✔
745
                .ok_or(Error::DecodeError(d.into()))
60✔
746
        })
60✔
747
        .collect()
104✔
748
}
104✔
749

750
fn read_form_ids<R: BufRead + Seek>(reader: &mut R, game_id: GameId) -> Result<Vec<u32>, Error> {
112✔
751
    let mut form_ids = Vec::new();
112✔
752
    let mut header_buf = [0; MAX_RECORD_HEADER_LENGTH];
112✔
753

754
    while !reader.fill_buf()?.is_empty() {
272✔
755
        Group::read_form_ids(reader, game_id, &mut form_ids, &mut header_buf)?;
160✔
756
    }
757

758
    Ok(form_ids)
112✔
759
}
112✔
760

761
fn read_morrowind_record_ids<R: BufRead + Seek>(reader: &mut R) -> Result<RecordIds, Error> {
38✔
762
    let mut record_ids = Vec::new();
38✔
763
    let mut header_buf = [0; 16]; // Morrowind record headers are 16 bytes long.
38✔
764

765
    while !reader.fill_buf()?.is_empty() {
354✔
766
        let (_, record_id) =
316✔
767
            Record::read_record_id(reader, GameId::Morrowind, &mut header_buf, false)?;
316✔
768

769
        if let Some(RecordId::NamespacedId(record_id)) = record_id {
316✔
770
            record_ids.push(record_id);
316✔
771
        }
316✔
772
    }
773

774
    record_ids.sort();
38✔
775

776
    Ok(record_ids.into())
38✔
777
}
38✔
778

779
fn read_record_ids<R: BufRead + Seek>(reader: &mut R, game_id: GameId) -> Result<RecordIds, Error> {
150✔
780
    if game_id == GameId::Morrowind {
150✔
781
        read_morrowind_record_ids(reader)
38✔
782
    } else {
783
        read_form_ids(reader, game_id).map(Into::into)
112✔
784
    }
785
}
150✔
786

787
fn read_plugin<R: BufRead + Seek>(
252✔
788
    reader: &mut R,
252✔
789
    game_id: GameId,
252✔
790
    options: ParseOptions,
252✔
791
    expected_header_type: &'static [u8],
252✔
792
) -> Result<PluginData, Error> {
252✔
793
    let header_record = Record::read(reader, game_id, expected_header_type)?;
252✔
794

795
    if options.header_only {
244✔
796
        return Ok(PluginData {
94✔
797
            header_record,
94✔
798
            record_ids: RecordIds::None,
94✔
799
        });
94✔
800
    }
150✔
801

802
    let record_ids = read_record_ids(reader, game_id)?;
150✔
803

804
    Ok(PluginData {
150✔
805
        header_record,
150✔
806
        record_ids,
150✔
807
    })
150✔
808
}
252✔
809

810
/// Return the slice up to and not including the first null byte. If there is no
811
/// null byte, return the whole string.
812
fn until_first_null(bytes: &[u8]) -> &[u8] {
70✔
813
    if let Some(i) = memchr::memchr(0, bytes) {
70✔
814
        bytes.split_at(i).0
70✔
815
    } else {
816
        bytes
×
817
    }
818
}
70✔
819

820
#[cfg(test)]
821
mod tests {
822
    use std::fs::{copy, read};
823
    use std::io::Cursor;
824
    use tempfile::tempdir;
825

826
    use crate::read_test_data;
827

828
    use super::*;
829

830
    mod morrowind {
831
        use super::*;
832

833
        #[test]
834
        fn parse_file_should_succeed() {
2✔
835
            let mut plugin = Plugin::new(
2✔
836
                GameId::Morrowind,
2✔
837
                Path::new("testing-plugins/Morrowind/Data Files/Blank.esm"),
2✔
838
            );
839

840
            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
841

842
            match plugin.data.record_ids {
2✔
843
                RecordIds::NamespacedIds(ids) => assert_eq!(10, ids.len()),
2✔
844
                _ => panic!("Expected namespaced record IDs"),
×
845
            }
846
        }
2✔
847

848
        #[test]
849
        fn plugin_parse_file_should_read_a_unique_id_for_each_record() {
2✔
850
            let mut plugin = Plugin::new(
2✔
851
                GameId::Morrowind,
2✔
852
                Path::new("testing-plugins/Morrowind/Data Files/Blank.esm"),
2✔
853
            );
854

855
            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
856

857
            match plugin.data.record_ids {
2✔
858
                RecordIds::NamespacedIds(ids) => {
2✔
859
                    let set: HashSet<NamespacedId> = ids.iter().cloned().collect();
2✔
860
                    assert_eq!(set.len(), ids.len());
2✔
861
                }
862
                _ => panic!("Expected namespaced record IDs"),
×
863
            }
864
        }
2✔
865

866
        #[test]
867
        fn parse_file_header_only_should_not_store_record_ids() {
2✔
868
            let mut plugin = Plugin::new(
2✔
869
                GameId::Morrowind,
2✔
870
                Path::new("testing-plugins/Morrowind/Data Files/Blank.esm"),
2✔
871
            );
872

873
            let result = plugin.parse_file(ParseOptions::header_only());
2✔
874

875
            assert!(result.is_ok());
2✔
876

877
            assert_eq!(RecordIds::None, plugin.data.record_ids);
2✔
878
        }
2✔
879

880
        #[test]
881
        fn game_id_should_return_the_plugins_associated_game_id() {
2✔
882
            let plugin = Plugin::new(GameId::Morrowind, Path::new("Data/Blank.esm"));
2✔
883

884
            assert_eq!(GameId::Morrowind, plugin.game_id());
2✔
885
        }
2✔
886

887
        #[test]
888
        fn is_master_file_should_be_true_for_plugin_with_master_flag_set() {
2✔
889
            let mut plugin = Plugin::new(
2✔
890
                GameId::Morrowind,
2✔
891
                Path::new("testing-plugins/Morrowind/Data Files/Blank.esm"),
2✔
892
            );
893

894
            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
2✔
895
            assert!(plugin.is_master_file());
2✔
896
        }
2✔
897

898
        #[test]
899
        fn is_master_file_should_be_false_for_plugin_without_master_flag_set() {
2✔
900
            let mut plugin = Plugin::new(
2✔
901
                GameId::Morrowind,
2✔
902
                Path::new("testing-plugins/Morrowind/Data Files/Blank.esp"),
2✔
903
            );
904

905
            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
2✔
906
            assert!(!plugin.is_master_file());
2✔
907
        }
2✔
908

909
        #[test]
910
        fn is_master_file_should_ignore_file_extension() {
2✔
911
            let tmp_dir = tempdir().unwrap();
2✔
912

913
            let esm = tmp_dir.path().join("Blank.esm");
2✔
914
            copy(
2✔
915
                Path::new("testing-plugins/Morrowind/Data Files/Blank.esp"),
2✔
916
                &esm,
2✔
917
            )
918
            .unwrap();
2✔
919

920
            let mut plugin = Plugin::new(GameId::Morrowind, &esm);
2✔
921

922
            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
2✔
923
            assert!(!plugin.is_master_file());
2✔
924
        }
2✔
925

926
        #[test]
927
        fn is_light_plugin_should_be_false() {
2✔
928
            let plugin = Plugin::new(GameId::Morrowind, Path::new("Blank.esp"));
2✔
929
            assert!(!plugin.is_light_plugin());
2✔
930
            let plugin = Plugin::new(GameId::Morrowind, Path::new("Blank.esm"));
2✔
931
            assert!(!plugin.is_light_plugin());
2✔
932
            let plugin = Plugin::new(GameId::Morrowind, Path::new("Blank.esl"));
2✔
933
            assert!(!plugin.is_light_plugin());
2✔
934
        }
2✔
935

936
        #[test]
937
        fn is_medium_plugin_should_be_false() {
2✔
938
            let plugin = Plugin::new(GameId::Morrowind, Path::new("Blank.esp"));
2✔
939
            assert!(!plugin.is_medium_plugin());
2✔
940
            let plugin = Plugin::new(GameId::Morrowind, Path::new("Blank.esm"));
2✔
941
            assert!(!plugin.is_medium_plugin());
2✔
942
            let plugin = Plugin::new(GameId::Morrowind, Path::new("Blank.esl"));
2✔
943
            assert!(!plugin.is_medium_plugin());
2✔
944
        }
2✔
945

946
        #[test]
947
        fn description_should_trim_nulls_in_plugin_header_hedr_subrecord_content() {
2✔
948
            let mut plugin = Plugin::new(
2✔
949
                GameId::Morrowind,
2✔
950
                Path::new("testing-plugins/Morrowind/Data Files/Blank.esm"),
2✔
951
            );
952

953
            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
2✔
954

955
            assert_eq!("v5.0", plugin.description().unwrap().unwrap());
2✔
956
        }
2✔
957

958
        #[expect(clippy::float_cmp, reason = "float values should be exactly equal")]
959
        #[test]
960
        fn header_version_should_return_plugin_header_hedr_subrecord_field() {
2✔
961
            let mut plugin = Plugin::new(
2✔
962
                GameId::Morrowind,
2✔
963
                Path::new("testing-plugins/Morrowind/Data Files/Blank.esm"),
2✔
964
            );
965

966
            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
2✔
967

968
            assert_eq!(1.2, plugin.header_version().unwrap());
2✔
969
        }
2✔
970

971
        #[test]
972
        fn record_and_group_count_should_read_correct_offset() {
2✔
973
            let mut plugin = Plugin::new(
2✔
974
                GameId::Morrowind,
2✔
975
                Path::new("testing-plugins/Morrowind/Data Files/Blank.esm"),
2✔
976
            );
977

978
            assert!(plugin.record_and_group_count().is_none());
2✔
979
            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
2✔
980
            assert_eq!(10, plugin.record_and_group_count().unwrap());
2✔
981
        }
2✔
982

983
        #[test]
984
        fn record_and_group_count_should_match_record_ids_length() {
2✔
985
            let mut plugin = Plugin::new(
2✔
986
                GameId::Morrowind,
2✔
987
                Path::new("testing-plugins/Morrowind/Data Files/Blank.esm"),
2✔
988
            );
989

990
            assert!(plugin.record_and_group_count().is_none());
2✔
991
            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
992
            assert_eq!(10, plugin.record_and_group_count().unwrap());
2✔
993
            match plugin.data.record_ids {
2✔
994
                RecordIds::NamespacedIds(ids) => assert_eq!(10, ids.len()),
2✔
995
                _ => panic!("Expected namespaced record IDs"),
×
996
            }
997
        }
2✔
998

999
        #[test]
1000
        fn count_override_records_should_error_if_record_ids_are_not_yet_resolved() {
2✔
1001
            let mut plugin = Plugin::new(
2✔
1002
                GameId::Morrowind,
2✔
1003
                Path::new("testing-plugins/Morrowind/Data Files/Blank - Master Dependent.esm"),
2✔
1004
            );
1005

1006
            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
1007

1008
            match plugin.count_override_records().unwrap_err() {
2✔
1009
                Error::UnresolvedRecordIds(path) => assert_eq!(plugin.path, path),
2✔
1010
                _ => panic!("Expected unresolved record IDs error"),
×
1011
            }
1012
        }
2✔
1013

1014
        #[test]
1015
        fn count_override_records_should_count_how_many_records_are_also_present_in_masters() {
2✔
1016
            let mut plugin = Plugin::new(
2✔
1017
                GameId::Morrowind,
2✔
1018
                Path::new("testing-plugins/Morrowind/Data Files/Blank - Master Dependent.esm"),
2✔
1019
            );
1020
            let mut master = Plugin::new(
2✔
1021
                GameId::Morrowind,
2✔
1022
                Path::new("testing-plugins/Morrowind/Data Files/Blank.esm"),
2✔
1023
            );
1024

1025
            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
1026
            assert!(master.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
1027

1028
            let plugins_metadata = plugins_metadata(&[&master]).unwrap();
2✔
1029

1030
            plugin.resolve_record_ids(&plugins_metadata).unwrap();
2✔
1031

1032
            assert_eq!(4, plugin.count_override_records().unwrap());
2✔
1033
        }
2✔
1034

1035
        #[test]
1036
        fn overlaps_with_should_detect_when_two_plugins_have_a_record_with_the_same_id() {
2✔
1037
            let mut plugin1 = Plugin::new(
2✔
1038
                GameId::Morrowind,
2✔
1039
                Path::new("testing-plugins/Morrowind/Data Files/Blank.esm"),
2✔
1040
            );
1041
            let mut plugin2 = Plugin::new(
2✔
1042
                GameId::Morrowind,
2✔
1043
                Path::new("testing-plugins/Morrowind/Data Files/Blank - Different.esm"),
2✔
1044
            );
1045

1046
            assert!(plugin1.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
1047
            assert!(plugin2.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
1048

1049
            assert!(plugin1.overlaps_with(&plugin1).unwrap());
2✔
1050
            assert!(!plugin1.overlaps_with(&plugin2).unwrap());
2✔
1051
        }
2✔
1052

1053
        #[test]
1054
        fn overlap_size_should_only_count_each_record_once() {
2✔
1055
            let mut plugin1 = Plugin::new(
2✔
1056
                GameId::Morrowind,
2✔
1057
                Path::new("testing-plugins/Morrowind/Data Files/Blank.esm"),
2✔
1058
            );
1059
            let mut plugin2 = Plugin::new(
2✔
1060
                GameId::Morrowind,
2✔
1061
                Path::new("testing-plugins/Morrowind/Data Files/Blank - Master Dependent.esm"),
2✔
1062
            );
1063

1064
            assert!(plugin1.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
1065
            assert!(plugin2.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
1066

1067
            assert_eq!(4, plugin1.overlap_size(&[&plugin2, &plugin2]).unwrap());
2✔
1068
        }
2✔
1069

1070
        #[test]
1071
        fn overlap_size_should_check_against_all_given_plugins() {
2✔
1072
            let mut plugin1 = Plugin::new(
2✔
1073
                GameId::Morrowind,
2✔
1074
                Path::new("testing-plugins/Morrowind/Data Files/Blank.esm"),
2✔
1075
            );
1076
            let mut plugin2 = Plugin::new(
2✔
1077
                GameId::Morrowind,
2✔
1078
                Path::new("testing-plugins/Morrowind/Data Files/Blank.esp"),
2✔
1079
            );
1080
            let mut plugin3 = Plugin::new(
2✔
1081
                GameId::Morrowind,
2✔
1082
                Path::new("testing-plugins/Morrowind/Data Files/Blank - Master Dependent.esm"),
2✔
1083
            );
1084

1085
            assert!(plugin1.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
1086
            assert!(plugin2.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
1087
            assert!(plugin3.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
1088

1089
            assert_eq!(4, plugin1.overlap_size(&[&plugin2, &plugin3]).unwrap());
2✔
1090
        }
2✔
1091

1092
        #[test]
1093
        fn overlap_size_should_return_0_if_plugins_have_not_been_parsed() {
2✔
1094
            let mut plugin1 = Plugin::new(
2✔
1095
                GameId::Morrowind,
2✔
1096
                Path::new("testing-plugins/Morrowind/Data Files/Blank.esm"),
2✔
1097
            );
1098
            let mut plugin2 = Plugin::new(
2✔
1099
                GameId::Morrowind,
2✔
1100
                Path::new("testing-plugins/Morrowind/Data Files/Blank - Master Dependent.esm"),
2✔
1101
            );
1102

1103
            assert_eq!(0, plugin1.overlap_size(&[&plugin2]).unwrap());
2✔
1104

1105
            assert!(plugin1.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
1106

1107
            assert_eq!(0, plugin1.overlap_size(&[&plugin2]).unwrap());
2✔
1108

1109
            assert!(plugin2.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
1110

1111
            assert_ne!(0, plugin1.overlap_size(&[&plugin2]).unwrap());
2✔
1112
        }
2✔
1113

1114
        #[test]
1115
        fn overlap_size_should_return_0_when_there_is_no_overlap() {
2✔
1116
            let mut plugin1 = Plugin::new(
2✔
1117
                GameId::Morrowind,
2✔
1118
                Path::new("testing-plugins/Morrowind/Data Files/Blank.esm"),
2✔
1119
            );
1120
            let mut plugin2 = Plugin::new(
2✔
1121
                GameId::Morrowind,
2✔
1122
                Path::new("testing-plugins/Morrowind/Data Files/Blank.esp"),
2✔
1123
            );
1124

1125
            assert!(plugin1.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
1126
            assert!(plugin2.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
1127

1128
            assert!(!plugin1.overlaps_with(&plugin2).unwrap());
2✔
1129
            assert_eq!(0, plugin1.overlap_size(&[&plugin2]).unwrap());
2✔
1130
        }
2✔
1131

1132
        #[test]
1133
        fn valid_light_form_id_range_should_be_empty() {
2✔
1134
            let mut plugin = Plugin::new(
2✔
1135
                GameId::Morrowind,
2✔
1136
                Path::new("testing-plugins/Morrowind/Data Files/Blank - Master Dependent.esm"),
2✔
1137
            );
1138
            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
1139

1140
            let range = plugin.valid_light_form_id_range();
2✔
1141
            assert_eq!(&0, range.start());
2✔
1142
            assert_eq!(&0, range.end());
2✔
1143
        }
2✔
1144

1145
        #[test]
1146
        fn is_valid_as_light_plugin_should_always_be_false() {
2✔
1147
            let mut plugin = Plugin::new(
2✔
1148
                GameId::Morrowind,
2✔
1149
                Path::new("testing-plugins/Morrowind/Data Files/Blank - Master Dependent.esm"),
2✔
1150
            );
1151
            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
1152
            assert!(!plugin.is_valid_as_light_plugin().unwrap());
2✔
1153
        }
2✔
1154
    }
1155

1156
    mod oblivion {
1157
        use super::super::*;
1158

1159
        #[test]
1160
        fn is_light_plugin_should_be_false() {
2✔
1161
            let plugin = Plugin::new(GameId::Oblivion, Path::new("Blank.esp"));
2✔
1162
            assert!(!plugin.is_light_plugin());
2✔
1163
            let plugin = Plugin::new(GameId::Oblivion, Path::new("Blank.esm"));
2✔
1164
            assert!(!plugin.is_light_plugin());
2✔
1165
            let plugin = Plugin::new(GameId::Oblivion, Path::new("Blank.esl"));
2✔
1166
            assert!(!plugin.is_light_plugin());
2✔
1167
        }
2✔
1168

1169
        #[test]
1170
        fn is_medium_plugin_should_be_false() {
2✔
1171
            let plugin = Plugin::new(GameId::Oblivion, Path::new("Blank.esp"));
2✔
1172
            assert!(!plugin.is_medium_plugin());
2✔
1173
            let plugin = Plugin::new(GameId::Oblivion, Path::new("Blank.esm"));
2✔
1174
            assert!(!plugin.is_medium_plugin());
2✔
1175
            let plugin = Plugin::new(GameId::Oblivion, Path::new("Blank.esl"));
2✔
1176
            assert!(!plugin.is_medium_plugin());
2✔
1177
        }
2✔
1178

1179
        #[expect(clippy::float_cmp, reason = "float values should be exactly equal")]
1180
        #[test]
1181
        fn header_version_should_return_plugin_header_hedr_subrecord_field() {
2✔
1182
            let mut plugin = Plugin::new(
2✔
1183
                GameId::Oblivion,
2✔
1184
                Path::new("testing-plugins/Oblivion/Data/Blank.esm"),
2✔
1185
            );
1186

1187
            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
2✔
1188

1189
            assert_eq!(0.8, plugin.header_version().unwrap());
2✔
1190
        }
2✔
1191

1192
        #[test]
1193
        fn valid_light_form_id_range_should_be_empty() {
2✔
1194
            let mut plugin = Plugin::new(
2✔
1195
                GameId::Oblivion,
2✔
1196
                Path::new("testing-plugins/Oblivion/Data/Blank - Master Dependent.esm"),
2✔
1197
            );
1198
            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
1199

1200
            let range = plugin.valid_light_form_id_range();
2✔
1201
            assert_eq!(&0, range.start());
2✔
1202
            assert_eq!(&0, range.end());
2✔
1203
        }
2✔
1204

1205
        #[test]
1206
        fn is_valid_as_light_plugin_should_always_be_false() {
2✔
1207
            let mut plugin = Plugin::new(
2✔
1208
                GameId::Oblivion,
2✔
1209
                Path::new("testing-plugins/Oblivion/Data/Blank - Master Dependent.esm"),
2✔
1210
            );
1211
            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
1212
            assert!(!plugin.is_valid_as_light_plugin().unwrap());
2✔
1213
        }
2✔
1214
    }
1215

1216
    mod skyrim {
1217
        use super::*;
1218

1219
        #[test]
1220
        fn parse_file_should_succeed() {
2✔
1221
            let mut plugin = Plugin::new(
2✔
1222
                GameId::Skyrim,
2✔
1223
                Path::new("testing-plugins/Skyrim/Data/Blank.esm"),
2✔
1224
            );
1225

1226
            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
1227

1228
            match plugin.data.record_ids {
2✔
1229
                RecordIds::Resolved(ids) => assert_eq!(10, ids.len()),
2✔
1230
                _ => panic!("Expected resolved FormIDs"),
×
1231
            }
1232
        }
2✔
1233

1234
        #[test]
1235
        fn parse_file_header_only_should_not_store_form_ids() {
2✔
1236
            let mut plugin = Plugin::new(
2✔
1237
                GameId::Skyrim,
2✔
1238
                Path::new("testing-plugins/Skyrim/Data/Blank.esm"),
2✔
1239
            );
1240

1241
            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
2✔
1242

1243
            assert_eq!(RecordIds::None, plugin.data.record_ids);
2✔
1244
        }
2✔
1245

1246
        #[test]
1247
        fn game_id_should_return_the_plugins_associated_game_id() {
2✔
1248
            let plugin = Plugin::new(GameId::Skyrim, Path::new("Data/Blank.esm"));
2✔
1249

1250
            assert_eq!(GameId::Skyrim, plugin.game_id());
2✔
1251
        }
2✔
1252

1253
        #[test]
1254
        fn is_master_file_should_be_true_for_master_file() {
2✔
1255
            let mut plugin = Plugin::new(
2✔
1256
                GameId::Skyrim,
2✔
1257
                Path::new("testing-plugins/Skyrim/Data/Blank.esm"),
2✔
1258
            );
1259

1260
            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
2✔
1261
            assert!(plugin.is_master_file());
2✔
1262
        }
2✔
1263

1264
        #[test]
1265
        fn is_master_file_should_be_false_for_non_master_file() {
2✔
1266
            let mut plugin = Plugin::new(
2✔
1267
                GameId::Skyrim,
2✔
1268
                Path::new("testing-plugins/Skyrim/Data/Blank.esp"),
2✔
1269
            );
1270

1271
            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
2✔
1272
            assert!(!plugin.is_master_file());
2✔
1273
        }
2✔
1274

1275
        #[test]
1276
        fn is_light_plugin_should_be_false() {
2✔
1277
            let plugin = Plugin::new(GameId::Skyrim, Path::new("Blank.esp"));
2✔
1278
            assert!(!plugin.is_light_plugin());
2✔
1279
            let plugin = Plugin::new(GameId::Skyrim, Path::new("Blank.esm"));
2✔
1280
            assert!(!plugin.is_light_plugin());
2✔
1281
            let plugin = Plugin::new(GameId::Skyrim, Path::new("Blank.esl"));
2✔
1282
            assert!(!plugin.is_light_plugin());
2✔
1283
        }
2✔
1284

1285
        #[test]
1286
        fn is_medium_plugin_should_be_false() {
2✔
1287
            let plugin = Plugin::new(GameId::Skyrim, Path::new("Blank.esp"));
2✔
1288
            assert!(!plugin.is_medium_plugin());
2✔
1289
            let plugin = Plugin::new(GameId::Skyrim, Path::new("Blank.esm"));
2✔
1290
            assert!(!plugin.is_medium_plugin());
2✔
1291
            let plugin = Plugin::new(GameId::Skyrim, Path::new("Blank.esl"));
2✔
1292
            assert!(!plugin.is_medium_plugin());
2✔
1293
        }
2✔
1294

1295
        #[test]
1296
        fn description_should_return_plugin_description_field_content() {
2✔
1297
            let mut plugin = Plugin::new(
2✔
1298
                GameId::Skyrim,
2✔
1299
                Path::new("testing-plugins/Skyrim/Data/Blank.esm"),
2✔
1300
            );
1301

1302
            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
2✔
1303
            assert_eq!("v5.0", plugin.description().unwrap().unwrap());
2✔
1304

1305
            let mut plugin = Plugin::new(
2✔
1306
                GameId::Skyrim,
2✔
1307
                Path::new("testing-plugins/Skyrim/Data/Blank.esp"),
2✔
1308
            );
1309

1310
            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
2✔
1311
            assert_eq!(
2✔
1312
                "\u{20ac}\u{192}\u{160}",
1313
                plugin.description().unwrap().unwrap()
2✔
1314
            );
1315

1316
            let mut plugin = Plugin::new(
2✔
1317
                GameId::Skyrim,
2✔
1318
                Path::new(
2✔
1319
                    "testing-plugins/Skyrim/Data/Blank - \
2✔
1320
                      Master Dependent.esm",
2✔
1321
                ),
2✔
1322
            );
1323

1324
            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
2✔
1325
            assert_eq!("", plugin.description().unwrap().unwrap());
2✔
1326
        }
2✔
1327

1328
        #[test]
1329
        fn description_should_trim_nulls_in_plugin_description_field_content() {
2✔
1330
            let mut plugin = Plugin::new(
2✔
1331
                GameId::Skyrim,
2✔
1332
                Path::new("testing-plugins/Skyrim/Data/Blank.esm"),
2✔
1333
            );
1334

1335
            let mut bytes = read(plugin.path()).unwrap();
2✔
1336

1337
            assert_eq!(0x2E, bytes[0x39]);
2✔
1338
            bytes[0x39] = 0;
2✔
1339

1340
            assert!(plugin
2✔
1341
                .parse_reader(Cursor::new(bytes), ParseOptions::whole_plugin())
2✔
1342
                .is_ok());
2✔
1343

1344
            assert_eq!("v5", plugin.description().unwrap().unwrap());
2✔
1345
        }
2✔
1346

1347
        #[expect(clippy::float_cmp, reason = "float values should be exactly equal")]
1348
        #[test]
1349
        fn header_version_should_return_plugin_header_hedr_subrecord_field() {
2✔
1350
            let mut plugin = Plugin::new(
2✔
1351
                GameId::Skyrim,
2✔
1352
                Path::new("testing-plugins/Skyrim/Data/Blank.esm"),
2✔
1353
            );
1354

1355
            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
2✔
1356

1357
            assert_eq!(0.94, plugin.header_version().unwrap());
2✔
1358
        }
2✔
1359

1360
        #[test]
1361
        fn record_and_group_count_should_be_non_zero_for_a_plugin_with_records() {
2✔
1362
            let mut plugin = Plugin::new(
2✔
1363
                GameId::Skyrim,
2✔
1364
                Path::new("testing-plugins/Skyrim/Data/Blank.esm"),
2✔
1365
            );
1366

1367
            assert!(plugin.record_and_group_count().is_none());
2✔
1368
            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
2✔
1369
            assert_eq!(15, plugin.record_and_group_count().unwrap());
2✔
1370
        }
2✔
1371

1372
        #[test]
1373
        fn count_override_records_should_count_how_many_records_come_from_masters() {
2✔
1374
            let mut plugin = Plugin::new(
2✔
1375
                GameId::Skyrim,
2✔
1376
                Path::new("testing-plugins/Skyrim/Data/Blank - Different Master Dependent.esp"),
2✔
1377
            );
1378

1379
            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
1380
            assert_eq!(2, plugin.count_override_records().unwrap());
2✔
1381
        }
2✔
1382

1383
        #[test]
1384
        fn overlaps_with_should_detect_when_two_plugins_have_a_record_from_the_same_master() {
2✔
1385
            let mut plugin1 = Plugin::new(
2✔
1386
                GameId::Skyrim,
2✔
1387
                Path::new("testing-plugins/Skyrim/Data/Blank.esm"),
2✔
1388
            );
1389
            let mut plugin2 = Plugin::new(
2✔
1390
                GameId::Skyrim,
2✔
1391
                Path::new("testing-plugins/Skyrim/Data/Blank - Different.esm"),
2✔
1392
            );
1393

1394
            assert!(plugin1.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
1395
            assert!(plugin2.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
1396

1397
            assert!(plugin1.overlaps_with(&plugin1).unwrap());
2✔
1398
            assert!(!plugin1.overlaps_with(&plugin2).unwrap());
2✔
1399
        }
2✔
1400

1401
        #[test]
1402
        fn overlap_size_should_only_count_each_record_once() {
2✔
1403
            let mut plugin1 = Plugin::new(
2✔
1404
                GameId::Skyrim,
2✔
1405
                Path::new("testing-plugins/Skyrim/Data/Blank.esm"),
2✔
1406
            );
1407
            let mut plugin2 = Plugin::new(
2✔
1408
                GameId::Skyrim,
2✔
1409
                Path::new("testing-plugins/Skyrim/Data/Blank - Master Dependent.esm"),
2✔
1410
            );
1411

1412
            assert!(plugin1.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
1413
            assert!(plugin2.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
1414

1415
            assert_eq!(4, plugin1.overlap_size(&[&plugin2, &plugin2]).unwrap());
2✔
1416
        }
2✔
1417

1418
        #[test]
1419
        fn overlap_size_should_check_against_all_given_plugins() {
2✔
1420
            let mut plugin1 = Plugin::new(
2✔
1421
                GameId::Skyrim,
2✔
1422
                Path::new("testing-plugins/Skyrim/Data/Blank.esm"),
2✔
1423
            );
1424
            let mut plugin2 = Plugin::new(
2✔
1425
                GameId::Skyrim,
2✔
1426
                Path::new("testing-plugins/Skyrim/Data/Blank.esp"),
2✔
1427
            );
1428
            let mut plugin3 = Plugin::new(
2✔
1429
                GameId::Skyrim,
2✔
1430
                Path::new("testing-plugins/Skyrim/Data/Blank - Master Dependent.esp"),
2✔
1431
            );
1432

1433
            assert!(plugin1.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
1434
            assert!(plugin2.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
1435
            assert!(plugin3.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
1436

1437
            assert_eq!(2, plugin1.overlap_size(&[&plugin2, &plugin3]).unwrap());
2✔
1438
        }
2✔
1439

1440
        #[test]
1441
        fn overlap_size_should_return_0_if_plugins_have_not_been_parsed() {
2✔
1442
            let mut plugin1 = Plugin::new(
2✔
1443
                GameId::Skyrim,
2✔
1444
                Path::new("testing-plugins/Skyrim/Data/Blank.esm"),
2✔
1445
            );
1446
            let mut plugin2 = Plugin::new(
2✔
1447
                GameId::Skyrim,
2✔
1448
                Path::new("testing-plugins/Skyrim/Data/Blank - Master Dependent.esm"),
2✔
1449
            );
1450

1451
            assert_eq!(0, plugin1.overlap_size(&[&plugin2]).unwrap());
2✔
1452

1453
            assert!(plugin1.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
1454

1455
            assert_eq!(0, plugin1.overlap_size(&[&plugin2]).unwrap());
2✔
1456

1457
            assert!(plugin2.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
1458

1459
            assert_ne!(0, plugin1.overlap_size(&[&plugin2]).unwrap());
2✔
1460
        }
2✔
1461

1462
        #[test]
1463
        fn overlap_size_should_return_0_when_there_is_no_overlap() {
2✔
1464
            let mut plugin1 = Plugin::new(
2✔
1465
                GameId::Skyrim,
2✔
1466
                Path::new("testing-plugins/Skyrim/Data/Blank.esm"),
2✔
1467
            );
1468
            let mut plugin2 = Plugin::new(
2✔
1469
                GameId::Skyrim,
2✔
1470
                Path::new("testing-plugins/Skyrim/Data/Blank.esp"),
2✔
1471
            );
1472

1473
            assert!(plugin1.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
1474
            assert!(plugin2.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
1475

1476
            assert!(!plugin1.overlaps_with(&plugin2).unwrap());
2✔
1477
            assert_eq!(0, plugin1.overlap_size(&[&plugin2]).unwrap());
2✔
1478
        }
2✔
1479

1480
        #[test]
1481
        fn valid_light_form_id_range_should_be_empty() {
2✔
1482
            let mut plugin = Plugin::new(
2✔
1483
                GameId::Skyrim,
2✔
1484
                Path::new("testing-plugins/Skyrim/Data/Blank - Master Dependent.esm"),
2✔
1485
            );
1486
            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
1487

1488
            let range = plugin.valid_light_form_id_range();
2✔
1489
            assert_eq!(&0, range.start());
2✔
1490
            assert_eq!(&0, range.end());
2✔
1491
        }
2✔
1492

1493
        #[test]
1494
        fn is_valid_as_light_plugin_should_always_be_false() {
2✔
1495
            let mut plugin = Plugin::new(
2✔
1496
                GameId::Skyrim,
2✔
1497
                Path::new("testing-plugins/Skyrim/Data/Blank - Master Dependent.esm"),
2✔
1498
            );
1499
            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
1500
            assert!(!plugin.is_valid_as_light_plugin().unwrap());
2✔
1501
        }
2✔
1502
    }
1503

1504
    mod skyrimse {
1505
        use super::*;
1506

1507
        #[test]
1508
        fn is_master_file_should_use_file_extension_and_flag() {
2✔
1509
            let tmp_dir = tempdir().unwrap();
2✔
1510

1511
            let master_flagged_esp = tmp_dir.path().join("Blank.esp");
2✔
1512
            copy(
2✔
1513
                Path::new("testing-plugins/Skyrim/Data/Blank.esm"),
2✔
1514
                &master_flagged_esp,
2✔
1515
            )
1516
            .unwrap();
2✔
1517

1518
            let plugin = Plugin::new(GameId::SkyrimSE, Path::new("Blank.esp"));
2✔
1519
            assert!(!plugin.is_master_file());
2✔
1520

1521
            let plugin = Plugin::new(GameId::SkyrimSE, Path::new("Blank.esm"));
2✔
1522
            assert!(plugin.is_master_file());
2✔
1523

1524
            let plugin = Plugin::new(GameId::SkyrimSE, Path::new("Blank.esl"));
2✔
1525
            assert!(plugin.is_master_file());
2✔
1526

1527
            let mut plugin = Plugin::new(
2✔
1528
                GameId::SkyrimSE,
2✔
1529
                Path::new("testing-plugins/Skyrim/Data/Blank.esp"),
2✔
1530
            );
1531
            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
2✔
1532
            assert!(!plugin.is_master_file());
2✔
1533

1534
            let mut plugin = Plugin::new(GameId::SkyrimSE, &master_flagged_esp);
2✔
1535
            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
2✔
1536
            assert!(plugin.is_master_file());
2✔
1537
        }
2✔
1538

1539
        #[test]
1540
        fn is_light_plugin_should_be_true_for_plugins_with_an_esl_file_extension() {
2✔
1541
            let plugin = Plugin::new(GameId::SkyrimSE, Path::new("Blank.esp"));
2✔
1542
            assert!(!plugin.is_light_plugin());
2✔
1543
            let plugin = Plugin::new(GameId::SkyrimSE, Path::new("Blank.esm"));
2✔
1544
            assert!(!plugin.is_light_plugin());
2✔
1545
            let plugin = Plugin::new(GameId::SkyrimSE, Path::new("Blank.esl"));
2✔
1546
            assert!(plugin.is_light_plugin());
2✔
1547
        }
2✔
1548

1549
        #[test]
1550
        fn is_light_plugin_should_be_true_for_a_ghosted_esl_file() {
2✔
1551
            let plugin = Plugin::new(GameId::SkyrimSE, Path::new("Blank.esl.ghost"));
2✔
1552
            assert!(plugin.is_light_plugin());
2✔
1553
        }
2✔
1554

1555
        #[test]
1556
        fn is_light_plugin_should_be_true_for_an_esp_file_with_the_light_flag_set() {
2✔
1557
            let tmp_dir = tempdir().unwrap();
2✔
1558

1559
            let light_flagged_esp = tmp_dir.path().join("Blank.esp");
2✔
1560
            copy(
2✔
1561
                Path::new("testing-plugins/SkyrimSE/Data/Blank.esl"),
2✔
1562
                &light_flagged_esp,
2✔
1563
            )
1564
            .unwrap();
2✔
1565

1566
            let mut plugin = Plugin::new(GameId::SkyrimSE, &light_flagged_esp);
2✔
1567
            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
2✔
1568
            assert!(plugin.is_light_plugin());
2✔
1569
            assert!(!plugin.is_master_file());
2✔
1570
        }
2✔
1571

1572
        #[test]
1573
        fn is_light_plugin_should_be_true_for_an_esm_file_with_the_light_flag_set() {
2✔
1574
            let tmp_dir = tempdir().unwrap();
2✔
1575

1576
            let light_flagged_esm = tmp_dir.path().join("Blank.esm");
2✔
1577
            copy(
2✔
1578
                Path::new("testing-plugins/SkyrimSE/Data/Blank.esl"),
2✔
1579
                &light_flagged_esm,
2✔
1580
            )
1581
            .unwrap();
2✔
1582

1583
            let mut plugin = Plugin::new(GameId::SkyrimSE, &light_flagged_esm);
2✔
1584
            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
2✔
1585
            assert!(plugin.is_light_plugin());
2✔
1586
            assert!(plugin.is_master_file());
2✔
1587
        }
2✔
1588

1589
        #[test]
1590
        fn is_medium_plugin_should_be_false() {
2✔
1591
            let plugin = Plugin::new(GameId::SkyrimSE, Path::new("Blank.esp"));
2✔
1592
            assert!(!plugin.is_medium_plugin());
2✔
1593
            let plugin = Plugin::new(GameId::SkyrimSE, Path::new("Blank.esm"));
2✔
1594
            assert!(!plugin.is_medium_plugin());
2✔
1595
            let plugin = Plugin::new(GameId::SkyrimSE, Path::new("Blank.esl"));
2✔
1596
            assert!(!plugin.is_medium_plugin());
2✔
1597
        }
2✔
1598

1599
        #[expect(clippy::float_cmp, reason = "float values should be exactly equal")]
1600
        #[test]
1601
        fn header_version_should_return_plugin_header_hedr_subrecord_field() {
2✔
1602
            let mut plugin = Plugin::new(
2✔
1603
                GameId::SkyrimSE,
2✔
1604
                Path::new("testing-plugins/SkyrimSE/Data/Blank.esm"),
2✔
1605
            );
1606

1607
            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
2✔
1608

1609
            assert_eq!(0.94, plugin.header_version().unwrap());
2✔
1610
        }
2✔
1611

1612
        #[test]
1613
        fn valid_light_form_id_range_should_be_0x800_to_0xfff_if_hedr_version_is_less_than_1_71() {
2✔
1614
            let mut plugin = Plugin::new(
2✔
1615
                GameId::SkyrimSE,
2✔
1616
                Path::new("testing-plugins/SkyrimSE/Data/Blank - Master Dependent.esm"),
2✔
1617
            );
1618
            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
1619

1620
            let range = plugin.valid_light_form_id_range();
2✔
1621
            assert_eq!(&0x800, range.start());
2✔
1622
            assert_eq!(&0xFFF, range.end());
2✔
1623
        }
2✔
1624

1625
        #[test]
1626
        fn valid_light_form_id_range_should_be_0_to_0xfff_if_hedr_version_is_1_71_or_greater() {
2✔
1627
            let mut plugin = Plugin::new(
2✔
1628
                GameId::SkyrimSE,
2✔
1629
                Path::new("testing-plugins/SkyrimSE/Data/Blank - Master Dependent.esm"),
2✔
1630
            );
1631
            let mut bytes = read(plugin.path()).unwrap();
2✔
1632

1633
            assert_eq!(0xD7, bytes[0x1E]);
2✔
1634
            assert_eq!(0xA3, bytes[0x1F]);
2✔
1635
            assert_eq!(0x70, bytes[0x20]);
2✔
1636
            assert_eq!(0x3F, bytes[0x21]);
2✔
1637
            bytes[0x1E] = 0x48;
2✔
1638
            bytes[0x1F] = 0xE1;
2✔
1639
            bytes[0x20] = 0xDA;
2✔
1640
            bytes[0x21] = 0x3F;
2✔
1641

1642
            assert!(plugin
2✔
1643
                .parse_reader(Cursor::new(bytes), ParseOptions::whole_plugin())
2✔
1644
                .is_ok());
2✔
1645

1646
            let range = plugin.valid_light_form_id_range();
2✔
1647
            assert_eq!(&0, range.start());
2✔
1648
            assert_eq!(&0xFFF, range.end());
2✔
1649
        }
2✔
1650

1651
        #[test]
1652
        fn is_valid_as_light_plugin_should_be_true_if_the_plugin_has_no_form_ids_outside_the_valid_range(
2✔
1653
        ) {
2✔
1654
            let mut plugin = Plugin::new(
2✔
1655
                GameId::SkyrimSE,
2✔
1656
                Path::new("testing-plugins/SkyrimSE/Data/Blank - Master Dependent.esm"),
2✔
1657
            );
1658
            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
1659

1660
            assert!(plugin.is_valid_as_light_plugin().unwrap());
2✔
1661
        }
2✔
1662

1663
        #[test]
1664
        fn is_valid_as_light_plugin_should_be_true_if_the_plugin_has_an_override_form_id_outside_the_valid_range(
2✔
1665
        ) {
2✔
1666
            let mut plugin = Plugin::new(
2✔
1667
                GameId::SkyrimSE,
2✔
1668
                Path::new("testing-plugins/SkyrimSE/Data/Blank - Master Dependent.esm"),
2✔
1669
            );
1670
            let mut bytes = read(plugin.path()).unwrap();
2✔
1671

1672
            assert_eq!(0xF0, bytes[0x7A]);
2✔
1673
            assert_eq!(0x0C, bytes[0x7B]);
2✔
1674
            bytes[0x7A] = 0xFF;
2✔
1675
            bytes[0x7B] = 0x07;
2✔
1676

1677
            assert!(plugin
2✔
1678
                .parse_reader(Cursor::new(bytes), ParseOptions::whole_plugin())
2✔
1679
                .is_ok());
2✔
1680

1681
            assert!(plugin.is_valid_as_light_plugin().unwrap());
2✔
1682
        }
2✔
1683

1684
        #[test]
1685
        fn is_valid_as_light_plugin_should_be_false_if_the_plugin_has_a_new_form_id_greater_than_0xfff(
2✔
1686
        ) {
2✔
1687
            let mut plugin = Plugin::new(
2✔
1688
                GameId::SkyrimSE,
2✔
1689
                Path::new("testing-plugins/SkyrimSE/Data/Blank - Master Dependent.esm"),
2✔
1690
            );
1691
            let mut bytes = read(plugin.path()).unwrap();
2✔
1692

1693
            assert_eq!(0xEB, bytes[0x386]);
2✔
1694
            assert_eq!(0x0C, bytes[0x387]);
2✔
1695
            bytes[0x386] = 0x00;
2✔
1696
            bytes[0x387] = 0x10;
2✔
1697

1698
            assert!(plugin
2✔
1699
                .parse_reader(Cursor::new(bytes), ParseOptions::whole_plugin())
2✔
1700
                .is_ok());
2✔
1701

1702
            assert!(!plugin.is_valid_as_light_plugin().unwrap());
2✔
1703
        }
2✔
1704
    }
1705

1706
    mod fallout3 {
1707
        use super::super::*;
1708

1709
        #[test]
1710
        fn is_light_plugin_should_be_false() {
2✔
1711
            let plugin = Plugin::new(GameId::Fallout3, Path::new("Blank.esp"));
2✔
1712
            assert!(!plugin.is_light_plugin());
2✔
1713
            let plugin = Plugin::new(GameId::Fallout3, Path::new("Blank.esm"));
2✔
1714
            assert!(!plugin.is_light_plugin());
2✔
1715
            let plugin = Plugin::new(GameId::Fallout3, Path::new("Blank.esl"));
2✔
1716
            assert!(!plugin.is_light_plugin());
2✔
1717
        }
2✔
1718

1719
        #[test]
1720
        fn is_medium_plugin_should_be_false() {
2✔
1721
            let plugin = Plugin::new(GameId::Fallout3, Path::new("Blank.esp"));
2✔
1722
            assert!(!plugin.is_medium_plugin());
2✔
1723
            let plugin = Plugin::new(GameId::Fallout3, Path::new("Blank.esm"));
2✔
1724
            assert!(!plugin.is_medium_plugin());
2✔
1725
            let plugin = Plugin::new(GameId::Fallout3, Path::new("Blank.esl"));
2✔
1726
            assert!(!plugin.is_medium_plugin());
2✔
1727
        }
2✔
1728

1729
        #[test]
1730
        fn valid_light_form_id_range_should_be_empty() {
2✔
1731
            let mut plugin = Plugin::new(
2✔
1732
                GameId::Fallout3,
2✔
1733
                Path::new("testing-plugins/Skyrim/Data/Blank - Master Dependent.esm"),
2✔
1734
            );
1735
            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
1736

1737
            let range = plugin.valid_light_form_id_range();
2✔
1738
            assert_eq!(&0, range.start());
2✔
1739
            assert_eq!(&0, range.end());
2✔
1740
        }
2✔
1741

1742
        #[test]
1743
        fn is_valid_as_light_plugin_should_always_be_false() {
2✔
1744
            let mut plugin = Plugin::new(
2✔
1745
                GameId::Fallout3,
2✔
1746
                Path::new("testing-plugins/Skyrim/Data/Blank - Master Dependent.esm"),
2✔
1747
            );
1748
            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
1749
            assert!(!plugin.is_valid_as_light_plugin().unwrap());
2✔
1750
        }
2✔
1751
    }
1752

1753
    mod falloutnv {
1754
        use super::super::*;
1755

1756
        #[test]
1757
        fn is_light_plugin_should_be_false() {
2✔
1758
            let plugin = Plugin::new(GameId::FalloutNV, Path::new("Blank.esp"));
2✔
1759
            assert!(!plugin.is_light_plugin());
2✔
1760
            let plugin = Plugin::new(GameId::FalloutNV, Path::new("Blank.esm"));
2✔
1761
            assert!(!plugin.is_light_plugin());
2✔
1762
            let plugin = Plugin::new(GameId::FalloutNV, Path::new("Blank.esl"));
2✔
1763
            assert!(!plugin.is_light_plugin());
2✔
1764
        }
2✔
1765

1766
        #[test]
1767
        fn is_medium_plugin_should_be_false() {
2✔
1768
            let plugin = Plugin::new(GameId::FalloutNV, Path::new("Blank.esp"));
2✔
1769
            assert!(!plugin.is_medium_plugin());
2✔
1770
            let plugin = Plugin::new(GameId::FalloutNV, Path::new("Blank.esm"));
2✔
1771
            assert!(!plugin.is_medium_plugin());
2✔
1772
            let plugin = Plugin::new(GameId::FalloutNV, Path::new("Blank.esl"));
2✔
1773
            assert!(!plugin.is_medium_plugin());
2✔
1774
        }
2✔
1775

1776
        #[test]
1777
        fn valid_light_form_id_range_should_be_empty() {
2✔
1778
            let mut plugin = Plugin::new(
2✔
1779
                GameId::Fallout3,
2✔
1780
                Path::new("testing-plugins/Skyrim/Data/Blank - Master Dependent.esm"),
2✔
1781
            );
1782
            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
1783

1784
            let range = plugin.valid_light_form_id_range();
2✔
1785
            assert_eq!(&0, range.start());
2✔
1786
            assert_eq!(&0, range.end());
2✔
1787
        }
2✔
1788

1789
        #[test]
1790
        fn is_valid_as_light_plugin_should_always_be_false() {
2✔
1791
            let mut plugin = Plugin::new(
2✔
1792
                GameId::FalloutNV,
2✔
1793
                Path::new("testing-plugins/Skyrim/Data/Blank - Master Dependent.esm"),
2✔
1794
            );
1795
            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
1796
            assert!(!plugin.is_valid_as_light_plugin().unwrap());
2✔
1797
        }
2✔
1798
    }
1799

1800
    mod fallout4 {
1801
        use super::*;
1802

1803
        #[test]
1804
        fn is_master_file_should_use_file_extension_and_flag() {
2✔
1805
            let tmp_dir = tempdir().unwrap();
2✔
1806

1807
            let master_flagged_esp = tmp_dir.path().join("Blank.esp");
2✔
1808
            copy(
2✔
1809
                Path::new("testing-plugins/Skyrim/Data/Blank.esm"),
2✔
1810
                &master_flagged_esp,
2✔
1811
            )
1812
            .unwrap();
2✔
1813

1814
            let plugin = Plugin::new(GameId::Fallout4, Path::new("Blank.esp"));
2✔
1815
            assert!(!plugin.is_master_file());
2✔
1816

1817
            let plugin = Plugin::new(GameId::Fallout4, Path::new("Blank.esm"));
2✔
1818
            assert!(plugin.is_master_file());
2✔
1819

1820
            let plugin = Plugin::new(GameId::Fallout4, Path::new("Blank.esl"));
2✔
1821
            assert!(plugin.is_master_file());
2✔
1822

1823
            let mut plugin = Plugin::new(
2✔
1824
                GameId::Fallout4,
2✔
1825
                Path::new("testing-plugins/Skyrim/Data/Blank.esp"),
2✔
1826
            );
1827
            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
2✔
1828
            assert!(!plugin.is_master_file());
2✔
1829

1830
            let mut plugin = Plugin::new(GameId::Fallout4, &master_flagged_esp);
2✔
1831
            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
2✔
1832
            assert!(plugin.is_master_file());
2✔
1833
        }
2✔
1834

1835
        #[test]
1836
        fn is_light_plugin_should_be_true_for_plugins_with_an_esl_file_extension() {
2✔
1837
            let plugin = Plugin::new(GameId::Fallout4, Path::new("Blank.esp"));
2✔
1838
            assert!(!plugin.is_light_plugin());
2✔
1839
            let plugin = Plugin::new(GameId::Fallout4, Path::new("Blank.esm"));
2✔
1840
            assert!(!plugin.is_light_plugin());
2✔
1841
            let plugin = Plugin::new(GameId::Fallout4, Path::new("Blank.esl"));
2✔
1842
            assert!(plugin.is_light_plugin());
2✔
1843
        }
2✔
1844

1845
        #[test]
1846
        fn is_light_plugin_should_be_true_for_a_ghosted_esl_file() {
2✔
1847
            let plugin = Plugin::new(GameId::Fallout4, Path::new("Blank.esl.ghost"));
2✔
1848
            assert!(plugin.is_light_plugin());
2✔
1849
        }
2✔
1850

1851
        #[test]
1852
        fn is_medium_plugin_should_be_false() {
2✔
1853
            let plugin = Plugin::new(GameId::Fallout4, Path::new("Blank.esp"));
2✔
1854
            assert!(!plugin.is_medium_plugin());
2✔
1855
            let plugin = Plugin::new(GameId::Fallout4, Path::new("Blank.esm"));
2✔
1856
            assert!(!plugin.is_medium_plugin());
2✔
1857
            let plugin = Plugin::new(GameId::Fallout4, Path::new("Blank.esl"));
2✔
1858
            assert!(!plugin.is_medium_plugin());
2✔
1859
        }
2✔
1860

1861
        #[test]
1862
        fn valid_light_form_id_range_should_be_1_to_0xfff_if_hedr_version_is_less_than_1() {
2✔
1863
            let mut plugin = Plugin::new(
2✔
1864
                GameId::Fallout4,
2✔
1865
                Path::new("testing-plugins/SkyrimSE/Data/Blank - Master Dependent.esm"),
2✔
1866
            );
1867
            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
1868

1869
            let range = plugin.valid_light_form_id_range();
2✔
1870
            assert_eq!(&0x800, range.start());
2✔
1871
            assert_eq!(&0xFFF, range.end());
2✔
1872
        }
2✔
1873

1874
        #[test]
1875
        fn valid_light_form_id_range_should_be_1_to_0xfff_if_hedr_version_is_1_or_greater() {
2✔
1876
            let mut plugin = Plugin::new(
2✔
1877
                GameId::Fallout4,
2✔
1878
                Path::new("testing-plugins/SkyrimSE/Data/Blank - Master Dependent.esm"),
2✔
1879
            );
1880
            let mut bytes = read(plugin.path()).unwrap();
2✔
1881

1882
            assert_eq!(0xD7, bytes[0x1E]);
2✔
1883
            assert_eq!(0xA3, bytes[0x1F]);
2✔
1884
            assert_eq!(0x70, bytes[0x20]);
2✔
1885
            assert_eq!(0x3F, bytes[0x21]);
2✔
1886
            bytes[0x1E] = 0;
2✔
1887
            bytes[0x1F] = 0;
2✔
1888
            bytes[0x20] = 0x80;
2✔
1889
            bytes[0x21] = 0x3F;
2✔
1890

1891
            assert!(plugin
2✔
1892
                .parse_reader(Cursor::new(bytes), ParseOptions::whole_plugin())
2✔
1893
                .is_ok());
2✔
1894

1895
            let range = plugin.valid_light_form_id_range();
2✔
1896
            assert_eq!(&1, range.start());
2✔
1897
            assert_eq!(&0xFFF, range.end());
2✔
1898
        }
2✔
1899

1900
        #[test]
1901
        fn is_valid_as_light_plugin_should_be_true_if_the_plugin_has_no_form_ids_outside_the_valid_range(
2✔
1902
        ) {
2✔
1903
            let mut plugin = Plugin::new(
2✔
1904
                GameId::Fallout4,
2✔
1905
                Path::new("testing-plugins/SkyrimSE/Data/Blank - Master Dependent.esm"),
2✔
1906
            );
1907
            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
1908

1909
            assert!(plugin.is_valid_as_light_plugin().unwrap());
2✔
1910
        }
2✔
1911
    }
1912

1913
    mod starfield {
1914
        use super::*;
1915

1916
        #[test]
1917
        fn parse_file_should_succeed() {
2✔
1918
            let mut plugin = Plugin::new(
2✔
1919
                GameId::Starfield,
2✔
1920
                Path::new("testing-plugins/Starfield/Data/Blank.full.esm"),
2✔
1921
            );
1922

1923
            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
1924

1925
            match plugin.data.record_ids {
2✔
1926
                RecordIds::FormIds(ids) => assert_eq!(10, ids.len()),
2✔
1927
                _ => panic!("Expected raw FormIDs"),
×
1928
            }
1929
        }
2✔
1930

1931
        #[test]
1932
        fn resolve_record_ids_should_resolve_unresolved_form_ids() {
2✔
1933
            let mut plugin = Plugin::new(
2✔
1934
                GameId::Starfield,
2✔
1935
                Path::new("testing-plugins/Starfield/Data/Blank.full.esm"),
2✔
1936
            );
1937

1938
            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
1939

1940
            assert!(plugin.resolve_record_ids(&[]).is_ok());
2✔
1941

1942
            match plugin.data.record_ids {
2✔
1943
                RecordIds::Resolved(ids) => assert_eq!(10, ids.len()),
2✔
1944
                _ => panic!("Expected resolved FormIDs"),
×
1945
            }
1946
        }
2✔
1947

1948
        #[test]
1949
        fn resolve_record_ids_should_do_nothing_if_form_ids_are_already_resolved() {
2✔
1950
            let mut plugin = Plugin::new(
2✔
1951
                GameId::Starfield,
2✔
1952
                Path::new("testing-plugins/Starfield/Data/Blank.full.esm"),
2✔
1953
            );
1954

1955
            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
1956

1957
            assert!(plugin.resolve_record_ids(&[]).is_ok());
2✔
1958

1959
            let vec_ptr = match &plugin.data.record_ids {
2✔
1960
                RecordIds::Resolved(ids) => ids.as_ptr(),
2✔
1961
                _ => panic!("Expected resolved FormIDs"),
×
1962
            };
1963

1964
            assert!(plugin.resolve_record_ids(&[]).is_ok());
2✔
1965

1966
            let vec_ptr_2 = match &plugin.data.record_ids {
2✔
1967
                RecordIds::Resolved(ids) => ids.as_ptr(),
2✔
1968
                _ => panic!("Expected resolved FormIDs"),
×
1969
            };
1970

1971
            assert_eq!(vec_ptr, vec_ptr_2);
2✔
1972
        }
2✔
1973

1974
        #[test]
1975
        fn scale_should_return_full_for_a_full_plugin() {
2✔
1976
            let mut plugin = Plugin::new(
2✔
1977
                GameId::Starfield,
2✔
1978
                Path::new("testing-plugins/Starfield/Data/Blank.full.esm"),
2✔
1979
            );
1980
            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
2✔
1981

1982
            assert_eq!(PluginScale::Full, plugin.scale());
2✔
1983
        }
2✔
1984

1985
        #[test]
1986
        fn scale_should_return_medium_for_a_medium_plugin() {
2✔
1987
            let mut plugin = Plugin::new(
2✔
1988
                GameId::Starfield,
2✔
1989
                Path::new("testing-plugins/Starfield/Data/Blank.medium.esm"),
2✔
1990
            );
1991
            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
2✔
1992

1993
            assert_eq!(PluginScale::Medium, plugin.scale());
2✔
1994
        }
2✔
1995

1996
        #[test]
1997
        fn scale_should_return_small_for_a_small_plugin() {
2✔
1998
            let mut plugin = Plugin::new(
2✔
1999
                GameId::Starfield,
2✔
2000
                Path::new("testing-plugins/Starfield/Data/Blank.small.esm"),
2✔
2001
            );
2002
            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
2✔
2003

2004
            assert_eq!(PluginScale::Small, plugin.scale());
2✔
2005
        }
2✔
2006

2007
        #[test]
2008
        fn is_master_file_should_use_file_extension_and_flag() {
2✔
2009
            let tmp_dir = tempdir().unwrap();
2✔
2010

2011
            let master_flagged_esp = tmp_dir.path().join("Blank.esp");
2✔
2012
            copy(
2✔
2013
                Path::new("testing-plugins/Starfield/Data/Blank.full.esm"),
2✔
2014
                &master_flagged_esp,
2✔
2015
            )
2016
            .unwrap();
2✔
2017

2018
            let plugin = Plugin::new(GameId::Starfield, Path::new("Blank.esp"));
2✔
2019
            assert!(!plugin.is_master_file());
2✔
2020

2021
            let plugin = Plugin::new(GameId::Starfield, Path::new("Blank.esm"));
2✔
2022
            assert!(plugin.is_master_file());
2✔
2023

2024
            let plugin = Plugin::new(GameId::Starfield, Path::new("Blank.esl"));
2✔
2025
            assert!(plugin.is_master_file());
2✔
2026

2027
            let mut plugin = Plugin::new(
2✔
2028
                GameId::Starfield,
2✔
2029
                Path::new("testing-plugins/Starfield/Data/Blank.esp"),
2✔
2030
            );
2031
            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
2✔
2032
            assert!(!plugin.is_master_file());
2✔
2033

2034
            let mut plugin = Plugin::new(GameId::Starfield, &master_flagged_esp);
2✔
2035
            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
2✔
2036
            assert!(plugin.is_master_file());
2✔
2037
        }
2✔
2038

2039
        #[test]
2040
        fn is_light_plugin_should_be_true_for_plugins_with_an_esl_file_extension_if_the_update_flag_is_not_set(
2✔
2041
        ) {
2✔
2042
            // The flag won't be set because no data is loaded.
2043
            let plugin = Plugin::new(GameId::Starfield, Path::new("Blank.esp"));
2✔
2044
            assert!(!plugin.is_light_plugin());
2✔
2045
            let plugin = Plugin::new(GameId::Starfield, Path::new("Blank.esm"));
2✔
2046
            assert!(!plugin.is_light_plugin());
2✔
2047
            let plugin = Plugin::new(GameId::Starfield, Path::new("Blank.esl"));
2✔
2048
            assert!(plugin.is_light_plugin());
2✔
2049
        }
2✔
2050

2051
        #[test]
2052
        fn is_light_plugin_should_be_false_for_a_plugin_with_the_update_flag_set_and_an_esl_extension(
2✔
2053
        ) {
2✔
2054
            let tmp_dir = tempdir().unwrap();
2✔
2055
            let esl_path = tmp_dir.path().join("Blank.esl");
2✔
2056
            copy(
2✔
2057
                Path::new("testing-plugins/Starfield/Data/Blank - Override.esp"),
2✔
2058
                &esl_path,
2✔
2059
            )
2060
            .unwrap();
2✔
2061

2062
            let mut plugin = Plugin::new(GameId::Starfield, &esl_path);
2✔
2063
            plugin.parse_file(ParseOptions::header_only()).unwrap();
2✔
2064

2065
            assert!(!plugin.is_light_plugin());
2✔
2066
        }
2✔
2067

2068
        #[test]
2069
        fn is_light_plugin_should_be_true_for_a_ghosted_esl_file() {
2✔
2070
            let plugin = Plugin::new(GameId::Starfield, Path::new("Blank.esl.ghost"));
2✔
2071
            assert!(plugin.is_light_plugin());
2✔
2072
        }
2✔
2073

2074
        #[test]
2075
        fn is_light_plugin_should_be_true_for_a_plugin_with_the_light_flag_set() {
2✔
2076
            let mut plugin = Plugin::new(
2✔
2077
                GameId::Starfield,
2✔
2078
                Path::new("testing-plugins/Starfield/Data/Blank.small.esm"),
2✔
2079
            );
2080
            plugin.parse_file(ParseOptions::header_only()).unwrap();
2✔
2081

2082
            assert!(plugin.is_light_plugin());
2✔
2083
        }
2✔
2084

2085
        #[test]
2086
        fn is_medium_plugin_should_be_false_for_a_plugin_without_the_medium_flag_set() {
2✔
2087
            let plugin = Plugin::new(GameId::Starfield, Path::new("Blank.esp"));
2✔
2088
            assert!(!plugin.is_medium_plugin());
2✔
2089
            let plugin = Plugin::new(GameId::Starfield, Path::new("Blank.esm"));
2✔
2090
            assert!(!plugin.is_medium_plugin());
2✔
2091
            let plugin = Plugin::new(GameId::Starfield, Path::new("Blank.esl"));
2✔
2092
            assert!(!plugin.is_medium_plugin());
2✔
2093
        }
2✔
2094

2095
        #[test]
2096
        fn is_medium_plugin_should_be_true_for_a_plugin_with_the_medium_flag_set() {
2✔
2097
            let mut plugin = Plugin::new(
2✔
2098
                GameId::Starfield,
2✔
2099
                Path::new("testing-plugins/Starfield/Data/Blank.medium.esm"),
2✔
2100
            );
2101
            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
2✔
2102
            assert!(plugin.is_medium_plugin());
2✔
2103
        }
2✔
2104

2105
        #[test]
2106
        fn is_medium_plugin_should_be_false_for_a_plugin_with_the_medium_and_light_flags_set() {
2✔
2107
            let mut plugin = Plugin::new(
2✔
2108
                GameId::Starfield,
2✔
2109
                Path::new("testing-plugins/Starfield/Data/Blank.small.esm"),
2✔
2110
            );
2111
            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
2✔
2112
            assert!(!plugin.is_medium_plugin());
2✔
2113
        }
2✔
2114

2115
        #[test]
2116
        fn is_medium_plugin_should_be_false_for_an_esl_plugin_with_the_medium_flag_set() {
2✔
2117
            let tmp_dir = tempdir().unwrap();
2✔
2118
            let path = tmp_dir.path().join("Blank.esl");
2✔
2119
            copy(
2✔
2120
                Path::new("testing-plugins/Starfield/Data/Blank.medium.esm"),
2✔
2121
                &path,
2✔
2122
            )
2123
            .unwrap();
2✔
2124

2125
            let mut plugin = Plugin::new(GameId::Starfield, &path);
2✔
2126
            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
2✔
2127
            assert!(!plugin.is_medium_plugin());
2✔
2128
        }
2✔
2129

2130
        #[test]
2131
        fn is_update_plugin_should_be_true_for_a_plugin_with_the_update_flag_set_and_at_least_one_master_and_no_light_flag(
2✔
2132
        ) {
2✔
2133
            let mut plugin = Plugin::new(
2✔
2134
                GameId::Starfield,
2✔
2135
                Path::new("testing-plugins/Starfield/Data/Blank - Override.full.esm"),
2✔
2136
            );
2137
            plugin.parse_file(ParseOptions::header_only()).unwrap();
2✔
2138

2139
            assert!(plugin.is_update_plugin());
2✔
2140
        }
2✔
2141

2142
        #[test]
2143
        fn is_update_plugin_should_be_false_for_a_plugin_with_the_update_flag_set_and_no_masters() {
2✔
2144
            let mut plugin = Plugin::new(GameId::Starfield, Path::new("Blank.esm"));
2✔
2145
            let file_data = &[
2✔
2146
                0x54, 0x45, 0x53, 0x34, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00,
2✔
2147
                0x00, 0x00, 0xB2, 0x2E, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00,
2✔
2148
            ];
2✔
2149
            plugin.data.header_record =
2✔
2150
                Record::read(&mut Cursor::new(file_data), GameId::Starfield, b"TES4").unwrap();
2✔
2151

2152
            assert!(!plugin.is_update_plugin());
2✔
2153
        }
2✔
2154

2155
        #[test]
2156
        fn is_update_plugin_should_be_false_for_a_plugin_with_the_update_and_light_flags_set() {
2✔
2157
            let mut plugin = Plugin::new(
2✔
2158
                GameId::Starfield,
2✔
2159
                Path::new("testing-plugins/Starfield/Data/Blank - Override.small.esm"),
2✔
2160
            );
2161
            plugin.parse_file(ParseOptions::header_only()).unwrap();
2✔
2162

2163
            assert!(!plugin.is_update_plugin());
2✔
2164
        }
2✔
2165

2166
        #[test]
2167
        fn is_update_plugin_should_be_false_for_a_plugin_with_the_update_and_medium_flags_set() {
2✔
2168
            let mut plugin = Plugin::new(
2✔
2169
                GameId::Starfield,
2✔
2170
                Path::new("testing-plugins/Starfield/Data/Blank - Override.medium.esm"),
2✔
2171
            );
2172
            plugin.parse_file(ParseOptions::header_only()).unwrap();
2✔
2173

2174
            assert!(!plugin.is_update_plugin());
2✔
2175
        }
2✔
2176

2177
        #[test]
2178
        fn is_blueprint_plugin_should_be_false_for_a_plugin_without_the_blueprint_flag_set() {
2✔
2179
            let mut plugin = Plugin::new(
2✔
2180
                GameId::Starfield,
2✔
2181
                Path::new("testing-plugins/Starfield/Data/Blank.full.esm"),
2✔
2182
            );
2183
            plugin.parse_file(ParseOptions::header_only()).unwrap();
2✔
2184

2185
            assert!(!plugin.is_blueprint_plugin());
2✔
2186
        }
2✔
2187

2188
        #[test]
2189
        fn is_blueprint_plugin_should_be_false_for_a_plugin_with_the_blueprint_flag_set() {
2✔
2190
            let mut plugin = Plugin::new(
2✔
2191
                GameId::Starfield,
2✔
2192
                Path::new("testing-plugins/Starfield/Data/Blank.full.esm"),
2✔
2193
            );
2194

2195
            let mut bytes = read(plugin.path()).unwrap();
2✔
2196

2197
            assert_eq!(0, bytes[0x09]);
2✔
2198
            bytes[0x09] = 8;
2✔
2199

2200
            assert!(plugin
2✔
2201
                .parse_reader(Cursor::new(bytes), ParseOptions::header_only())
2✔
2202
                .is_ok());
2✔
2203

2204
            assert!(plugin.is_blueprint_plugin());
2✔
2205
        }
2✔
2206

2207
        #[test]
2208
        fn count_override_records_should_error_if_form_ids_are_unresolved() {
2✔
2209
            let mut plugin = Plugin::new(
2✔
2210
                GameId::Starfield,
2✔
2211
                Path::new("testing-plugins/Starfield/Data/Blank.full.esm"),
2✔
2212
            );
2213

2214
            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
2215

2216
            match plugin.count_override_records().unwrap_err() {
2✔
2217
                Error::UnresolvedRecordIds(path) => assert_eq!(plugin.path, path),
2✔
2218
                _ => panic!("Expected unresolved FormIDs error"),
×
2219
            }
2220
        }
2✔
2221

2222
        #[test]
2223
        fn count_override_records_should_succeed_if_form_ids_are_resolved() {
2✔
2224
            let mut plugin = Plugin::new(
2✔
2225
                GameId::Starfield,
2✔
2226
                Path::new("testing-plugins/Starfield/Data/Blank.full.esm"),
2✔
2227
            );
2228

2229
            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
2230

2231
            assert!(plugin.resolve_record_ids(&[]).is_ok());
2✔
2232

2233
            assert_eq!(0, plugin.count_override_records().unwrap());
2✔
2234
        }
2✔
2235

2236
        #[test]
2237
        fn overlaps_with_should_error_if_form_ids_in_self_are_unresolved() {
2✔
2238
            let mut plugin1 = Plugin::new(
2✔
2239
                GameId::Starfield,
2✔
2240
                Path::new("testing-plugins/Starfield/Data/Blank.full.esm"),
2✔
2241
            );
2242
            let mut plugin2 = Plugin::new(
2✔
2243
                GameId::Starfield,
2✔
2244
                Path::new("testing-plugins/Starfield/Data/Blank - Override.esp"),
2✔
2245
            );
2246
            let plugin1_metadata = PluginMetadata {
2✔
2247
                filename: plugin1.filename().unwrap(),
2✔
2248
                scale: plugin1.scale(),
2✔
2249
                record_ids: Box::new([]),
2✔
2250
            };
2✔
2251

2252
            assert!(plugin1.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
2253
            assert!(plugin2.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
2254
            assert!(plugin2.resolve_record_ids(&[plugin1_metadata]).is_ok());
2✔
2255

2256
            match plugin1.overlaps_with(&plugin2).unwrap_err() {
2✔
2257
                Error::UnresolvedRecordIds(path) => assert_eq!(plugin1.path, path),
2✔
2258
                _ => panic!("Expected unresolved FormIDs error"),
×
2259
            }
2260
        }
2✔
2261

2262
        #[test]
2263
        fn overlaps_with_should_error_if_form_ids_in_other_are_unresolved() {
2✔
2264
            let mut plugin1 = Plugin::new(
2✔
2265
                GameId::Starfield,
2✔
2266
                Path::new("testing-plugins/Starfield/Data/Blank.full.esm"),
2✔
2267
            );
2268
            let mut plugin2 = Plugin::new(
2✔
2269
                GameId::Starfield,
2✔
2270
                Path::new("testing-plugins/Starfield/Data/Blank - Override.esp"),
2✔
2271
            );
2272

2273
            assert!(plugin1.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
2274
            assert!(plugin2.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
2275
            assert!(plugin1.resolve_record_ids(&[]).is_ok());
2✔
2276

2277
            match plugin1.overlaps_with(&plugin2).unwrap_err() {
2✔
2278
                Error::UnresolvedRecordIds(path) => assert_eq!(plugin2.path, path),
2✔
2279
                _ => panic!("Expected unresolved FormIDs error"),
×
2280
            }
2281
        }
2✔
2282

2283
        #[test]
2284
        fn overlaps_with_should_succeed_if_form_ids_are_resolved() {
2✔
2285
            let mut plugin1 = Plugin::new(
2✔
2286
                GameId::Starfield,
2✔
2287
                Path::new("testing-plugins/Starfield/Data/Blank.full.esm"),
2✔
2288
            );
2289
            let mut plugin2 = Plugin::new(
2✔
2290
                GameId::Starfield,
2✔
2291
                Path::new("testing-plugins/Starfield/Data/Blank - Override.esp"),
2✔
2292
            );
2293
            let plugin1_metadata = PluginMetadata {
2✔
2294
                filename: plugin1.filename().unwrap(),
2✔
2295
                scale: plugin1.scale(),
2✔
2296
                record_ids: Box::new([]),
2✔
2297
            };
2✔
2298

2299
            assert!(plugin1.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
2300
            assert!(plugin2.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
2301
            assert!(plugin1.resolve_record_ids(&[]).is_ok());
2✔
2302
            assert!(plugin2.resolve_record_ids(&[plugin1_metadata]).is_ok());
2✔
2303

2304
            assert!(plugin1.overlaps_with(&plugin2).unwrap());
2✔
2305
        }
2✔
2306

2307
        #[test]
2308
        fn overlap_size_should_error_if_form_ids_in_self_are_unresolved() {
2✔
2309
            let mut plugin1 = Plugin::new(
2✔
2310
                GameId::Starfield,
2✔
2311
                Path::new("testing-plugins/Starfield/Data/Blank.full.esm"),
2✔
2312
            );
2313
            let mut plugin2 = Plugin::new(
2✔
2314
                GameId::Starfield,
2✔
2315
                Path::new("testing-plugins/Starfield/Data/Blank - Override.esp"),
2✔
2316
            );
2317
            let plugin1_metadata = PluginMetadata {
2✔
2318
                filename: plugin1.filename().unwrap(),
2✔
2319
                scale: plugin1.scale(),
2✔
2320
                record_ids: Box::new([]),
2✔
2321
            };
2✔
2322

2323
            assert!(plugin1.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
2324
            assert!(plugin2.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
2325
            assert!(plugin2.resolve_record_ids(&[plugin1_metadata]).is_ok());
2✔
2326

2327
            match plugin1.overlap_size(&[&plugin2]).unwrap_err() {
2✔
2328
                Error::UnresolvedRecordIds(path) => assert_eq!(plugin1.path, path),
2✔
2329
                _ => panic!("Expected unresolved FormIDs error"),
×
2330
            }
2331
        }
2✔
2332

2333
        #[test]
2334
        fn overlap_size_should_error_if_form_ids_in_other_are_unresolved() {
2✔
2335
            let mut plugin1 = Plugin::new(
2✔
2336
                GameId::Starfield,
2✔
2337
                Path::new("testing-plugins/Starfield/Data/Blank.full.esm"),
2✔
2338
            );
2339
            let mut plugin2 = Plugin::new(
2✔
2340
                GameId::Starfield,
2✔
2341
                Path::new("testing-plugins/Starfield/Data/Blank - Override.esp"),
2✔
2342
            );
2343

2344
            assert!(plugin1.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
2345
            assert!(plugin2.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
2346
            assert!(plugin1.resolve_record_ids(&[]).is_ok());
2✔
2347

2348
            match plugin1.overlap_size(&[&plugin2]).unwrap_err() {
2✔
2349
                Error::UnresolvedRecordIds(path) => assert_eq!(plugin2.path, path),
2✔
2350
                _ => panic!("Expected unresolved FormIDs error"),
×
2351
            }
2352
        }
2✔
2353

2354
        #[test]
2355
        fn overlap_size_should_succeed_if_form_ids_are_resolved() {
2✔
2356
            let mut plugin1 = Plugin::new(
2✔
2357
                GameId::Starfield,
2✔
2358
                Path::new("testing-plugins/Starfield/Data/Blank.full.esm"),
2✔
2359
            );
2360
            let mut plugin2 = Plugin::new(
2✔
2361
                GameId::Starfield,
2✔
2362
                Path::new("testing-plugins/Starfield/Data/Blank - Override.esp"),
2✔
2363
            );
2364
            let plugin1_metadata = PluginMetadata {
2✔
2365
                filename: plugin1.filename().unwrap(),
2✔
2366
                scale: plugin1.scale(),
2✔
2367
                record_ids: Box::new([]),
2✔
2368
            };
2✔
2369

2370
            assert!(plugin1.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
2371
            assert!(plugin2.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
2372
            assert!(plugin1.resolve_record_ids(&[]).is_ok());
2✔
2373
            assert!(plugin2.resolve_record_ids(&[plugin1_metadata]).is_ok());
2✔
2374

2375
            assert_eq!(1, plugin1.overlap_size(&[&plugin2]).unwrap());
2✔
2376
        }
2✔
2377

2378
        #[test]
2379
        fn valid_light_form_id_range_should_be_0_to_0xfff() {
2✔
2380
            let plugin = Plugin::new(GameId::Starfield, Path::new("Blank.esp"));
2✔
2381

2382
            let range = plugin.valid_light_form_id_range();
2✔
2383
            assert_eq!(&0, range.start());
2✔
2384
            assert_eq!(&0xFFF, range.end());
2✔
2385
        }
2✔
2386

2387
        #[test]
2388
        fn valid_medium_form_id_range_should_be_0_to_0xffff() {
2✔
2389
            let mut plugin = Plugin::new(
2✔
2390
                GameId::Starfield,
2✔
2391
                Path::new("testing-plugins/Starfield/Data/Blank.small.esm"),
2✔
2392
            );
2393
            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
2394

2395
            let range = plugin.valid_medium_form_id_range();
2✔
2396
            assert_eq!(&0, range.start());
2✔
2397
            assert_eq!(&0xFFFF, range.end());
2✔
2398
        }
2✔
2399

2400
        #[test]
2401
        fn is_valid_as_light_plugin_should_be_false_if_form_ids_are_unresolved() {
2✔
2402
            let mut plugin = Plugin::new(
2✔
2403
                GameId::Starfield,
2✔
2404
                Path::new("testing-plugins/Starfield/Data/Blank.small.esm"),
2✔
2405
            );
2406
            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
2407

2408
            match plugin.is_valid_as_light_plugin().unwrap_err() {
2✔
2409
                Error::UnresolvedRecordIds(path) => assert_eq!(plugin.path, path),
2✔
2410
                _ => panic!("Expected unresolved FormIDs error"),
×
2411
            }
2412
        }
2✔
2413

2414
        #[test]
2415
        fn is_valid_as_light_plugin_should_be_true_if_the_plugin_has_no_form_ids_outside_the_valid_range(
2✔
2416
        ) {
2✔
2417
            let mut plugin = Plugin::new(
2✔
2418
                GameId::Starfield,
2✔
2419
                Path::new("testing-plugins/Starfield/Data/Blank.full.esm"),
2✔
2420
            );
2421
            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
2422
            assert!(plugin.resolve_record_ids(&[]).is_ok());
2✔
2423

2424
            assert!(plugin.is_valid_as_light_plugin().unwrap());
2✔
2425
        }
2✔
2426

2427
        #[test]
2428
        fn is_valid_as_medium_plugin_should_be_false_if_form_ids_are_unresolved() {
2✔
2429
            let mut plugin = Plugin::new(
2✔
2430
                GameId::Starfield,
2✔
2431
                Path::new("testing-plugins/Starfield/Data/Blank.medium.esm"),
2✔
2432
            );
2433
            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
2434

2435
            match plugin.is_valid_as_medium_plugin().unwrap_err() {
2✔
2436
                Error::UnresolvedRecordIds(path) => assert_eq!(plugin.path, path),
2✔
2437
                _ => panic!("Expected unresolved FormIDs error"),
×
2438
            }
2439
        }
2✔
2440

2441
        #[test]
2442
        fn is_valid_as_medium_plugin_should_be_true_if_the_plugin_has_no_form_ids_outside_the_valid_range(
2✔
2443
        ) {
2✔
2444
            let mut plugin = Plugin::new(
2✔
2445
                GameId::Starfield,
2✔
2446
                Path::new("testing-plugins/Starfield/Data/Blank.medium.esm"),
2✔
2447
            );
2448
            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
2449
            assert!(plugin.resolve_record_ids(&[]).is_ok());
2✔
2450

2451
            assert!(plugin.is_valid_as_medium_plugin().unwrap());
2✔
2452
        }
2✔
2453

2454
        #[test]
2455
        fn is_valid_as_update_plugin_should_be_false_if_form_ids_are_unresolved() {
2✔
2456
            let mut plugin = Plugin::new(
2✔
2457
                GameId::Starfield,
2✔
2458
                Path::new("testing-plugins/Starfield/Data/Blank - Override.esp"),
2✔
2459
            );
2460
            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
2461

2462
            match plugin.is_valid_as_update_plugin().unwrap_err() {
2✔
2463
                Error::UnresolvedRecordIds(path) => assert_eq!(plugin.path, path),
2✔
2464
                _ => panic!("Expected unresolved FormIDs error"),
×
2465
            }
2466
        }
2✔
2467

2468
        #[test]
2469
        fn is_valid_as_update_plugin_should_be_true_if_the_plugin_has_no_new_records() {
2✔
2470
            let master_metadata = PluginMetadata {
2✔
2471
                filename: "Blank.full.esm".to_owned(),
2✔
2472
                scale: PluginScale::Full,
2✔
2473
                record_ids: Box::new([]),
2✔
2474
            };
2✔
2475
            let mut plugin = Plugin::new(
2✔
2476
                GameId::Starfield,
2✔
2477
                Path::new("testing-plugins/Starfield/Data/Blank - Override.esp"),
2✔
2478
            );
2479
            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
2480
            assert!(plugin.resolve_record_ids(&[master_metadata]).is_ok());
2✔
2481

2482
            assert!(plugin.is_valid_as_update_plugin().unwrap());
2✔
2483
        }
2✔
2484

2485
        #[test]
2486
        fn is_valid_as_update_plugin_should_be_false_if_the_plugin_has_new_records() {
2✔
2487
            let master_metadata = PluginMetadata {
2✔
2488
                filename: "Blank.full.esm".to_owned(),
2✔
2489
                scale: PluginScale::Full,
2✔
2490
                record_ids: Box::new([]),
2✔
2491
            };
2✔
2492
            let mut plugin = Plugin::new(
2✔
2493
                GameId::Starfield,
2✔
2494
                Path::new("testing-plugins/Starfield/Data/Blank.full.esm"),
2✔
2495
            );
2496
            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
2✔
2497
            assert!(plugin.resolve_record_ids(&[master_metadata]).is_ok());
2✔
2498

2499
            assert!(!plugin.is_valid_as_update_plugin().unwrap());
2✔
2500
        }
2✔
2501

2502
        #[test]
2503
        fn plugins_metadata_should_return_plugin_names_and_scales() {
2✔
2504
            let mut plugin1 = Plugin::new(
2✔
2505
                GameId::Starfield,
2✔
2506
                Path::new("testing-plugins/Starfield/Data/Blank.full.esm"),
2✔
2507
            );
2508
            let mut plugin2 = Plugin::new(
2✔
2509
                GameId::Starfield,
2✔
2510
                Path::new("testing-plugins/Starfield/Data/Blank.medium.esm"),
2✔
2511
            );
2512
            let mut plugin3 = Plugin::new(
2✔
2513
                GameId::Starfield,
2✔
2514
                Path::new("testing-plugins/Starfield/Data/Blank.small.esm"),
2✔
2515
            );
2516
            assert!(plugin1.parse_file(ParseOptions::header_only()).is_ok());
2✔
2517
            assert!(plugin2.parse_file(ParseOptions::header_only()).is_ok());
2✔
2518
            assert!(plugin3.parse_file(ParseOptions::header_only()).is_ok());
2✔
2519

2520
            let metadata = plugins_metadata(&[&plugin1, &plugin2, &plugin3]).unwrap();
2✔
2521

2522
            assert_eq!(
2✔
2523
                vec![
2✔
2524
                    PluginMetadata {
2✔
2525
                        filename: "Blank.full.esm".to_owned(),
2✔
2526
                        scale: PluginScale::Full,
2✔
2527
                        record_ids: Box::new([]),
2✔
2528
                    },
2✔
2529
                    PluginMetadata {
2✔
2530
                        filename: "Blank.medium.esm".to_owned(),
2✔
2531
                        scale: PluginScale::Medium,
2✔
2532
                        record_ids: Box::new([]),
2✔
2533
                    },
2✔
2534
                    PluginMetadata {
2✔
2535
                        filename: "Blank.small.esm".to_owned(),
2✔
2536
                        scale: PluginScale::Small,
2✔
2537
                        record_ids: Box::new([]),
2✔
2538
                    },
2✔
2539
                ],
2540
                metadata
2541
            );
2542
        }
2✔
2543

2544
        #[test]
2545
        fn hashed_parent_should_use_full_object_index_mask_for_games_other_than_starfield() {
2✔
2546
            let metadata = PluginMetadata {
2✔
2547
                filename: "a".to_owned(),
2✔
2548
                scale: PluginScale::Full,
2✔
2549
                record_ids: Box::new([]),
2✔
2550
            };
2✔
2551

2552
            let plugin = hashed_parent(GameId::SkyrimSE, &metadata);
2✔
2553

2554
            assert_eq!(u32::from(ObjectIndexMask::Full), plugin.mod_index_mask);
2✔
2555
            assert_eq!(u32::from(ObjectIndexMask::Full), plugin.object_index_mask);
2✔
2556

2557
            let metadata = PluginMetadata {
2✔
2558
                filename: "a".to_owned(),
2✔
2559
                scale: PluginScale::Medium,
2✔
2560
                record_ids: Box::new([]),
2✔
2561
            };
2✔
2562

2563
            let plugin = hashed_parent(GameId::SkyrimSE, &metadata);
2✔
2564

2565
            assert_eq!(u32::from(ObjectIndexMask::Full), plugin.mod_index_mask);
2✔
2566
            assert_eq!(u32::from(ObjectIndexMask::Full), plugin.object_index_mask);
2✔
2567

2568
            let metadata = PluginMetadata {
2✔
2569
                filename: "a".to_owned(),
2✔
2570
                scale: PluginScale::Small,
2✔
2571
                record_ids: Box::new([]),
2✔
2572
            };
2✔
2573

2574
            let plugin = hashed_parent(GameId::SkyrimSE, &metadata);
2✔
2575

2576
            assert_eq!(u32::from(ObjectIndexMask::Full), plugin.mod_index_mask);
2✔
2577
            assert_eq!(u32::from(ObjectIndexMask::Full), plugin.object_index_mask);
2✔
2578
        }
2✔
2579

2580
        #[test]
2581
        fn hashed_parent_should_use_object_index_mask_matching_the_plugin_scale_for_starfield() {
2✔
2582
            let metadata = PluginMetadata {
2✔
2583
                filename: "a".to_owned(),
2✔
2584
                scale: PluginScale::Full,
2✔
2585
                record_ids: Box::new([]),
2✔
2586
            };
2✔
2587

2588
            let plugin = hashed_parent(GameId::Starfield, &metadata);
2✔
2589

2590
            assert_eq!(u32::from(ObjectIndexMask::Full), plugin.mod_index_mask);
2✔
2591
            assert_eq!(u32::from(ObjectIndexMask::Full), plugin.object_index_mask);
2✔
2592

2593
            let metadata = PluginMetadata {
2✔
2594
                filename: "a".to_owned(),
2✔
2595
                scale: PluginScale::Medium,
2✔
2596
                record_ids: Box::new([]),
2✔
2597
            };
2✔
2598

2599
            let plugin = hashed_parent(GameId::Starfield, &metadata);
2✔
2600

2601
            assert_eq!(u32::from(ObjectIndexMask::Medium), plugin.mod_index_mask);
2✔
2602
            assert_eq!(u32::from(ObjectIndexMask::Medium), plugin.object_index_mask);
2✔
2603

2604
            let metadata = PluginMetadata {
2✔
2605
                filename: "a".to_owned(),
2✔
2606
                scale: PluginScale::Small,
2✔
2607
                record_ids: Box::new([]),
2✔
2608
            };
2✔
2609

2610
            let plugin = hashed_parent(GameId::Starfield, &metadata);
2✔
2611

2612
            assert_eq!(u32::from(ObjectIndexMask::Small), plugin.mod_index_mask);
2✔
2613
            assert_eq!(u32::from(ObjectIndexMask::Small), plugin.object_index_mask);
2✔
2614
        }
2✔
2615

2616
        #[test]
2617
        fn hashed_masters_should_use_vec_index_as_mod_index() {
2✔
2618
            let masters = &["a".to_owned(), "b".to_owned(), "c".to_owned()];
2✔
2619
            let hashed_masters = hashed_masters(masters);
2✔
2620

2621
            assert_eq!(
2✔
2622
                vec![
2✔
2623
                    SourcePlugin::master("a", 0, ObjectIndexMask::Full),
2✔
2624
                    SourcePlugin::master("b", 0x0100_0000, ObjectIndexMask::Full),
2✔
2625
                    SourcePlugin::master("c", 0x0200_0000, ObjectIndexMask::Full),
2✔
2626
                ],
2627
                hashed_masters
2628
            );
2629
        }
2✔
2630

2631
        #[test]
2632
        fn hashed_masters_for_starfield_should_error_if_it_cannot_find_a_masters_metadata() {
2✔
2633
            let masters = &["a".to_owned(), "b".to_owned(), "c".to_owned()];
2✔
2634
            let metadata = &[
2✔
2635
                PluginMetadata {
2✔
2636
                    filename: masters[0].clone(),
2✔
2637
                    scale: PluginScale::Full,
2✔
2638
                    record_ids: Box::new([]),
2✔
2639
                },
2✔
2640
                PluginMetadata {
2✔
2641
                    filename: masters[1].clone(),
2✔
2642
                    scale: PluginScale::Full,
2✔
2643
                    record_ids: Box::new([]),
2✔
2644
                },
2✔
2645
            ];
2✔
2646

2647
            match hashed_masters_for_starfield(masters, metadata).unwrap_err() {
2✔
2648
                Error::PluginMetadataNotFound(master) => assert_eq!(masters[2], master),
2✔
2649
                _ => panic!("Expected plugin metadata not found error"),
×
2650
            }
2651
        }
2✔
2652

2653
        #[test]
2654
        fn hashed_masters_for_starfield_should_match_names_to_metadata_case_insensitively() {
2✔
2655
            let masters = &["a".to_owned()];
2✔
2656
            let metadata = &[PluginMetadata {
2✔
2657
                filename: "A".to_owned(),
2✔
2658
                scale: PluginScale::Full,
2✔
2659
                record_ids: Box::new([]),
2✔
2660
            }];
2✔
2661

2662
            let hashed_masters = hashed_masters_for_starfield(masters, metadata).unwrap();
2✔
2663

2664
            assert_eq!(
2✔
2665
                vec![SourcePlugin::master(&masters[0], 0, ObjectIndexMask::Full),],
2✔
2666
                hashed_masters
2667
            );
2668
        }
2✔
2669

2670
        #[test]
2671
        fn hashed_masters_for_starfield_should_count_mod_indexes_separately_for_different_plugin_scales(
2✔
2672
        ) {
2✔
2673
            let masters: Vec<_> = (0u8..7u8).map(|i| i.to_string()).collect();
14✔
2674
            let metadata = &[
2✔
2675
                PluginMetadata {
2✔
2676
                    filename: masters[0].clone(),
2✔
2677
                    scale: PluginScale::Full,
2✔
2678
                    record_ids: Box::new([]),
2✔
2679
                },
2✔
2680
                PluginMetadata {
2✔
2681
                    filename: masters[1].clone(),
2✔
2682
                    scale: PluginScale::Medium,
2✔
2683
                    record_ids: Box::new([]),
2✔
2684
                },
2✔
2685
                PluginMetadata {
2✔
2686
                    filename: masters[2].clone(),
2✔
2687
                    scale: PluginScale::Small,
2✔
2688
                    record_ids: Box::new([]),
2✔
2689
                },
2✔
2690
                PluginMetadata {
2✔
2691
                    filename: masters[3].clone(),
2✔
2692
                    scale: PluginScale::Medium,
2✔
2693
                    record_ids: Box::new([]),
2✔
2694
                },
2✔
2695
                PluginMetadata {
2✔
2696
                    filename: masters[4].clone(),
2✔
2697
                    scale: PluginScale::Full,
2✔
2698
                    record_ids: Box::new([]),
2✔
2699
                },
2✔
2700
                PluginMetadata {
2✔
2701
                    filename: masters[5].clone(),
2✔
2702
                    scale: PluginScale::Small,
2✔
2703
                    record_ids: Box::new([]),
2✔
2704
                },
2✔
2705
                PluginMetadata {
2✔
2706
                    filename: masters[6].clone(),
2✔
2707
                    scale: PluginScale::Small,
2✔
2708
                    record_ids: Box::new([]),
2✔
2709
                },
2✔
2710
            ];
2✔
2711

2712
            let hashed_masters = hashed_masters_for_starfield(&masters, metadata).unwrap();
2✔
2713

2714
            assert_eq!(
2✔
2715
                vec![
2✔
2716
                    SourcePlugin::master(&masters[0], 0, ObjectIndexMask::Full),
2✔
2717
                    SourcePlugin::master(&masters[1], 0xFD00_0000, ObjectIndexMask::Medium),
2✔
2718
                    SourcePlugin::master(&masters[2], 0xFE00_0000, ObjectIndexMask::Small),
2✔
2719
                    SourcePlugin::master(&masters[3], 0xFD01_0000, ObjectIndexMask::Medium),
2✔
2720
                    SourcePlugin::master(&masters[4], 0x0100_0000, ObjectIndexMask::Full),
2✔
2721
                    SourcePlugin::master(&masters[5], 0xFE00_1000, ObjectIndexMask::Small),
2✔
2722
                    SourcePlugin::master(&masters[6], 0xFE00_2000, ObjectIndexMask::Small),
2✔
2723
                ],
2724
                hashed_masters
2725
            );
2726
        }
2✔
2727
    }
2728

2729
    fn write_invalid_plugin(path: &Path) {
4✔
2730
        use std::io::Write;
2731
        let mut file = File::create(path).unwrap();
4✔
2732
        let bytes = [0; MAX_RECORD_HEADER_LENGTH];
4✔
2733
        file.write_all(&bytes).unwrap();
4✔
2734
    }
4✔
2735

2736
    #[test]
2737
    fn parse_file_should_error_if_plugin_does_not_exist() {
2✔
2738
        let mut plugin = Plugin::new(GameId::Skyrim, Path::new("Blank.esm"));
2✔
2739

2740
        assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_err());
2✔
2741
    }
2✔
2742

2743
    #[test]
2744
    fn parse_file_should_error_if_plugin_is_not_valid() {
2✔
2745
        let mut plugin = Plugin::new(
2✔
2746
            GameId::Oblivion,
2✔
2747
            Path::new("testing-plugins/Oblivion/Data/Blank.bsa"),
2✔
2748
        );
2749

2750
        let result = plugin.parse_file(ParseOptions::whole_plugin());
2✔
2751
        assert!(result.is_err());
2✔
2752
        assert_eq!(
2✔
2753
             "An error was encountered while parsing the plugin content \"BSA\\x00g\\x00\\x00\\x00$\\x00\\x00\\x00\\x07\\x07\\x00\\x00\": Expected record type \"TES4\"",
2754
             result.unwrap_err().to_string()
2✔
2755
         );
2756
    }
2✔
2757

2758
    #[test]
2759
    fn parse_file_header_only_should_fail_for_a_non_plugin_file() {
2✔
2760
        let tmp_dir = tempdir().unwrap();
2✔
2761

2762
        let path = tmp_dir.path().join("Invalid.esm");
2✔
2763
        write_invalid_plugin(&path);
2✔
2764

2765
        let mut plugin = Plugin::new(GameId::Skyrim, &path);
2✔
2766

2767
        let result = plugin.parse_file(ParseOptions::header_only());
2✔
2768
        assert!(result.is_err());
2✔
2769
        assert_eq!("An error was encountered while parsing the plugin content \"\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\": Expected record type \"TES4\"", result.unwrap_err().to_string());
2✔
2770
    }
2✔
2771

2772
    #[test]
2773
    fn parse_file_should_fail_for_a_non_plugin_file() {
2✔
2774
        let tmp_dir = tempdir().unwrap();
2✔
2775

2776
        let path = tmp_dir.path().join("Invalid.esm");
2✔
2777
        write_invalid_plugin(&path);
2✔
2778

2779
        let mut plugin = Plugin::new(GameId::Skyrim, &path);
2✔
2780

2781
        let result = plugin.parse_file(ParseOptions::whole_plugin());
2✔
2782
        assert!(result.is_err());
2✔
2783
        assert_eq!("An error was encountered while parsing the plugin content \"\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\": Expected record type \"TES4\"", result.unwrap_err().to_string());
2✔
2784
    }
2✔
2785

2786
    #[test]
2787
    fn is_valid_should_return_true_for_a_valid_plugin() {
2✔
2788
        let is_valid = Plugin::is_valid(
2✔
2789
            GameId::Skyrim,
2✔
2790
            Path::new("testing-plugins/Skyrim/Data/Blank.esm"),
2✔
2791
            ParseOptions::header_only(),
2✔
2792
        );
2793

2794
        assert!(is_valid);
2✔
2795
    }
2✔
2796

2797
    #[test]
2798
    fn is_valid_should_return_false_for_an_invalid_plugin() {
2✔
2799
        let is_valid = Plugin::is_valid(
2✔
2800
            GameId::Skyrim,
2✔
2801
            Path::new("testing-plugins/Oblivion/Data/Blank.bsa"),
2✔
2802
            ParseOptions::header_only(),
2✔
2803
        );
2804

2805
        assert!(!is_valid);
2✔
2806
    }
2✔
2807

2808
    #[test]
2809
    fn path_should_return_the_full_plugin_path() {
2✔
2810
        let path = Path::new("Data/Blank.esm");
2✔
2811
        let plugin = Plugin::new(GameId::Skyrim, path);
2✔
2812

2813
        assert_eq!(path, plugin.path());
2✔
2814
    }
2✔
2815

2816
    #[test]
2817
    fn filename_should_return_filename_in_given_path() {
2✔
2818
        let plugin = Plugin::new(GameId::Skyrim, Path::new("Blank.esm"));
2✔
2819

2820
        assert_eq!("Blank.esm", plugin.filename().unwrap());
2✔
2821

2822
        let plugin = Plugin::new(GameId::Skyrim, Path::new("Blank.esp"));
2✔
2823

2824
        assert_eq!("Blank.esp", plugin.filename().unwrap());
2✔
2825
    }
2✔
2826

2827
    #[test]
2828
    fn filename_should_not_trim_dot_ghost_extension() {
2✔
2829
        let plugin = Plugin::new(GameId::Skyrim, Path::new("Blank.esp.ghost"));
2✔
2830

2831
        assert_eq!("Blank.esp.ghost", plugin.filename().unwrap());
2✔
2832
    }
2✔
2833

2834
    #[test]
2835
    fn masters_should_be_empty_for_a_plugin_with_no_masters() {
2✔
2836
        let mut plugin = Plugin::new(
2✔
2837
            GameId::Skyrim,
2✔
2838
            Path::new("testing-plugins/Skyrim/Data/Blank.esm"),
2✔
2839
        );
2840

2841
        assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
2✔
2842
        assert_eq!(0, plugin.masters().unwrap().len());
2✔
2843
    }
2✔
2844

2845
    #[test]
2846
    fn masters_should_not_be_empty_for_a_plugin_with_one_or_more_masters() {
2✔
2847
        let mut plugin = Plugin::new(
2✔
2848
            GameId::Skyrim,
2✔
2849
            Path::new(
2✔
2850
                "testing-plugins/Skyrim/Data/Blank - \
2✔
2851
                  Master Dependent.esm",
2✔
2852
            ),
2✔
2853
        );
2854

2855
        assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
2✔
2856

2857
        let masters = plugin.masters().unwrap();
2✔
2858
        assert_eq!(1, masters.len());
2✔
2859
        assert_eq!("Blank.esm", masters[0]);
2✔
2860
    }
2✔
2861

2862
    #[test]
2863
    fn masters_should_only_read_up_to_the_first_null_for_each_master() {
2✔
2864
        let mut plugin = Plugin::new(
2✔
2865
            GameId::Skyrim,
2✔
2866
            Path::new("testing-plugins/Skyrim/Data/Blank - Master Dependent.esm"),
2✔
2867
        );
2868

2869
        let mut bytes = read(plugin.path()).unwrap();
2✔
2870

2871
        assert_eq!(0x2E, bytes[0x43]);
2✔
2872
        bytes[0x43] = 0;
2✔
2873

2874
        assert!(plugin
2✔
2875
            .parse_reader(Cursor::new(bytes), ParseOptions::whole_plugin())
2✔
2876
            .is_ok());
2✔
2877

2878
        assert_eq!("Blank", plugin.masters().unwrap()[0]);
2✔
2879
    }
2✔
2880

2881
    #[test]
2882
    fn description_should_error_for_a_plugin_header_subrecord_that_is_too_small() {
2✔
2883
        let mut plugin = Plugin::new(
2✔
2884
            GameId::Morrowind,
2✔
2885
            Path::new("testing-plugins/Morrowind/Data Files/Blank.esm"),
2✔
2886
        );
2887

2888
        let mut data = read_test_data("Morrowind/Data Files/Blank.esm", ..0x20);
2✔
2889
        data[0x04] = 16;
2✔
2890
        data[0x05] = 0;
2✔
2891
        data[0x14] = 8;
2✔
2892
        data[0x15] = 0;
2✔
2893

2894
        assert!(plugin
2✔
2895
            .parse_reader(Cursor::new(data), ParseOptions::header_only())
2✔
2896
            .is_ok());
2✔
2897

2898
        let result = plugin.description();
2✔
2899
        assert!(result.is_err());
2✔
2900
        assert_eq!("An error was encountered while parsing the plugin content \"\\x9a\\x99\\x99?\\x01\\x00\\x00\\x00\": Subrecord data field too short, expected at least 40 bytes", result.unwrap_err().to_string());
2✔
2901
    }
2✔
2902

2903
    #[test]
2904
    fn header_version_should_be_none_for_a_plugin_header_hedr_subrecord_that_is_too_small() {
2✔
2905
        let mut plugin = Plugin::new(
2✔
2906
            GameId::Morrowind,
2✔
2907
            Path::new("testing-plugins/Morrowind/Data Files/Blank.esm"),
2✔
2908
        );
2909

2910
        let mut data = read_test_data("Morrowind/Data Files/Blank.esm", ..0x1B);
2✔
2911
        data[0x04] = 11;
2✔
2912
        data[0x05] = 0;
2✔
2913
        data[0x14] = 3;
2✔
2914
        data[0x15] = 0;
2✔
2915

2916
        assert!(plugin
2✔
2917
            .parse_reader(Cursor::new(data), ParseOptions::header_only())
2✔
2918
            .is_ok());
2✔
2919
        assert!(plugin.header_version().is_none());
2✔
2920
    }
2✔
2921

2922
    #[test]
2923
    fn record_and_group_count_should_be_none_for_a_plugin_hedr_subrecord_that_is_too_small() {
2✔
2924
        let mut plugin = Plugin::new(
2✔
2925
            GameId::Morrowind,
2✔
2926
            Path::new("testing-plugins/Morrowind/Data Files/Blank.esm"),
2✔
2927
        );
2928

2929
        let mut data = read_test_data("Morrowind/Data Files/Blank.esm", ..0x140);
2✔
2930
        data[0x04] = 0x30;
2✔
2931
        data[0x14] = 0x28;
2✔
2932

2933
        assert!(plugin
2✔
2934
            .parse_reader(Cursor::new(data), ParseOptions::header_only())
2✔
2935
            .is_ok());
2✔
2936
        assert!(plugin.record_and_group_count().is_none());
2✔
2937
    }
2✔
2938

2939
    #[test]
2940
    fn resolve_form_ids_should_use_plugin_names_case_insensitively() {
2✔
2941
        let raw_form_ids = vec![0x0000_0001, 0x0100_0002];
2✔
2942

2943
        let masters = vec!["t\u{e9}st.esm".to_owned()];
2✔
2944
        let form_ids = resolve_form_ids(
2✔
2945
            GameId::SkyrimSE,
2✔
2946
            &raw_form_ids,
2✔
2947
            &PluginMetadata {
2✔
2948
                filename: "Bl\u{e0}\u{f1}k.esp".to_owned(),
2✔
2949
                scale: PluginScale::Full,
2✔
2950
                record_ids: Box::new([]),
2✔
2951
            },
2✔
2952
            &masters,
2✔
2953
            &[],
2✔
2954
        )
2955
        .unwrap();
2✔
2956

2957
        let other_masters = vec!["T\u{c9}ST.ESM".to_owned()];
2✔
2958
        let other_form_ids = resolve_form_ids(
2✔
2959
            GameId::SkyrimSE,
2✔
2960
            &raw_form_ids,
2✔
2961
            &PluginMetadata {
2✔
2962
                filename: "BL\u{c0}\u{d1}K.ESP".to_owned(),
2✔
2963
                scale: PluginScale::Full,
2✔
2964
                record_ids: Box::new([]),
2✔
2965
            },
2✔
2966
            &other_masters,
2✔
2967
            &[],
2✔
2968
        )
2969
        .unwrap();
2✔
2970

2971
        assert_eq!(form_ids, other_form_ids);
2✔
2972
    }
2✔
2973
}
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