esplugin/
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
20use std::collections::HashSet;
21use std::ffi::OsStr;
22use std::fs::File;
23use std::io::{BufRead, BufReader, Seek};
24use std::ops::RangeInclusive;
25use std::path::{Path, PathBuf};
26
27use encoding_rs::WINDOWS_1252;
28
29use crate::error::{Error, ParsingErrorKind};
30use crate::game_id::GameId;
31use crate::group::Group;
32use crate::record::{Record, MAX_RECORD_HEADER_LENGTH};
33use crate::record_id::{NamespacedId, ObjectIndexMask, RecordId, ResolvedRecordId, SourcePlugin};
34
35#[derive(Copy, Clone, PartialEq, Eq)]
36enum FileExtension {
37    Esm,
38    Esl,
39    Ghost,
40    Unrecognised,
41}
42
43impl From<&OsStr> for FileExtension {
44    fn from(value: &OsStr) -> Self {
45        if value.eq_ignore_ascii_case("esm") {
46            FileExtension::Esm
47        } else if value.eq_ignore_ascii_case("esl") {
48            FileExtension::Esl
49        } else if value.eq_ignore_ascii_case("ghost") {
50            FileExtension::Ghost
51        } else {
52            FileExtension::Unrecognised
53        }
54    }
55}
56
57#[derive(Clone, Default, PartialEq, Eq, Debug, Hash)]
58enum RecordIds {
59    #[default]
60    None,
61    FormIds(Vec<u32>),
62    NamespacedIds(Vec<NamespacedId>),
63    Resolved(Vec<ResolvedRecordId>),
64}
65
66impl From<Vec<NamespacedId>> for RecordIds {
67    fn from(record_ids: Vec<NamespacedId>) -> RecordIds {
68        RecordIds::NamespacedIds(record_ids)
69    }
70}
71
72impl From<Vec<u32>> for RecordIds {
73    fn from(form_ids: Vec<u32>) -> RecordIds {
74        RecordIds::FormIds(form_ids)
75    }
76}
77
78#[derive(Clone, PartialEq, Eq, Debug, Hash, Default)]
79struct PluginData {
80    header_record: Record,
81    record_ids: RecordIds,
82}
83
84#[derive(Copy, Clone, PartialEq, Eq, Debug, Hash)]
85enum PluginScale {
86    Full,
87    Medium,
88    Small,
89}
90
91#[derive(Clone, PartialEq, Eq, Debug, Hash)]
92pub struct Plugin {
93    game_id: GameId,
94    path: PathBuf,
95    data: PluginData,
96}
97
98#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
99pub struct ParseOptions {
100    header_only: bool,
101}
102
103impl ParseOptions {
104    pub fn header_only() -> Self {
105        Self { header_only: true }
106    }
107
108    pub fn whole_plugin() -> Self {
109        Self { header_only: false }
110    }
111}
112
113impl Plugin {
114    pub fn new(game_id: GameId, filepath: &Path) -> Plugin {
115        Plugin {
116            game_id,
117            path: filepath.to_path_buf(),
118            data: PluginData::default(),
119        }
120    }
121
122    pub fn parse_reader<R: std::io::Read + std::io::Seek>(
123        &mut self,
124        reader: R,
125        options: ParseOptions,
126    ) -> Result<(), Error> {
127        let mut reader = BufReader::new(reader);
128
129        self.data = read_plugin(&mut reader, self.game_id, options, self.header_type())?;
130
131        if self.game_id != GameId::Morrowind && self.game_id != GameId::Starfield {
132            self.resolve_record_ids(&[])?;
133        }
134
135        Ok(())
136    }
137
138    pub fn parse_file(&mut self, options: ParseOptions) -> Result<(), Error> {
139        let file = File::open(&self.path)?;
140
141        self.parse_reader(file, options)
142    }
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> {
146        match &self.data.record_ids {
147            RecordIds::FormIds(form_ids) => {
148                let filename = self
149                    .filename()
150                    .ok_or_else(|| Error::NoFilename(self.path.clone()))?;
151                let parent_metadata = PluginMetadata {
152                    filename,
153                    scale: self.scale(),
154                    record_ids: Box::new([]),
155                };
156                let masters = self.masters()?;
157
158                let form_ids = resolve_form_ids(
159                    self.game_id,
160                    form_ids,
161                    &parent_metadata,
162                    &masters,
163                    plugins_metadata,
164                )?;
165
166                self.data.record_ids = RecordIds::Resolved(form_ids);
167            }
168            RecordIds::NamespacedIds(namespaced_ids) => {
169                let masters = self.masters()?;
170
171                let record_ids =
172                    resolve_namespaced_ids(namespaced_ids, &masters, plugins_metadata)?;
173
174                self.data.record_ids = RecordIds::Resolved(record_ids);
175            }
176            RecordIds::None | RecordIds::Resolved(_) => {
177                // Do nothing.
178            }
179        }
180
181        Ok(())
182    }
183
184    pub fn game_id(&self) -> GameId {
185        self.game_id
186    }
187
188    pub fn path(&self) -> &Path {
189        &self.path
190    }
191
192    pub fn filename(&self) -> Option<String> {
193        self.path
194            .file_name()
195            .and_then(std::ffi::OsStr::to_str)
196            .map(std::string::ToString::to_string)
197    }
198
199    pub fn masters(&self) -> Result<Vec<String>, Error> {
200        masters(&self.data.header_record)
201    }
202
203    fn file_extension(&self) -> FileExtension {
204        if let Some(p) = self.path.extension() {
205            match FileExtension::from(p) {
206                FileExtension::Ghost => self
207                    .path
208                    .file_stem()
209                    .map(Path::new)
210                    .and_then(Path::extension)
211                    .map_or(FileExtension::Unrecognised, FileExtension::from),
212                e => e,
213            }
214        } else {
215            FileExtension::Unrecognised
216        }
217    }
218
219    pub fn is_master_file(&self) -> bool {
220        match self.game_id {
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()
225                    || matches!(
226                        self.file_extension(),
227                        FileExtension::Esm | FileExtension::Esl
228                    )
229            }
230            _ => self.is_master_flag_set(),
231        }
232    }
233
234    fn scale(&self) -> PluginScale {
235        if self.is_light_plugin() {
236            PluginScale::Small
237        } else if self.is_medium_flag_set() {
238            PluginScale::Medium
239        } else {
240            PluginScale::Full
241        }
242    }
243
244    pub fn is_light_plugin(&self) -> bool {
245        if self.game_id.supports_light_plugins() {
246            if self.game_id == GameId::Starfield {
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()
250                    || (!self.is_update_flag_set() && self.file_extension() == FileExtension::Esl)
251            } else {
252                self.is_light_flag_set() || self.file_extension() == FileExtension::Esl
253            }
254        } else {
255            false
256        }
257    }
258
259    pub fn is_medium_plugin(&self) -> bool {
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()
262    }
263
264    pub fn is_update_plugin(&self) -> bool {
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()
268            && !self.is_light_flag_set()
269            && !self.is_medium_flag_set()
270            && self.masters().map(|m| !m.is_empty()).unwrap_or(false)
271    }
272
273    pub fn is_blueprint_plugin(&self) -> bool {
274        match self.game_id {
275            GameId::Starfield => self.data.header_record.header().flags() & 0x800 != 0,
276            _ => false,
277        }
278    }
279
280    pub fn is_valid(game_id: GameId, filepath: &Path, options: ParseOptions) -> bool {
281        let mut plugin = Plugin::new(game_id, filepath);
282
283        plugin.parse_file(options).is_ok()
284    }
285
286    pub fn description(&self) -> Result<Option<String>, Error> {
287        let (target_subrecord_type, description_offset) = match self.game_id {
288            GameId::Morrowind => (b"HEDR", 40),
289            _ => (b"SNAM", 0),
290        };
291
292        for subrecord in self.data.header_record.subrecords() {
293            if subrecord.subrecord_type() == target_subrecord_type {
294                let data = subrecord
295                    .data()
296                    .get(description_offset..)
297                    .map(until_first_null)
298                    .ok_or_else(|| {
299                        Error::ParsingError(
300                            subrecord.data().into(),
301                            ParsingErrorKind::SubrecordDataTooShort(description_offset),
302                        )
303                    })?;
304
305                return WINDOWS_1252
306                    .decode_without_bom_handling_and_without_replacement(data)
307                    .map(|s| Some(s.to_string()))
308                    .ok_or(Error::DecodeError(data.into()));
309            }
310        }
311
312        Ok(None)
313    }
314
315    pub fn header_version(&self) -> Option<f32> {
316        self.data
317            .header_record
318            .subrecords()
319            .iter()
320            .find(|s| s.subrecord_type() == b"HEDR")
321            .and_then(|s| crate::le_slice_to_f32(s.data()).ok())
322    }
323
324    pub fn record_and_group_count(&self) -> Option<u32> {
325        let count_offset = match self.game_id {
326            GameId::Morrowind => 296,
327            _ => 4,
328        };
329
330        self.data
331            .header_record
332            .subrecords()
333            .iter()
334            .find(|s| s.subrecord_type() == b"HEDR")
335            .and_then(|s| s.data().get(count_offset..))
336            .and_then(|d| crate::le_slice_to_u32(d).ok())
337    }
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> {
341        match &self.data.record_ids {
342            RecordIds::None => Ok(0),
343            RecordIds::FormIds(_) | RecordIds::NamespacedIds(_) => {
344                Err(Error::UnresolvedRecordIds(self.path.clone()))
345            }
346            RecordIds::Resolved(form_ids) => {
347                let count = form_ids.iter().filter(|f| f.is_overridden_record()).count();
348                Ok(count)
349            }
350        }
351    }
352
353    pub fn overlaps_with(&self, other: &Self) -> Result<bool, Error> {
354        use RecordIds::{FormIds, NamespacedIds, Resolved};
355        match (&self.data.record_ids, &other.data.record_ids) {
356            (FormIds(_), _) => Err(Error::UnresolvedRecordIds(self.path.clone())),
357            (_, FormIds(_)) => Err(Error::UnresolvedRecordIds(other.path.clone())),
358            (Resolved(left), Resolved(right)) => Ok(sorted_slices_intersect(left, right)),
359            (NamespacedIds(left), NamespacedIds(right)) => Ok(sorted_slices_intersect(left, right)),
360            _ => Ok(false),
361        }
362    }
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> {
368        use RecordIds::{FormIds, NamespacedIds, None, Resolved};
369
370        match &self.data.record_ids {
371            FormIds(_) => Err(Error::UnresolvedRecordIds(self.path.clone())),
372            Resolved(ids) => {
373                let mut count = 0;
374                for id in ids {
375                    for other in others {
376                        match &other.data.record_ids {
377                            FormIds(_) => {
378                                return Err(Error::UnresolvedRecordIds(other.path.clone()))
379                            }
380                            Resolved(master_ids) if master_ids.binary_search(id).is_ok() => {
381                                count += 1;
382                                break;
383                            }
384                            _ => {
385                                // Do nothing.
386                            }
387                        }
388                    }
389                }
390
391                Ok(count)
392            }
393            NamespacedIds(ids) => {
394                let count = ids
395                    .iter()
396                    .filter(|id| {
397                        others.iter().any(|other| match &other.data.record_ids {
398                            NamespacedIds(master_ids) => master_ids.binary_search(id).is_ok(),
399                            _ => false,
400                        })
401                    })
402                    .count();
403                Ok(count)
404            }
405            None => Ok(0),
406        }
407    }
408
409    pub fn is_valid_as_light_plugin(&self) -> Result<bool, Error> {
410        if self.game_id.supports_light_plugins() {
411            match &self.data.record_ids {
412                RecordIds::None => Ok(true),
413                RecordIds::FormIds(_) => Err(Error::UnresolvedRecordIds(self.path.clone())),
414                RecordIds::Resolved(form_ids) => {
415                    let valid_range = self.valid_light_form_id_range();
416
417                    let is_valid = form_ids
418                        .iter()
419                        .filter(|f| !f.is_overridden_record())
420                        .all(|f| f.is_object_index_in(&valid_range));
421
422                    Ok(is_valid)
423                }
424                RecordIds::NamespacedIds(_) => Ok(false),
425            }
426        } else {
427            Ok(false)
428        }
429    }
430
431    pub fn is_valid_as_medium_plugin(&self) -> Result<bool, Error> {
432        if self.game_id.supports_medium_plugins() {
433            match &self.data.record_ids {
434                RecordIds::None => Ok(true),
435                RecordIds::FormIds(_) => Err(Error::UnresolvedRecordIds(self.path.clone())),
436                RecordIds::Resolved(form_ids) => {
437                    let valid_range = self.valid_medium_form_id_range();
438
439                    let is_valid = form_ids
440                        .iter()
441                        .filter(|f| !f.is_overridden_record())
442                        .all(|f| f.is_object_index_in(&valid_range));
443
444                    Ok(is_valid)
445                }
446                RecordIds::NamespacedIds(_) => Ok(false), // this should never happen.
447            }
448        } else {
449            Ok(false)
450        }
451    }
452
453    pub fn is_valid_as_update_plugin(&self) -> Result<bool, Error> {
454        if self.game_id == GameId::Starfield {
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 {
460                RecordIds::None => Ok(true),
461                RecordIds::FormIds(_) => Err(Error::UnresolvedRecordIds(self.path.clone())),
462                RecordIds::Resolved(form_ids) => {
463                    Ok(form_ids.iter().all(ResolvedRecordId::is_overridden_record))
464                }
465                RecordIds::NamespacedIds(_) => Ok(false), // this should never happen.
466            }
467        } else {
468            Ok(false)
469        }
470    }
471
472    fn header_type(&self) -> &'static [u8] {
473        match self.game_id {
474            GameId::Morrowind => b"TES3",
475            _ => b"TES4",
476        }
477    }
478
479    fn is_master_flag_set(&self) -> bool {
480        match self.game_id {
481            GameId::Morrowind => self
482                .data
483                .header_record
484                .subrecords()
485                .iter()
486                .find(|s| s.subrecord_type() == b"HEDR")
487                .and_then(|s| s.data().get(4))
488                .is_some_and(|b| b & 0x1 != 0),
489            _ => self.data.header_record.header().flags() & 0x1 != 0,
490        }
491    }
492
493    fn is_light_flag_set(&self) -> bool {
494        let flag = match self.game_id {
495            GameId::Starfield => 0x100,
496            GameId::SkyrimSE | GameId::Fallout4 => 0x200,
497            _ => return false,
498        };
499
500        self.data.header_record.header().flags() & flag != 0
501    }
502
503    fn is_medium_flag_set(&self) -> bool {
504        let flag = match self.game_id {
505            GameId::Starfield => 0x400,
506            _ => return false,
507        };
508
509        self.data.header_record.header().flags() & flag != 0
510    }
511
512    fn is_update_flag_set(&self) -> bool {
513        match self.game_id {
514            GameId::Starfield => self.data.header_record.header().flags() & 0x200 != 0,
515            _ => false,
516        }
517    }
518
519    fn valid_light_form_id_range(&self) -> RangeInclusive<u32> {
520        match self.game_id {
521            GameId::SkyrimSE => match self.header_version() {
522                Some(v) if v < 1.71 => 0x800..=0xFFF,
523                Some(_) => 0..=0xFFF,
524                None => 0..=0,
525            },
526            GameId::Fallout4 => match self.header_version() {
527                Some(v) if v < 1.0 => 0x800..=0xFFF,
528                Some(_) => 0x001..=0xFFF,
529                None => 0..=0,
530            },
531            GameId::Starfield => 0..=0xFFF,
532            _ => 0..=0,
533        }
534    }
535
536    fn valid_medium_form_id_range(&self) -> RangeInclusive<u32> {
537        match self.game_id {
538            GameId::Starfield => 0..=0xFFFF,
539            _ => 0..=0,
540        }
541    }
542}
543
544#[derive(Clone, Debug, PartialEq, Eq)]
545pub 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.
552pub fn plugins_metadata(plugins: &[&Plugin]) -> Result<Vec<PluginMetadata>, Error> {
553    let mut vec = Vec::new();
554
555    for plugin in plugins {
556        let filename = plugin
557            .filename()
558            .ok_or_else(|| Error::NoFilename(plugin.path().to_path_buf()))?;
559
560        let record_ids = if plugin.game_id == GameId::Morrowind {
561            match &plugin.data.record_ids {
562                RecordIds::NamespacedIds(ids) => ids.clone(),
563                _ => Vec::new(), // This should never happen.
564            }
565        } else {
566            Vec::new()
567        };
568
569        let metadata = PluginMetadata {
570            filename,
571            scale: plugin.scale(),
572            record_ids: record_ids.into_boxed_slice(),
573        };
574
575        vec.push(metadata);
576    }
577
578    Ok(vec)
579}
580
581fn sorted_slices_intersect<T: PartialOrd>(left: &[T], right: &[T]) -> bool {
582    let mut left_iter = left.iter();
583    let mut right_iter = right.iter();
584
585    let mut left_element = left_iter.next();
586    let mut right_element = right_iter.next();
587
588    while let (Some(left_value), Some(right_value)) = (left_element, right_element) {
589        if left_value < right_value {
590            left_element = left_iter.next();
591        } else if left_value > right_value {
592            right_element = right_iter.next();
593        } else {
594            return true;
595        }
596    }
597
598    false
599}
600
601fn resolve_form_ids(
602    game_id: GameId,
603    form_ids: &[u32],
604    plugin_metadata: &PluginMetadata,
605    masters: &[String],
606    other_plugins_metadata: &[PluginMetadata],
607) -> Result<Vec<ResolvedRecordId>, Error> {
608    let hashed_parent = hashed_parent(game_id, plugin_metadata);
609    let hashed_masters = match game_id {
610        GameId::Starfield => hashed_masters_for_starfield(masters, other_plugins_metadata)?,
611        _ => hashed_masters(masters),
612    };
613
614    let mut form_ids: Vec<_> = form_ids
615        .iter()
616        .map(|form_id| ResolvedRecordId::from_form_id(hashed_parent, &hashed_masters, *form_id))
617        .collect();
618
619    form_ids.sort();
620
621    Ok(form_ids)
622}
623
624fn resolve_namespaced_ids(
625    namespaced_ids: &[NamespacedId],
626    masters: &[String],
627    other_plugins_metadata: &[PluginMetadata],
628) -> Result<Vec<ResolvedRecordId>, Error> {
629    let mut record_ids: HashSet<NamespacedId> = HashSet::new();
630
631    for master in masters {
632        let master_record_ids = other_plugins_metadata
633            .iter()
634            .find(|m| unicase::eq(&m.filename, master))
635            .map(|m| &m.record_ids)
636            .ok_or_else(|| Error::PluginMetadataNotFound(master.clone()))?;
637
638        record_ids.extend(master_record_ids.iter().cloned());
639    }
640
641    let mut resolved_ids: Vec<_> = namespaced_ids
642        .iter()
643        .map(|id| ResolvedRecordId::from_namespaced_id(id, &record_ids))
644        .collect();
645
646    resolved_ids.sort();
647
648    Ok(resolved_ids)
649}
650
651fn hashed_parent(game_id: GameId, parent_metadata: &PluginMetadata) -> SourcePlugin {
652    match game_id {
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 {
656                PluginScale::Full => ObjectIndexMask::Full,
657                PluginScale::Medium => ObjectIndexMask::Medium,
658                PluginScale::Small => ObjectIndexMask::Small,
659            };
660            SourcePlugin::parent(&parent_metadata.filename, object_index_mask)
661        }
662        // The full object index mask is used for all plugin scales in other games.
663        _ => SourcePlugin::parent(&parent_metadata.filename, ObjectIndexMask::Full),
664    }
665}
666
667fn hashed_masters(masters: &[String]) -> Vec<SourcePlugin> {
668    masters
669        .iter()
670        .enumerate()
671        .filter_map(|(i, m)| {
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()?;
674            let mod_index_mask = u32::from(i) << 24u8;
675            Some(SourcePlugin::master(
676                m,
677                mod_index_mask,
678                ObjectIndexMask::Full,
679            ))
680        })
681        .collect()
682}
683
684// Get HashedMaster objects for the current plugin.
685fn hashed_masters_for_starfield(
686    masters: &[String],
687    masters_metadata: &[PluginMetadata],
688) -> Result<Vec<SourcePlugin>, Error> {
689    let mut hashed_masters = Vec::new();
690    let mut full_mask = 0;
691    let mut medium_mask = 0xFD00_0000;
692    let mut small_mask = 0xFE00_0000;
693
694    for master in masters {
695        let master_scale = masters_metadata
696            .iter()
697            .find(|m| unicase::eq(&m.filename, master))
698            .map(|m| m.scale)
699            .ok_or_else(|| Error::PluginMetadataNotFound(master.clone()))?;
700
701        match master_scale {
702            PluginScale::Full => {
703                hashed_masters.push(SourcePlugin::master(
704                    master,
705                    full_mask,
706                    ObjectIndexMask::Full,
707                ));
708
709                full_mask += 0x0100_0000;
710            }
711            PluginScale::Medium => {
712                hashed_masters.push(SourcePlugin::master(
713                    master,
714                    medium_mask,
715                    ObjectIndexMask::Medium,
716                ));
717
718                medium_mask += 0x0001_0000;
719            }
720            PluginScale::Small => {
721                hashed_masters.push(SourcePlugin::master(
722                    master,
723                    small_mask,
724                    ObjectIndexMask::Small,
725                ));
726
727                small_mask += 0x0000_1000;
728            }
729        }
730    }
731
732    Ok(hashed_masters)
733}
734
735fn masters(header_record: &Record) -> Result<Vec<String>, Error> {
736    header_record
737        .subrecords()
738        .iter()
739        .filter(|s| s.subrecord_type() == b"MAST")
740        .map(|s| until_first_null(s.data()))
741        .map(|d| {
742            WINDOWS_1252
743                .decode_without_bom_handling_and_without_replacement(d)
744                .map(|s| s.to_string())
745                .ok_or(Error::DecodeError(d.into()))
746        })
747        .collect()
748}
749
750fn read_form_ids<R: BufRead + Seek>(reader: &mut R, game_id: GameId) -> Result<Vec<u32>, Error> {
751    let mut form_ids = Vec::new();
752    let mut header_buf = [0; MAX_RECORD_HEADER_LENGTH];
753
754    while !reader.fill_buf()?.is_empty() {
755        Group::read_form_ids(reader, game_id, &mut form_ids, &mut header_buf)?;
756    }
757
758    Ok(form_ids)
759}
760
761fn read_morrowind_record_ids<R: BufRead + Seek>(reader: &mut R) -> Result<RecordIds, Error> {
762    let mut record_ids = Vec::new();
763    let mut header_buf = [0; 16]; // Morrowind record headers are 16 bytes long.
764
765    while !reader.fill_buf()?.is_empty() {
766        let (_, record_id) =
767            Record::read_record_id(reader, GameId::Morrowind, &mut header_buf, false)?;
768
769        if let Some(RecordId::NamespacedId(record_id)) = record_id {
770            record_ids.push(record_id);
771        }
772    }
773
774    record_ids.sort();
775
776    Ok(record_ids.into())
777}
778
779fn read_record_ids<R: BufRead + Seek>(reader: &mut R, game_id: GameId) -> Result<RecordIds, Error> {
780    if game_id == GameId::Morrowind {
781        read_morrowind_record_ids(reader)
782    } else {
783        read_form_ids(reader, game_id).map(Into::into)
784    }
785}
786
787fn read_plugin<R: BufRead + Seek>(
788    reader: &mut R,
789    game_id: GameId,
790    options: ParseOptions,
791    expected_header_type: &'static [u8],
792) -> Result<PluginData, Error> {
793    let header_record = Record::read(reader, game_id, expected_header_type)?;
794
795    if options.header_only {
796        return Ok(PluginData {
797            header_record,
798            record_ids: RecordIds::None,
799        });
800    }
801
802    let record_ids = read_record_ids(reader, game_id)?;
803
804    Ok(PluginData {
805        header_record,
806        record_ids,
807    })
808}
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.
812fn until_first_null(bytes: &[u8]) -> &[u8] {
813    if let Some(i) = memchr::memchr(0, bytes) {
814        bytes.split_at(i).0
815    } else {
816        bytes
817    }
818}
819
820#[cfg(test)]
821mod tests {
822    use std::fs::{copy, read};
823    use std::io::Cursor;
824    use tempfile::tempdir;
825
826    use super::*;
827
828    mod morrowind {
829        use super::*;
830
831        #[test]
832        fn parse_file_should_succeed() {
833            let mut plugin = Plugin::new(
834                GameId::Morrowind,
835                Path::new("testing-plugins/Morrowind/Data Files/Blank.esm"),
836            );
837
838            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
839
840            match plugin.data.record_ids {
841                RecordIds::NamespacedIds(ids) => assert_eq!(10, ids.len()),
842                _ => panic!("Expected namespaced record IDs"),
843            }
844        }
845
846        #[test]
847        fn plugin_parse_file_should_read_a_unique_id_for_each_record() {
848            let mut plugin = Plugin::new(
849                GameId::Morrowind,
850                Path::new("testing-plugins/Morrowind/Data Files/Blank.esm"),
851            );
852
853            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
854
855            match plugin.data.record_ids {
856                RecordIds::NamespacedIds(ids) => {
857                    let set: HashSet<NamespacedId> = ids.iter().cloned().collect();
858                    assert_eq!(set.len(), ids.len());
859                }
860                _ => panic!("Expected namespaced record IDs"),
861            }
862        }
863
864        #[test]
865        fn parse_file_header_only_should_not_store_record_ids() {
866            let mut plugin = Plugin::new(
867                GameId::Morrowind,
868                Path::new("testing-plugins/Morrowind/Data Files/Blank.esm"),
869            );
870
871            let result = plugin.parse_file(ParseOptions::header_only());
872
873            assert!(result.is_ok());
874
875            assert_eq!(RecordIds::None, plugin.data.record_ids);
876        }
877
878        #[test]
879        fn game_id_should_return_the_plugins_associated_game_id() {
880            let plugin = Plugin::new(GameId::Morrowind, Path::new("Data/Blank.esm"));
881
882            assert_eq!(GameId::Morrowind, plugin.game_id());
883        }
884
885        #[test]
886        fn is_master_file_should_be_true_for_plugin_with_master_flag_set() {
887            let mut plugin = Plugin::new(
888                GameId::Morrowind,
889                Path::new("testing-plugins/Morrowind/Data Files/Blank.esm"),
890            );
891
892            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
893            assert!(plugin.is_master_file());
894        }
895
896        #[test]
897        fn is_master_file_should_be_false_for_plugin_without_master_flag_set() {
898            let mut plugin = Plugin::new(
899                GameId::Morrowind,
900                Path::new("testing-plugins/Morrowind/Data Files/Blank.esp"),
901            );
902
903            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
904            assert!(!plugin.is_master_file());
905        }
906
907        #[test]
908        fn is_master_file_should_ignore_file_extension() {
909            let tmp_dir = tempdir().unwrap();
910
911            let esm = tmp_dir.path().join("Blank.esm");
912            copy(
913                Path::new("testing-plugins/Morrowind/Data Files/Blank.esp"),
914                &esm,
915            )
916            .unwrap();
917
918            let mut plugin = Plugin::new(GameId::Morrowind, &esm);
919
920            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
921            assert!(!plugin.is_master_file());
922        }
923
924        #[test]
925        fn is_light_plugin_should_be_false() {
926            let plugin = Plugin::new(GameId::Morrowind, Path::new("Blank.esp"));
927            assert!(!plugin.is_light_plugin());
928            let plugin = Plugin::new(GameId::Morrowind, Path::new("Blank.esm"));
929            assert!(!plugin.is_light_plugin());
930            let plugin = Plugin::new(GameId::Morrowind, Path::new("Blank.esl"));
931            assert!(!plugin.is_light_plugin());
932        }
933
934        #[test]
935        fn is_medium_plugin_should_be_false() {
936            let plugin = Plugin::new(GameId::Morrowind, Path::new("Blank.esp"));
937            assert!(!plugin.is_medium_plugin());
938            let plugin = Plugin::new(GameId::Morrowind, Path::new("Blank.esm"));
939            assert!(!plugin.is_medium_plugin());
940            let plugin = Plugin::new(GameId::Morrowind, Path::new("Blank.esl"));
941            assert!(!plugin.is_medium_plugin());
942        }
943
944        #[test]
945        fn description_should_trim_nulls_in_plugin_header_hedr_subrecord_content() {
946            let mut plugin = Plugin::new(
947                GameId::Morrowind,
948                Path::new("testing-plugins/Morrowind/Data Files/Blank.esm"),
949            );
950
951            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
952
953            assert_eq!("v5.0", plugin.description().unwrap().unwrap());
954        }
955
956        #[expect(clippy::float_cmp, reason = "float values should be exactly equal")]
957        #[test]
958        fn header_version_should_return_plugin_header_hedr_subrecord_field() {
959            let mut plugin = Plugin::new(
960                GameId::Morrowind,
961                Path::new("testing-plugins/Morrowind/Data Files/Blank.esm"),
962            );
963
964            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
965
966            assert_eq!(1.2, plugin.header_version().unwrap());
967        }
968
969        #[test]
970        fn record_and_group_count_should_read_correct_offset() {
971            let mut plugin = Plugin::new(
972                GameId::Morrowind,
973                Path::new("testing-plugins/Morrowind/Data Files/Blank.esm"),
974            );
975
976            assert!(plugin.record_and_group_count().is_none());
977            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
978            assert_eq!(10, plugin.record_and_group_count().unwrap());
979        }
980
981        #[test]
982        fn record_and_group_count_should_match_record_ids_length() {
983            let mut plugin = Plugin::new(
984                GameId::Morrowind,
985                Path::new("testing-plugins/Morrowind/Data Files/Blank.esm"),
986            );
987
988            assert!(plugin.record_and_group_count().is_none());
989            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
990            assert_eq!(10, plugin.record_and_group_count().unwrap());
991            match plugin.data.record_ids {
992                RecordIds::NamespacedIds(ids) => assert_eq!(10, ids.len()),
993                _ => panic!("Expected namespaced record IDs"),
994            }
995        }
996
997        #[test]
998        fn count_override_records_should_error_if_record_ids_are_not_yet_resolved() {
999            let mut plugin = Plugin::new(
1000                GameId::Morrowind,
1001                Path::new("testing-plugins/Morrowind/Data Files/Blank - Master Dependent.esm"),
1002            );
1003
1004            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
1005
1006            match plugin.count_override_records().unwrap_err() {
1007                Error::UnresolvedRecordIds(path) => assert_eq!(plugin.path, path),
1008                _ => panic!("Expected unresolved record IDs error"),
1009            }
1010        }
1011
1012        #[test]
1013        fn count_override_records_should_count_how_many_records_are_also_present_in_masters() {
1014            let mut plugin = Plugin::new(
1015                GameId::Morrowind,
1016                Path::new("testing-plugins/Morrowind/Data Files/Blank - Master Dependent.esm"),
1017            );
1018            let mut master = Plugin::new(
1019                GameId::Morrowind,
1020                Path::new("testing-plugins/Morrowind/Data Files/Blank.esm"),
1021            );
1022
1023            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
1024            assert!(master.parse_file(ParseOptions::whole_plugin()).is_ok());
1025
1026            let plugins_metadata = plugins_metadata(&[&master]).unwrap();
1027
1028            plugin.resolve_record_ids(&plugins_metadata).unwrap();
1029
1030            assert_eq!(4, plugin.count_override_records().unwrap());
1031        }
1032
1033        #[test]
1034        fn overlaps_with_should_detect_when_two_plugins_have_a_record_with_the_same_id() {
1035            let mut plugin1 = Plugin::new(
1036                GameId::Morrowind,
1037                Path::new("testing-plugins/Morrowind/Data Files/Blank.esm"),
1038            );
1039            let mut plugin2 = Plugin::new(
1040                GameId::Morrowind,
1041                Path::new("testing-plugins/Morrowind/Data Files/Blank - Different.esm"),
1042            );
1043
1044            assert!(plugin1.parse_file(ParseOptions::whole_plugin()).is_ok());
1045            assert!(plugin2.parse_file(ParseOptions::whole_plugin()).is_ok());
1046
1047            assert!(plugin1.overlaps_with(&plugin1).unwrap());
1048            assert!(!plugin1.overlaps_with(&plugin2).unwrap());
1049        }
1050
1051        #[test]
1052        fn overlap_size_should_only_count_each_record_once() {
1053            let mut plugin1 = Plugin::new(
1054                GameId::Morrowind,
1055                Path::new("testing-plugins/Morrowind/Data Files/Blank.esm"),
1056            );
1057            let mut plugin2 = Plugin::new(
1058                GameId::Morrowind,
1059                Path::new("testing-plugins/Morrowind/Data Files/Blank - Master Dependent.esm"),
1060            );
1061
1062            assert!(plugin1.parse_file(ParseOptions::whole_plugin()).is_ok());
1063            assert!(plugin2.parse_file(ParseOptions::whole_plugin()).is_ok());
1064
1065            assert_eq!(4, plugin1.overlap_size(&[&plugin2, &plugin2]).unwrap());
1066        }
1067
1068        #[test]
1069        fn overlap_size_should_check_against_all_given_plugins() {
1070            let mut plugin1 = Plugin::new(
1071                GameId::Morrowind,
1072                Path::new("testing-plugins/Morrowind/Data Files/Blank.esm"),
1073            );
1074            let mut plugin2 = Plugin::new(
1075                GameId::Morrowind,
1076                Path::new("testing-plugins/Morrowind/Data Files/Blank.esp"),
1077            );
1078            let mut plugin3 = Plugin::new(
1079                GameId::Morrowind,
1080                Path::new("testing-plugins/Morrowind/Data Files/Blank - Master Dependent.esm"),
1081            );
1082
1083            assert!(plugin1.parse_file(ParseOptions::whole_plugin()).is_ok());
1084            assert!(plugin2.parse_file(ParseOptions::whole_plugin()).is_ok());
1085            assert!(plugin3.parse_file(ParseOptions::whole_plugin()).is_ok());
1086
1087            assert_eq!(4, plugin1.overlap_size(&[&plugin2, &plugin3]).unwrap());
1088        }
1089
1090        #[test]
1091        fn overlap_size_should_return_0_if_plugins_have_not_been_parsed() {
1092            let mut plugin1 = Plugin::new(
1093                GameId::Morrowind,
1094                Path::new("testing-plugins/Morrowind/Data Files/Blank.esm"),
1095            );
1096            let mut plugin2 = Plugin::new(
1097                GameId::Morrowind,
1098                Path::new("testing-plugins/Morrowind/Data Files/Blank - Master Dependent.esm"),
1099            );
1100
1101            assert_eq!(0, plugin1.overlap_size(&[&plugin2]).unwrap());
1102
1103            assert!(plugin1.parse_file(ParseOptions::whole_plugin()).is_ok());
1104
1105            assert_eq!(0, plugin1.overlap_size(&[&plugin2]).unwrap());
1106
1107            assert!(plugin2.parse_file(ParseOptions::whole_plugin()).is_ok());
1108
1109            assert_ne!(0, plugin1.overlap_size(&[&plugin2]).unwrap());
1110        }
1111
1112        #[test]
1113        fn overlap_size_should_return_0_when_there_is_no_overlap() {
1114            let mut plugin1 = Plugin::new(
1115                GameId::Morrowind,
1116                Path::new("testing-plugins/Morrowind/Data Files/Blank.esm"),
1117            );
1118            let mut plugin2 = Plugin::new(
1119                GameId::Morrowind,
1120                Path::new("testing-plugins/Morrowind/Data Files/Blank.esp"),
1121            );
1122
1123            assert!(plugin1.parse_file(ParseOptions::whole_plugin()).is_ok());
1124            assert!(plugin2.parse_file(ParseOptions::whole_plugin()).is_ok());
1125
1126            assert!(!plugin1.overlaps_with(&plugin2).unwrap());
1127            assert_eq!(0, plugin1.overlap_size(&[&plugin2]).unwrap());
1128        }
1129
1130        #[test]
1131        fn valid_light_form_id_range_should_be_empty() {
1132            let mut plugin = Plugin::new(
1133                GameId::Morrowind,
1134                Path::new("testing-plugins/Morrowind/Data Files/Blank - Master Dependent.esm"),
1135            );
1136            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
1137
1138            let range = plugin.valid_light_form_id_range();
1139            assert_eq!(&0, range.start());
1140            assert_eq!(&0, range.end());
1141        }
1142
1143        #[test]
1144        fn is_valid_as_light_plugin_should_always_be_false() {
1145            let mut plugin = Plugin::new(
1146                GameId::Morrowind,
1147                Path::new("testing-plugins/Morrowind/Data Files/Blank - Master Dependent.esm"),
1148            );
1149            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
1150            assert!(!plugin.is_valid_as_light_plugin().unwrap());
1151        }
1152    }
1153
1154    mod oblivion {
1155        use super::super::*;
1156
1157        #[test]
1158        fn is_light_plugin_should_be_false() {
1159            let plugin = Plugin::new(GameId::Oblivion, Path::new("Blank.esp"));
1160            assert!(!plugin.is_light_plugin());
1161            let plugin = Plugin::new(GameId::Oblivion, Path::new("Blank.esm"));
1162            assert!(!plugin.is_light_plugin());
1163            let plugin = Plugin::new(GameId::Oblivion, Path::new("Blank.esl"));
1164            assert!(!plugin.is_light_plugin());
1165        }
1166
1167        #[test]
1168        fn is_medium_plugin_should_be_false() {
1169            let plugin = Plugin::new(GameId::Oblivion, Path::new("Blank.esp"));
1170            assert!(!plugin.is_medium_plugin());
1171            let plugin = Plugin::new(GameId::Oblivion, Path::new("Blank.esm"));
1172            assert!(!plugin.is_medium_plugin());
1173            let plugin = Plugin::new(GameId::Oblivion, Path::new("Blank.esl"));
1174            assert!(!plugin.is_medium_plugin());
1175        }
1176
1177        #[expect(clippy::float_cmp, reason = "float values should be exactly equal")]
1178        #[test]
1179        fn header_version_should_return_plugin_header_hedr_subrecord_field() {
1180            let mut plugin = Plugin::new(
1181                GameId::Oblivion,
1182                Path::new("testing-plugins/Oblivion/Data/Blank.esm"),
1183            );
1184
1185            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
1186
1187            assert_eq!(0.8, plugin.header_version().unwrap());
1188        }
1189
1190        #[test]
1191        fn valid_light_form_id_range_should_be_empty() {
1192            let mut plugin = Plugin::new(
1193                GameId::Oblivion,
1194                Path::new("testing-plugins/Oblivion/Data/Blank - Master Dependent.esm"),
1195            );
1196            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
1197
1198            let range = plugin.valid_light_form_id_range();
1199            assert_eq!(&0, range.start());
1200            assert_eq!(&0, range.end());
1201        }
1202
1203        #[test]
1204        fn is_valid_as_light_plugin_should_always_be_false() {
1205            let mut plugin = Plugin::new(
1206                GameId::Oblivion,
1207                Path::new("testing-plugins/Oblivion/Data/Blank - Master Dependent.esm"),
1208            );
1209            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
1210            assert!(!plugin.is_valid_as_light_plugin().unwrap());
1211        }
1212    }
1213
1214    mod skyrim {
1215        use super::*;
1216
1217        #[test]
1218        fn parse_file_should_succeed() {
1219            let mut plugin = Plugin::new(
1220                GameId::Skyrim,
1221                Path::new("testing-plugins/Skyrim/Data/Blank.esm"),
1222            );
1223
1224            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
1225
1226            match plugin.data.record_ids {
1227                RecordIds::Resolved(ids) => assert_eq!(10, ids.len()),
1228                _ => panic!("Expected resolved FormIDs"),
1229            }
1230        }
1231
1232        #[test]
1233        fn parse_file_header_only_should_not_store_form_ids() {
1234            let mut plugin = Plugin::new(
1235                GameId::Skyrim,
1236                Path::new("testing-plugins/Skyrim/Data/Blank.esm"),
1237            );
1238
1239            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
1240
1241            assert_eq!(RecordIds::None, plugin.data.record_ids);
1242        }
1243
1244        #[test]
1245        fn game_id_should_return_the_plugins_associated_game_id() {
1246            let plugin = Plugin::new(GameId::Skyrim, Path::new("Data/Blank.esm"));
1247
1248            assert_eq!(GameId::Skyrim, plugin.game_id());
1249        }
1250
1251        #[test]
1252        fn is_master_file_should_be_true_for_master_file() {
1253            let mut plugin = Plugin::new(
1254                GameId::Skyrim,
1255                Path::new("testing-plugins/Skyrim/Data/Blank.esm"),
1256            );
1257
1258            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
1259            assert!(plugin.is_master_file());
1260        }
1261
1262        #[test]
1263        fn is_master_file_should_be_false_for_non_master_file() {
1264            let mut plugin = Plugin::new(
1265                GameId::Skyrim,
1266                Path::new("testing-plugins/Skyrim/Data/Blank.esp"),
1267            );
1268
1269            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
1270            assert!(!plugin.is_master_file());
1271        }
1272
1273        #[test]
1274        fn is_light_plugin_should_be_false() {
1275            let plugin = Plugin::new(GameId::Skyrim, Path::new("Blank.esp"));
1276            assert!(!plugin.is_light_plugin());
1277            let plugin = Plugin::new(GameId::Skyrim, Path::new("Blank.esm"));
1278            assert!(!plugin.is_light_plugin());
1279            let plugin = Plugin::new(GameId::Skyrim, Path::new("Blank.esl"));
1280            assert!(!plugin.is_light_plugin());
1281        }
1282
1283        #[test]
1284        fn is_medium_plugin_should_be_false() {
1285            let plugin = Plugin::new(GameId::Skyrim, Path::new("Blank.esp"));
1286            assert!(!plugin.is_medium_plugin());
1287            let plugin = Plugin::new(GameId::Skyrim, Path::new("Blank.esm"));
1288            assert!(!plugin.is_medium_plugin());
1289            let plugin = Plugin::new(GameId::Skyrim, Path::new("Blank.esl"));
1290            assert!(!plugin.is_medium_plugin());
1291        }
1292
1293        #[test]
1294        fn description_should_return_plugin_description_field_content() {
1295            let mut plugin = Plugin::new(
1296                GameId::Skyrim,
1297                Path::new("testing-plugins/Skyrim/Data/Blank.esm"),
1298            );
1299
1300            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
1301            assert_eq!("v5.0", plugin.description().unwrap().unwrap());
1302
1303            let mut plugin = Plugin::new(
1304                GameId::Skyrim,
1305                Path::new("testing-plugins/Skyrim/Data/Blank.esp"),
1306            );
1307
1308            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
1309            assert_eq!(
1310                "\u{20ac}\u{192}\u{160}",
1311                plugin.description().unwrap().unwrap()
1312            );
1313
1314            let mut plugin = Plugin::new(
1315                GameId::Skyrim,
1316                Path::new(
1317                    "testing-plugins/Skyrim/Data/Blank - \
1318                      Master Dependent.esm",
1319                ),
1320            );
1321
1322            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
1323            assert_eq!("", plugin.description().unwrap().unwrap());
1324        }
1325
1326        #[test]
1327        fn description_should_trim_nulls_in_plugin_description_field_content() {
1328            let mut plugin = Plugin::new(
1329                GameId::Skyrim,
1330                Path::new("testing-plugins/Skyrim/Data/Blank.esm"),
1331            );
1332
1333            let mut bytes = read(plugin.path()).unwrap();
1334
1335            assert_eq!(0x2E, bytes[0x39]);
1336            bytes[0x39] = 0;
1337
1338            assert!(plugin
1339                .parse_reader(Cursor::new(bytes), ParseOptions::whole_plugin())
1340                .is_ok());
1341
1342            assert_eq!("v5", plugin.description().unwrap().unwrap());
1343        }
1344
1345        #[expect(clippy::float_cmp, reason = "float values should be exactly equal")]
1346        #[test]
1347        fn header_version_should_return_plugin_header_hedr_subrecord_field() {
1348            let mut plugin = Plugin::new(
1349                GameId::Skyrim,
1350                Path::new("testing-plugins/Skyrim/Data/Blank.esm"),
1351            );
1352
1353            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
1354
1355            assert_eq!(0.94, plugin.header_version().unwrap());
1356        }
1357
1358        #[test]
1359        fn record_and_group_count_should_be_non_zero_for_a_plugin_with_records() {
1360            let mut plugin = Plugin::new(
1361                GameId::Skyrim,
1362                Path::new("testing-plugins/Skyrim/Data/Blank.esm"),
1363            );
1364
1365            assert!(plugin.record_and_group_count().is_none());
1366            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
1367            assert_eq!(15, plugin.record_and_group_count().unwrap());
1368        }
1369
1370        #[test]
1371        fn count_override_records_should_count_how_many_records_come_from_masters() {
1372            let mut plugin = Plugin::new(
1373                GameId::Skyrim,
1374                Path::new("testing-plugins/Skyrim/Data/Blank - Different Master Dependent.esp"),
1375            );
1376
1377            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
1378            assert_eq!(2, plugin.count_override_records().unwrap());
1379        }
1380
1381        #[test]
1382        fn overlaps_with_should_detect_when_two_plugins_have_a_record_from_the_same_master() {
1383            let mut plugin1 = Plugin::new(
1384                GameId::Skyrim,
1385                Path::new("testing-plugins/Skyrim/Data/Blank.esm"),
1386            );
1387            let mut plugin2 = Plugin::new(
1388                GameId::Skyrim,
1389                Path::new("testing-plugins/Skyrim/Data/Blank - Different.esm"),
1390            );
1391
1392            assert!(plugin1.parse_file(ParseOptions::whole_plugin()).is_ok());
1393            assert!(plugin2.parse_file(ParseOptions::whole_plugin()).is_ok());
1394
1395            assert!(plugin1.overlaps_with(&plugin1).unwrap());
1396            assert!(!plugin1.overlaps_with(&plugin2).unwrap());
1397        }
1398
1399        #[test]
1400        fn overlap_size_should_only_count_each_record_once() {
1401            let mut plugin1 = Plugin::new(
1402                GameId::Skyrim,
1403                Path::new("testing-plugins/Skyrim/Data/Blank.esm"),
1404            );
1405            let mut plugin2 = Plugin::new(
1406                GameId::Skyrim,
1407                Path::new("testing-plugins/Skyrim/Data/Blank - Master Dependent.esm"),
1408            );
1409
1410            assert!(plugin1.parse_file(ParseOptions::whole_plugin()).is_ok());
1411            assert!(plugin2.parse_file(ParseOptions::whole_plugin()).is_ok());
1412
1413            assert_eq!(4, plugin1.overlap_size(&[&plugin2, &plugin2]).unwrap());
1414        }
1415
1416        #[test]
1417        fn overlap_size_should_check_against_all_given_plugins() {
1418            let mut plugin1 = Plugin::new(
1419                GameId::Skyrim,
1420                Path::new("testing-plugins/Skyrim/Data/Blank.esm"),
1421            );
1422            let mut plugin2 = Plugin::new(
1423                GameId::Skyrim,
1424                Path::new("testing-plugins/Skyrim/Data/Blank.esp"),
1425            );
1426            let mut plugin3 = Plugin::new(
1427                GameId::Skyrim,
1428                Path::new("testing-plugins/Skyrim/Data/Blank - Master Dependent.esp"),
1429            );
1430
1431            assert!(plugin1.parse_file(ParseOptions::whole_plugin()).is_ok());
1432            assert!(plugin2.parse_file(ParseOptions::whole_plugin()).is_ok());
1433            assert!(plugin3.parse_file(ParseOptions::whole_plugin()).is_ok());
1434
1435            assert_eq!(2, plugin1.overlap_size(&[&plugin2, &plugin3]).unwrap());
1436        }
1437
1438        #[test]
1439        fn overlap_size_should_return_0_if_plugins_have_not_been_parsed() {
1440            let mut plugin1 = Plugin::new(
1441                GameId::Skyrim,
1442                Path::new("testing-plugins/Skyrim/Data/Blank.esm"),
1443            );
1444            let mut plugin2 = Plugin::new(
1445                GameId::Skyrim,
1446                Path::new("testing-plugins/Skyrim/Data/Blank - Master Dependent.esm"),
1447            );
1448
1449            assert_eq!(0, plugin1.overlap_size(&[&plugin2]).unwrap());
1450
1451            assert!(plugin1.parse_file(ParseOptions::whole_plugin()).is_ok());
1452
1453            assert_eq!(0, plugin1.overlap_size(&[&plugin2]).unwrap());
1454
1455            assert!(plugin2.parse_file(ParseOptions::whole_plugin()).is_ok());
1456
1457            assert_ne!(0, plugin1.overlap_size(&[&plugin2]).unwrap());
1458        }
1459
1460        #[test]
1461        fn overlap_size_should_return_0_when_there_is_no_overlap() {
1462            let mut plugin1 = Plugin::new(
1463                GameId::Skyrim,
1464                Path::new("testing-plugins/Skyrim/Data/Blank.esm"),
1465            );
1466            let mut plugin2 = Plugin::new(
1467                GameId::Skyrim,
1468                Path::new("testing-plugins/Skyrim/Data/Blank.esp"),
1469            );
1470
1471            assert!(plugin1.parse_file(ParseOptions::whole_plugin()).is_ok());
1472            assert!(plugin2.parse_file(ParseOptions::whole_plugin()).is_ok());
1473
1474            assert!(!plugin1.overlaps_with(&plugin2).unwrap());
1475            assert_eq!(0, plugin1.overlap_size(&[&plugin2]).unwrap());
1476        }
1477
1478        #[test]
1479        fn valid_light_form_id_range_should_be_empty() {
1480            let mut plugin = Plugin::new(
1481                GameId::Skyrim,
1482                Path::new("testing-plugins/Skyrim/Data/Blank - Master Dependent.esm"),
1483            );
1484            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
1485
1486            let range = plugin.valid_light_form_id_range();
1487            assert_eq!(&0, range.start());
1488            assert_eq!(&0, range.end());
1489        }
1490
1491        #[test]
1492        fn is_valid_as_light_plugin_should_always_be_false() {
1493            let mut plugin = Plugin::new(
1494                GameId::Skyrim,
1495                Path::new("testing-plugins/Skyrim/Data/Blank - Master Dependent.esm"),
1496            );
1497            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
1498            assert!(!plugin.is_valid_as_light_plugin().unwrap());
1499        }
1500    }
1501
1502    mod skyrimse {
1503        use super::*;
1504
1505        #[test]
1506        fn is_master_file_should_use_file_extension_and_flag() {
1507            let tmp_dir = tempdir().unwrap();
1508
1509            let master_flagged_esp = tmp_dir.path().join("Blank.esp");
1510            copy(
1511                Path::new("testing-plugins/Skyrim/Data/Blank.esm"),
1512                &master_flagged_esp,
1513            )
1514            .unwrap();
1515
1516            let plugin = Plugin::new(GameId::SkyrimSE, Path::new("Blank.esp"));
1517            assert!(!plugin.is_master_file());
1518
1519            let plugin = Plugin::new(GameId::SkyrimSE, Path::new("Blank.esm"));
1520            assert!(plugin.is_master_file());
1521
1522            let plugin = Plugin::new(GameId::SkyrimSE, Path::new("Blank.esl"));
1523            assert!(plugin.is_master_file());
1524
1525            let mut plugin = Plugin::new(
1526                GameId::SkyrimSE,
1527                Path::new("testing-plugins/Skyrim/Data/Blank.esp"),
1528            );
1529            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
1530            assert!(!plugin.is_master_file());
1531
1532            let mut plugin = Plugin::new(GameId::SkyrimSE, &master_flagged_esp);
1533            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
1534            assert!(plugin.is_master_file());
1535        }
1536
1537        #[test]
1538        fn is_light_plugin_should_be_true_for_plugins_with_an_esl_file_extension() {
1539            let plugin = Plugin::new(GameId::SkyrimSE, Path::new("Blank.esp"));
1540            assert!(!plugin.is_light_plugin());
1541            let plugin = Plugin::new(GameId::SkyrimSE, Path::new("Blank.esm"));
1542            assert!(!plugin.is_light_plugin());
1543            let plugin = Plugin::new(GameId::SkyrimSE, Path::new("Blank.esl"));
1544            assert!(plugin.is_light_plugin());
1545        }
1546
1547        #[test]
1548        fn is_light_plugin_should_be_true_for_a_ghosted_esl_file() {
1549            let plugin = Plugin::new(GameId::SkyrimSE, Path::new("Blank.esl.ghost"));
1550            assert!(plugin.is_light_plugin());
1551        }
1552
1553        #[test]
1554        fn is_light_plugin_should_be_true_for_an_esp_file_with_the_light_flag_set() {
1555            let tmp_dir = tempdir().unwrap();
1556
1557            let light_flagged_esp = tmp_dir.path().join("Blank.esp");
1558            copy(
1559                Path::new("testing-plugins/SkyrimSE/Data/Blank.esl"),
1560                &light_flagged_esp,
1561            )
1562            .unwrap();
1563
1564            let mut plugin = Plugin::new(GameId::SkyrimSE, &light_flagged_esp);
1565            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
1566            assert!(plugin.is_light_plugin());
1567            assert!(!plugin.is_master_file());
1568        }
1569
1570        #[test]
1571        fn is_light_plugin_should_be_true_for_an_esm_file_with_the_light_flag_set() {
1572            let tmp_dir = tempdir().unwrap();
1573
1574            let light_flagged_esm = tmp_dir.path().join("Blank.esm");
1575            copy(
1576                Path::new("testing-plugins/SkyrimSE/Data/Blank.esl"),
1577                &light_flagged_esm,
1578            )
1579            .unwrap();
1580
1581            let mut plugin = Plugin::new(GameId::SkyrimSE, &light_flagged_esm);
1582            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
1583            assert!(plugin.is_light_plugin());
1584            assert!(plugin.is_master_file());
1585        }
1586
1587        #[test]
1588        fn is_medium_plugin_should_be_false() {
1589            let plugin = Plugin::new(GameId::SkyrimSE, Path::new("Blank.esp"));
1590            assert!(!plugin.is_medium_plugin());
1591            let plugin = Plugin::new(GameId::SkyrimSE, Path::new("Blank.esm"));
1592            assert!(!plugin.is_medium_plugin());
1593            let plugin = Plugin::new(GameId::SkyrimSE, Path::new("Blank.esl"));
1594            assert!(!plugin.is_medium_plugin());
1595        }
1596
1597        #[expect(clippy::float_cmp, reason = "float values should be exactly equal")]
1598        #[test]
1599        fn header_version_should_return_plugin_header_hedr_subrecord_field() {
1600            let mut plugin = Plugin::new(
1601                GameId::SkyrimSE,
1602                Path::new("testing-plugins/SkyrimSE/Data/Blank.esm"),
1603            );
1604
1605            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
1606
1607            assert_eq!(0.94, plugin.header_version().unwrap());
1608        }
1609
1610        #[test]
1611        fn valid_light_form_id_range_should_be_0x800_to_0xfff_if_hedr_version_is_less_than_1_71() {
1612            let mut plugin = Plugin::new(
1613                GameId::SkyrimSE,
1614                Path::new("testing-plugins/SkyrimSE/Data/Blank - Master Dependent.esm"),
1615            );
1616            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
1617
1618            let range = plugin.valid_light_form_id_range();
1619            assert_eq!(&0x800, range.start());
1620            assert_eq!(&0xFFF, range.end());
1621        }
1622
1623        #[test]
1624        fn valid_light_form_id_range_should_be_0_to_0xfff_if_hedr_version_is_1_71_or_greater() {
1625            let mut plugin = Plugin::new(
1626                GameId::SkyrimSE,
1627                Path::new("testing-plugins/SkyrimSE/Data/Blank - Master Dependent.esm"),
1628            );
1629            let mut bytes = read(plugin.path()).unwrap();
1630
1631            assert_eq!(0xD7, bytes[0x1E]);
1632            assert_eq!(0xA3, bytes[0x1F]);
1633            assert_eq!(0x70, bytes[0x20]);
1634            assert_eq!(0x3F, bytes[0x21]);
1635            bytes[0x1E] = 0x48;
1636            bytes[0x1F] = 0xE1;
1637            bytes[0x20] = 0xDA;
1638            bytes[0x21] = 0x3F;
1639
1640            assert!(plugin
1641                .parse_reader(Cursor::new(bytes), ParseOptions::whole_plugin())
1642                .is_ok());
1643
1644            let range = plugin.valid_light_form_id_range();
1645            assert_eq!(&0, range.start());
1646            assert_eq!(&0xFFF, range.end());
1647        }
1648
1649        #[test]
1650        fn is_valid_as_light_plugin_should_be_true_if_the_plugin_has_no_form_ids_outside_the_valid_range(
1651        ) {
1652            let mut plugin = Plugin::new(
1653                GameId::SkyrimSE,
1654                Path::new("testing-plugins/SkyrimSE/Data/Blank - Master Dependent.esm"),
1655            );
1656            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
1657
1658            assert!(plugin.is_valid_as_light_plugin().unwrap());
1659        }
1660
1661        #[test]
1662        fn is_valid_as_light_plugin_should_be_true_if_the_plugin_has_an_override_form_id_outside_the_valid_range(
1663        ) {
1664            let mut plugin = Plugin::new(
1665                GameId::SkyrimSE,
1666                Path::new("testing-plugins/SkyrimSE/Data/Blank - Master Dependent.esm"),
1667            );
1668            let mut bytes = read(plugin.path()).unwrap();
1669
1670            assert_eq!(0xF0, bytes[0x7A]);
1671            assert_eq!(0x0C, bytes[0x7B]);
1672            bytes[0x7A] = 0xFF;
1673            bytes[0x7B] = 0x07;
1674
1675            assert!(plugin
1676                .parse_reader(Cursor::new(bytes), ParseOptions::whole_plugin())
1677                .is_ok());
1678
1679            assert!(plugin.is_valid_as_light_plugin().unwrap());
1680        }
1681
1682        #[test]
1683        fn is_valid_as_light_plugin_should_be_false_if_the_plugin_has_a_new_form_id_greater_than_0xfff(
1684        ) {
1685            let mut plugin = Plugin::new(
1686                GameId::SkyrimSE,
1687                Path::new("testing-plugins/SkyrimSE/Data/Blank - Master Dependent.esm"),
1688            );
1689            let mut bytes = read(plugin.path()).unwrap();
1690
1691            assert_eq!(0xEB, bytes[0x386]);
1692            assert_eq!(0x0C, bytes[0x387]);
1693            bytes[0x386] = 0x00;
1694            bytes[0x387] = 0x10;
1695
1696            assert!(plugin
1697                .parse_reader(Cursor::new(bytes), ParseOptions::whole_plugin())
1698                .is_ok());
1699
1700            assert!(!plugin.is_valid_as_light_plugin().unwrap());
1701        }
1702    }
1703
1704    mod fallout3 {
1705        use super::super::*;
1706
1707        #[test]
1708        fn is_light_plugin_should_be_false() {
1709            let plugin = Plugin::new(GameId::Fallout3, Path::new("Blank.esp"));
1710            assert!(!plugin.is_light_plugin());
1711            let plugin = Plugin::new(GameId::Fallout3, Path::new("Blank.esm"));
1712            assert!(!plugin.is_light_plugin());
1713            let plugin = Plugin::new(GameId::Fallout3, Path::new("Blank.esl"));
1714            assert!(!plugin.is_light_plugin());
1715        }
1716
1717        #[test]
1718        fn is_medium_plugin_should_be_false() {
1719            let plugin = Plugin::new(GameId::Fallout3, Path::new("Blank.esp"));
1720            assert!(!plugin.is_medium_plugin());
1721            let plugin = Plugin::new(GameId::Fallout3, Path::new("Blank.esm"));
1722            assert!(!plugin.is_medium_plugin());
1723            let plugin = Plugin::new(GameId::Fallout3, Path::new("Blank.esl"));
1724            assert!(!plugin.is_medium_plugin());
1725        }
1726
1727        #[test]
1728        fn valid_light_form_id_range_should_be_empty() {
1729            let mut plugin = Plugin::new(
1730                GameId::Fallout3,
1731                Path::new("testing-plugins/Skyrim/Data/Blank - Master Dependent.esm"),
1732            );
1733            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
1734
1735            let range = plugin.valid_light_form_id_range();
1736            assert_eq!(&0, range.start());
1737            assert_eq!(&0, range.end());
1738        }
1739
1740        #[test]
1741        fn is_valid_as_light_plugin_should_always_be_false() {
1742            let mut plugin = Plugin::new(
1743                GameId::Fallout3,
1744                Path::new("testing-plugins/Skyrim/Data/Blank - Master Dependent.esm"),
1745            );
1746            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
1747            assert!(!plugin.is_valid_as_light_plugin().unwrap());
1748        }
1749    }
1750
1751    mod falloutnv {
1752        use super::super::*;
1753
1754        #[test]
1755        fn is_light_plugin_should_be_false() {
1756            let plugin = Plugin::new(GameId::FalloutNV, Path::new("Blank.esp"));
1757            assert!(!plugin.is_light_plugin());
1758            let plugin = Plugin::new(GameId::FalloutNV, Path::new("Blank.esm"));
1759            assert!(!plugin.is_light_plugin());
1760            let plugin = Plugin::new(GameId::FalloutNV, Path::new("Blank.esl"));
1761            assert!(!plugin.is_light_plugin());
1762        }
1763
1764        #[test]
1765        fn is_medium_plugin_should_be_false() {
1766            let plugin = Plugin::new(GameId::FalloutNV, Path::new("Blank.esp"));
1767            assert!(!plugin.is_medium_plugin());
1768            let plugin = Plugin::new(GameId::FalloutNV, Path::new("Blank.esm"));
1769            assert!(!plugin.is_medium_plugin());
1770            let plugin = Plugin::new(GameId::FalloutNV, Path::new("Blank.esl"));
1771            assert!(!plugin.is_medium_plugin());
1772        }
1773
1774        #[test]
1775        fn valid_light_form_id_range_should_be_empty() {
1776            let mut plugin = Plugin::new(
1777                GameId::Fallout3,
1778                Path::new("testing-plugins/Skyrim/Data/Blank - Master Dependent.esm"),
1779            );
1780            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
1781
1782            let range = plugin.valid_light_form_id_range();
1783            assert_eq!(&0, range.start());
1784            assert_eq!(&0, range.end());
1785        }
1786
1787        #[test]
1788        fn is_valid_as_light_plugin_should_always_be_false() {
1789            let mut plugin = Plugin::new(
1790                GameId::FalloutNV,
1791                Path::new("testing-plugins/Skyrim/Data/Blank - Master Dependent.esm"),
1792            );
1793            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
1794            assert!(!plugin.is_valid_as_light_plugin().unwrap());
1795        }
1796    }
1797
1798    mod fallout4 {
1799        use super::*;
1800
1801        #[test]
1802        fn is_master_file_should_use_file_extension_and_flag() {
1803            let tmp_dir = tempdir().unwrap();
1804
1805            let master_flagged_esp = tmp_dir.path().join("Blank.esp");
1806            copy(
1807                Path::new("testing-plugins/Skyrim/Data/Blank.esm"),
1808                &master_flagged_esp,
1809            )
1810            .unwrap();
1811
1812            let plugin = Plugin::new(GameId::Fallout4, Path::new("Blank.esp"));
1813            assert!(!plugin.is_master_file());
1814
1815            let plugin = Plugin::new(GameId::Fallout4, Path::new("Blank.esm"));
1816            assert!(plugin.is_master_file());
1817
1818            let plugin = Plugin::new(GameId::Fallout4, Path::new("Blank.esl"));
1819            assert!(plugin.is_master_file());
1820
1821            let mut plugin = Plugin::new(
1822                GameId::Fallout4,
1823                Path::new("testing-plugins/Skyrim/Data/Blank.esp"),
1824            );
1825            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
1826            assert!(!plugin.is_master_file());
1827
1828            let mut plugin = Plugin::new(GameId::Fallout4, &master_flagged_esp);
1829            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
1830            assert!(plugin.is_master_file());
1831        }
1832
1833        #[test]
1834        fn is_light_plugin_should_be_true_for_plugins_with_an_esl_file_extension() {
1835            let plugin = Plugin::new(GameId::Fallout4, Path::new("Blank.esp"));
1836            assert!(!plugin.is_light_plugin());
1837            let plugin = Plugin::new(GameId::Fallout4, Path::new("Blank.esm"));
1838            assert!(!plugin.is_light_plugin());
1839            let plugin = Plugin::new(GameId::Fallout4, Path::new("Blank.esl"));
1840            assert!(plugin.is_light_plugin());
1841        }
1842
1843        #[test]
1844        fn is_light_plugin_should_be_true_for_a_ghosted_esl_file() {
1845            let plugin = Plugin::new(GameId::Fallout4, Path::new("Blank.esl.ghost"));
1846            assert!(plugin.is_light_plugin());
1847        }
1848
1849        #[test]
1850        fn is_medium_plugin_should_be_false() {
1851            let plugin = Plugin::new(GameId::Fallout4, Path::new("Blank.esp"));
1852            assert!(!plugin.is_medium_plugin());
1853            let plugin = Plugin::new(GameId::Fallout4, Path::new("Blank.esm"));
1854            assert!(!plugin.is_medium_plugin());
1855            let plugin = Plugin::new(GameId::Fallout4, Path::new("Blank.esl"));
1856            assert!(!plugin.is_medium_plugin());
1857        }
1858
1859        #[test]
1860        fn valid_light_form_id_range_should_be_1_to_0xfff_if_hedr_version_is_less_than_1() {
1861            let mut plugin = Plugin::new(
1862                GameId::Fallout4,
1863                Path::new("testing-plugins/SkyrimSE/Data/Blank - Master Dependent.esm"),
1864            );
1865            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
1866
1867            let range = plugin.valid_light_form_id_range();
1868            assert_eq!(&0x800, range.start());
1869            assert_eq!(&0xFFF, range.end());
1870        }
1871
1872        #[test]
1873        fn valid_light_form_id_range_should_be_1_to_0xfff_if_hedr_version_is_1_or_greater() {
1874            let mut plugin = Plugin::new(
1875                GameId::Fallout4,
1876                Path::new("testing-plugins/SkyrimSE/Data/Blank - Master Dependent.esm"),
1877            );
1878            let mut bytes = read(plugin.path()).unwrap();
1879
1880            assert_eq!(0xD7, bytes[0x1E]);
1881            assert_eq!(0xA3, bytes[0x1F]);
1882            assert_eq!(0x70, bytes[0x20]);
1883            assert_eq!(0x3F, bytes[0x21]);
1884            bytes[0x1E] = 0;
1885            bytes[0x1F] = 0;
1886            bytes[0x20] = 0x80;
1887            bytes[0x21] = 0x3F;
1888
1889            assert!(plugin
1890                .parse_reader(Cursor::new(bytes), ParseOptions::whole_plugin())
1891                .is_ok());
1892
1893            let range = plugin.valid_light_form_id_range();
1894            assert_eq!(&1, range.start());
1895            assert_eq!(&0xFFF, range.end());
1896        }
1897
1898        #[test]
1899        fn is_valid_as_light_plugin_should_be_true_if_the_plugin_has_no_form_ids_outside_the_valid_range(
1900        ) {
1901            let mut plugin = Plugin::new(
1902                GameId::Fallout4,
1903                Path::new("testing-plugins/SkyrimSE/Data/Blank - Master Dependent.esm"),
1904            );
1905            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
1906
1907            assert!(plugin.is_valid_as_light_plugin().unwrap());
1908        }
1909    }
1910
1911    mod starfield {
1912        use super::*;
1913
1914        #[test]
1915        fn parse_file_should_succeed() {
1916            let mut plugin = Plugin::new(
1917                GameId::Starfield,
1918                Path::new("testing-plugins/Starfield/Data/Blank.full.esm"),
1919            );
1920
1921            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
1922
1923            match plugin.data.record_ids {
1924                RecordIds::FormIds(ids) => assert_eq!(10, ids.len()),
1925                _ => panic!("Expected raw FormIDs"),
1926            }
1927        }
1928
1929        #[test]
1930        fn resolve_record_ids_should_resolve_unresolved_form_ids() {
1931            let mut plugin = Plugin::new(
1932                GameId::Starfield,
1933                Path::new("testing-plugins/Starfield/Data/Blank.full.esm"),
1934            );
1935
1936            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
1937
1938            assert!(plugin.resolve_record_ids(&[]).is_ok());
1939
1940            match plugin.data.record_ids {
1941                RecordIds::Resolved(ids) => assert_eq!(10, ids.len()),
1942                _ => panic!("Expected resolved FormIDs"),
1943            }
1944        }
1945
1946        #[test]
1947        fn resolve_record_ids_should_do_nothing_if_form_ids_are_already_resolved() {
1948            let mut plugin = Plugin::new(
1949                GameId::Starfield,
1950                Path::new("testing-plugins/Starfield/Data/Blank.full.esm"),
1951            );
1952
1953            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
1954
1955            assert!(plugin.resolve_record_ids(&[]).is_ok());
1956
1957            let vec_ptr = match &plugin.data.record_ids {
1958                RecordIds::Resolved(ids) => ids.as_ptr(),
1959                _ => panic!("Expected resolved FormIDs"),
1960            };
1961
1962            assert!(plugin.resolve_record_ids(&[]).is_ok());
1963
1964            let vec_ptr_2 = match &plugin.data.record_ids {
1965                RecordIds::Resolved(ids) => ids.as_ptr(),
1966                _ => panic!("Expected resolved FormIDs"),
1967            };
1968
1969            assert_eq!(vec_ptr, vec_ptr_2);
1970        }
1971
1972        #[test]
1973        fn scale_should_return_full_for_a_full_plugin() {
1974            let mut plugin = Plugin::new(
1975                GameId::Starfield,
1976                Path::new("testing-plugins/Starfield/Data/Blank.full.esm"),
1977            );
1978            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
1979
1980            assert_eq!(PluginScale::Full, plugin.scale());
1981        }
1982
1983        #[test]
1984        fn scale_should_return_medium_for_a_medium_plugin() {
1985            let mut plugin = Plugin::new(
1986                GameId::Starfield,
1987                Path::new("testing-plugins/Starfield/Data/Blank.medium.esm"),
1988            );
1989            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
1990
1991            assert_eq!(PluginScale::Medium, plugin.scale());
1992        }
1993
1994        #[test]
1995        fn scale_should_return_small_for_a_small_plugin() {
1996            let mut plugin = Plugin::new(
1997                GameId::Starfield,
1998                Path::new("testing-plugins/Starfield/Data/Blank.small.esm"),
1999            );
2000            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
2001
2002            assert_eq!(PluginScale::Small, plugin.scale());
2003        }
2004
2005        #[test]
2006        fn is_master_file_should_use_file_extension_and_flag() {
2007            let tmp_dir = tempdir().unwrap();
2008
2009            let master_flagged_esp = tmp_dir.path().join("Blank.esp");
2010            copy(
2011                Path::new("testing-plugins/Starfield/Data/Blank.full.esm"),
2012                &master_flagged_esp,
2013            )
2014            .unwrap();
2015
2016            let plugin = Plugin::new(GameId::Starfield, Path::new("Blank.esp"));
2017            assert!(!plugin.is_master_file());
2018
2019            let plugin = Plugin::new(GameId::Starfield, Path::new("Blank.esm"));
2020            assert!(plugin.is_master_file());
2021
2022            let plugin = Plugin::new(GameId::Starfield, Path::new("Blank.esl"));
2023            assert!(plugin.is_master_file());
2024
2025            let mut plugin = Plugin::new(
2026                GameId::Starfield,
2027                Path::new("testing-plugins/Starfield/Data/Blank.esp"),
2028            );
2029            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
2030            assert!(!plugin.is_master_file());
2031
2032            let mut plugin = Plugin::new(GameId::Starfield, &master_flagged_esp);
2033            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
2034            assert!(plugin.is_master_file());
2035        }
2036
2037        #[test]
2038        fn is_light_plugin_should_be_true_for_plugins_with_an_esl_file_extension_if_the_update_flag_is_not_set(
2039        ) {
2040            // The flag won't be set because no data is loaded.
2041            let plugin = Plugin::new(GameId::Starfield, Path::new("Blank.esp"));
2042            assert!(!plugin.is_light_plugin());
2043            let plugin = Plugin::new(GameId::Starfield, Path::new("Blank.esm"));
2044            assert!(!plugin.is_light_plugin());
2045            let plugin = Plugin::new(GameId::Starfield, Path::new("Blank.esl"));
2046            assert!(plugin.is_light_plugin());
2047        }
2048
2049        #[test]
2050        fn is_light_plugin_should_be_false_for_a_plugin_with_the_update_flag_set_and_an_esl_extension(
2051        ) {
2052            let tmp_dir = tempdir().unwrap();
2053            let esl_path = tmp_dir.path().join("Blank.esl");
2054            copy(
2055                Path::new("testing-plugins/Starfield/Data/Blank - Override.esp"),
2056                &esl_path,
2057            )
2058            .unwrap();
2059
2060            let mut plugin = Plugin::new(GameId::Starfield, &esl_path);
2061            plugin.parse_file(ParseOptions::header_only()).unwrap();
2062
2063            assert!(!plugin.is_light_plugin());
2064        }
2065
2066        #[test]
2067        fn is_light_plugin_should_be_true_for_a_ghosted_esl_file() {
2068            let plugin = Plugin::new(GameId::Starfield, Path::new("Blank.esl.ghost"));
2069            assert!(plugin.is_light_plugin());
2070        }
2071
2072        #[test]
2073        fn is_light_plugin_should_be_true_for_a_plugin_with_the_light_flag_set() {
2074            let mut plugin = Plugin::new(
2075                GameId::Starfield,
2076                Path::new("testing-plugins/Starfield/Data/Blank.small.esm"),
2077            );
2078            plugin.parse_file(ParseOptions::header_only()).unwrap();
2079
2080            assert!(plugin.is_light_plugin());
2081        }
2082
2083        #[test]
2084        fn is_medium_plugin_should_be_false_for_a_plugin_without_the_medium_flag_set() {
2085            let plugin = Plugin::new(GameId::Starfield, Path::new("Blank.esp"));
2086            assert!(!plugin.is_medium_plugin());
2087            let plugin = Plugin::new(GameId::Starfield, Path::new("Blank.esm"));
2088            assert!(!plugin.is_medium_plugin());
2089            let plugin = Plugin::new(GameId::Starfield, Path::new("Blank.esl"));
2090            assert!(!plugin.is_medium_plugin());
2091        }
2092
2093        #[test]
2094        fn is_medium_plugin_should_be_true_for_a_plugin_with_the_medium_flag_set() {
2095            let mut plugin = Plugin::new(
2096                GameId::Starfield,
2097                Path::new("testing-plugins/Starfield/Data/Blank.medium.esm"),
2098            );
2099            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
2100            assert!(plugin.is_medium_plugin());
2101        }
2102
2103        #[test]
2104        fn is_medium_plugin_should_be_false_for_a_plugin_with_the_medium_and_light_flags_set() {
2105            let mut plugin = Plugin::new(
2106                GameId::Starfield,
2107                Path::new("testing-plugins/Starfield/Data/Blank.small.esm"),
2108            );
2109            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
2110            assert!(!plugin.is_medium_plugin());
2111        }
2112
2113        #[test]
2114        fn is_medium_plugin_should_be_false_for_an_esl_plugin_with_the_medium_flag_set() {
2115            let tmp_dir = tempdir().unwrap();
2116            let path = tmp_dir.path().join("Blank.esl");
2117            copy(
2118                Path::new("testing-plugins/Starfield/Data/Blank.medium.esm"),
2119                &path,
2120            )
2121            .unwrap();
2122
2123            let mut plugin = Plugin::new(GameId::Starfield, &path);
2124            assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
2125            assert!(!plugin.is_medium_plugin());
2126        }
2127
2128        #[test]
2129        fn is_update_plugin_should_be_true_for_a_plugin_with_the_update_flag_set_and_at_least_one_master_and_no_light_flag(
2130        ) {
2131            let mut plugin = Plugin::new(
2132                GameId::Starfield,
2133                Path::new("testing-plugins/Starfield/Data/Blank - Override.full.esm"),
2134            );
2135            plugin.parse_file(ParseOptions::header_only()).unwrap();
2136
2137            assert!(plugin.is_update_plugin());
2138        }
2139
2140        #[test]
2141        fn is_update_plugin_should_be_false_for_a_plugin_with_the_update_flag_set_and_no_masters() {
2142            let mut plugin = Plugin::new(GameId::Starfield, Path::new("Blank.esm"));
2143            let file_data = &[
2144                0x54, 0x45, 0x53, 0x34, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00,
2145                0x00, 0x00, 0xB2, 0x2E, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00,
2146            ];
2147            plugin.data.header_record =
2148                Record::read(&mut Cursor::new(file_data), GameId::Starfield, b"TES4").unwrap();
2149
2150            assert!(!plugin.is_update_plugin());
2151        }
2152
2153        #[test]
2154        fn is_update_plugin_should_be_false_for_a_plugin_with_the_update_and_light_flags_set() {
2155            let mut plugin = Plugin::new(
2156                GameId::Starfield,
2157                Path::new("testing-plugins/Starfield/Data/Blank - Override.small.esm"),
2158            );
2159            plugin.parse_file(ParseOptions::header_only()).unwrap();
2160
2161            assert!(!plugin.is_update_plugin());
2162        }
2163
2164        #[test]
2165        fn is_update_plugin_should_be_false_for_a_plugin_with_the_update_and_medium_flags_set() {
2166            let mut plugin = Plugin::new(
2167                GameId::Starfield,
2168                Path::new("testing-plugins/Starfield/Data/Blank - Override.medium.esm"),
2169            );
2170            plugin.parse_file(ParseOptions::header_only()).unwrap();
2171
2172            assert!(!plugin.is_update_plugin());
2173        }
2174
2175        #[test]
2176        fn is_blueprint_plugin_should_be_false_for_a_plugin_without_the_blueprint_flag_set() {
2177            let mut plugin = Plugin::new(
2178                GameId::Starfield,
2179                Path::new("testing-plugins/Starfield/Data/Blank.full.esm"),
2180            );
2181            plugin.parse_file(ParseOptions::header_only()).unwrap();
2182
2183            assert!(!plugin.is_blueprint_plugin());
2184        }
2185
2186        #[test]
2187        fn is_blueprint_plugin_should_be_false_for_a_plugin_with_the_blueprint_flag_set() {
2188            let mut plugin = Plugin::new(
2189                GameId::Starfield,
2190                Path::new("testing-plugins/Starfield/Data/Blank.full.esm"),
2191            );
2192
2193            let mut bytes = read(plugin.path()).unwrap();
2194
2195            assert_eq!(0, bytes[0x09]);
2196            bytes[0x09] = 8;
2197
2198            assert!(plugin
2199                .parse_reader(Cursor::new(bytes), ParseOptions::header_only())
2200                .is_ok());
2201
2202            assert!(plugin.is_blueprint_plugin());
2203        }
2204
2205        #[test]
2206        fn count_override_records_should_error_if_form_ids_are_unresolved() {
2207            let mut plugin = Plugin::new(
2208                GameId::Starfield,
2209                Path::new("testing-plugins/Starfield/Data/Blank.full.esm"),
2210            );
2211
2212            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
2213
2214            match plugin.count_override_records().unwrap_err() {
2215                Error::UnresolvedRecordIds(path) => assert_eq!(plugin.path, path),
2216                _ => panic!("Expected unresolved FormIDs error"),
2217            }
2218        }
2219
2220        #[test]
2221        fn count_override_records_should_succeed_if_form_ids_are_resolved() {
2222            let mut plugin = Plugin::new(
2223                GameId::Starfield,
2224                Path::new("testing-plugins/Starfield/Data/Blank.full.esm"),
2225            );
2226
2227            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
2228
2229            assert!(plugin.resolve_record_ids(&[]).is_ok());
2230
2231            assert_eq!(0, plugin.count_override_records().unwrap());
2232        }
2233
2234        #[test]
2235        fn overlaps_with_should_error_if_form_ids_in_self_are_unresolved() {
2236            let mut plugin1 = Plugin::new(
2237                GameId::Starfield,
2238                Path::new("testing-plugins/Starfield/Data/Blank.full.esm"),
2239            );
2240            let mut plugin2 = Plugin::new(
2241                GameId::Starfield,
2242                Path::new("testing-plugins/Starfield/Data/Blank - Override.esp"),
2243            );
2244            let plugin1_metadata = PluginMetadata {
2245                filename: plugin1.filename().unwrap(),
2246                scale: plugin1.scale(),
2247                record_ids: Box::new([]),
2248            };
2249
2250            assert!(plugin1.parse_file(ParseOptions::whole_plugin()).is_ok());
2251            assert!(plugin2.parse_file(ParseOptions::whole_plugin()).is_ok());
2252            assert!(plugin2.resolve_record_ids(&[plugin1_metadata]).is_ok());
2253
2254            match plugin1.overlaps_with(&plugin2).unwrap_err() {
2255                Error::UnresolvedRecordIds(path) => assert_eq!(plugin1.path, path),
2256                _ => panic!("Expected unresolved FormIDs error"),
2257            }
2258        }
2259
2260        #[test]
2261        fn overlaps_with_should_error_if_form_ids_in_other_are_unresolved() {
2262            let mut plugin1 = Plugin::new(
2263                GameId::Starfield,
2264                Path::new("testing-plugins/Starfield/Data/Blank.full.esm"),
2265            );
2266            let mut plugin2 = Plugin::new(
2267                GameId::Starfield,
2268                Path::new("testing-plugins/Starfield/Data/Blank - Override.esp"),
2269            );
2270
2271            assert!(plugin1.parse_file(ParseOptions::whole_plugin()).is_ok());
2272            assert!(plugin2.parse_file(ParseOptions::whole_plugin()).is_ok());
2273            assert!(plugin1.resolve_record_ids(&[]).is_ok());
2274
2275            match plugin1.overlaps_with(&plugin2).unwrap_err() {
2276                Error::UnresolvedRecordIds(path) => assert_eq!(plugin2.path, path),
2277                _ => panic!("Expected unresolved FormIDs error"),
2278            }
2279        }
2280
2281        #[test]
2282        fn overlaps_with_should_succeed_if_form_ids_are_resolved() {
2283            let mut plugin1 = Plugin::new(
2284                GameId::Starfield,
2285                Path::new("testing-plugins/Starfield/Data/Blank.full.esm"),
2286            );
2287            let mut plugin2 = Plugin::new(
2288                GameId::Starfield,
2289                Path::new("testing-plugins/Starfield/Data/Blank - Override.esp"),
2290            );
2291            let plugin1_metadata = PluginMetadata {
2292                filename: plugin1.filename().unwrap(),
2293                scale: plugin1.scale(),
2294                record_ids: Box::new([]),
2295            };
2296
2297            assert!(plugin1.parse_file(ParseOptions::whole_plugin()).is_ok());
2298            assert!(plugin2.parse_file(ParseOptions::whole_plugin()).is_ok());
2299            assert!(plugin1.resolve_record_ids(&[]).is_ok());
2300            assert!(plugin2.resolve_record_ids(&[plugin1_metadata]).is_ok());
2301
2302            assert!(plugin1.overlaps_with(&plugin2).unwrap());
2303        }
2304
2305        #[test]
2306        fn overlap_size_should_error_if_form_ids_in_self_are_unresolved() {
2307            let mut plugin1 = Plugin::new(
2308                GameId::Starfield,
2309                Path::new("testing-plugins/Starfield/Data/Blank.full.esm"),
2310            );
2311            let mut plugin2 = Plugin::new(
2312                GameId::Starfield,
2313                Path::new("testing-plugins/Starfield/Data/Blank - Override.esp"),
2314            );
2315            let plugin1_metadata = PluginMetadata {
2316                filename: plugin1.filename().unwrap(),
2317                scale: plugin1.scale(),
2318                record_ids: Box::new([]),
2319            };
2320
2321            assert!(plugin1.parse_file(ParseOptions::whole_plugin()).is_ok());
2322            assert!(plugin2.parse_file(ParseOptions::whole_plugin()).is_ok());
2323            assert!(plugin2.resolve_record_ids(&[plugin1_metadata]).is_ok());
2324
2325            match plugin1.overlap_size(&[&plugin2]).unwrap_err() {
2326                Error::UnresolvedRecordIds(path) => assert_eq!(plugin1.path, path),
2327                _ => panic!("Expected unresolved FormIDs error"),
2328            }
2329        }
2330
2331        #[test]
2332        fn overlap_size_should_error_if_form_ids_in_other_are_unresolved() {
2333            let mut plugin1 = Plugin::new(
2334                GameId::Starfield,
2335                Path::new("testing-plugins/Starfield/Data/Blank.full.esm"),
2336            );
2337            let mut plugin2 = Plugin::new(
2338                GameId::Starfield,
2339                Path::new("testing-plugins/Starfield/Data/Blank - Override.esp"),
2340            );
2341
2342            assert!(plugin1.parse_file(ParseOptions::whole_plugin()).is_ok());
2343            assert!(plugin2.parse_file(ParseOptions::whole_plugin()).is_ok());
2344            assert!(plugin1.resolve_record_ids(&[]).is_ok());
2345
2346            match plugin1.overlap_size(&[&plugin2]).unwrap_err() {
2347                Error::UnresolvedRecordIds(path) => assert_eq!(plugin2.path, path),
2348                _ => panic!("Expected unresolved FormIDs error"),
2349            }
2350        }
2351
2352        #[test]
2353        fn overlap_size_should_succeed_if_form_ids_are_resolved() {
2354            let mut plugin1 = Plugin::new(
2355                GameId::Starfield,
2356                Path::new("testing-plugins/Starfield/Data/Blank.full.esm"),
2357            );
2358            let mut plugin2 = Plugin::new(
2359                GameId::Starfield,
2360                Path::new("testing-plugins/Starfield/Data/Blank - Override.esp"),
2361            );
2362            let plugin1_metadata = PluginMetadata {
2363                filename: plugin1.filename().unwrap(),
2364                scale: plugin1.scale(),
2365                record_ids: Box::new([]),
2366            };
2367
2368            assert!(plugin1.parse_file(ParseOptions::whole_plugin()).is_ok());
2369            assert!(plugin2.parse_file(ParseOptions::whole_plugin()).is_ok());
2370            assert!(plugin1.resolve_record_ids(&[]).is_ok());
2371            assert!(plugin2.resolve_record_ids(&[plugin1_metadata]).is_ok());
2372
2373            assert_eq!(1, plugin1.overlap_size(&[&plugin2]).unwrap());
2374        }
2375
2376        #[test]
2377        fn valid_light_form_id_range_should_be_0_to_0xfff() {
2378            let plugin = Plugin::new(GameId::Starfield, Path::new("Blank.esp"));
2379
2380            let range = plugin.valid_light_form_id_range();
2381            assert_eq!(&0, range.start());
2382            assert_eq!(&0xFFF, range.end());
2383        }
2384
2385        #[test]
2386        fn valid_medium_form_id_range_should_be_0_to_0xffff() {
2387            let mut plugin = Plugin::new(
2388                GameId::Starfield,
2389                Path::new("testing-plugins/Starfield/Data/Blank.small.esm"),
2390            );
2391            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
2392
2393            let range = plugin.valid_medium_form_id_range();
2394            assert_eq!(&0, range.start());
2395            assert_eq!(&0xFFFF, range.end());
2396        }
2397
2398        #[test]
2399        fn is_valid_as_light_plugin_should_be_false_if_form_ids_are_unresolved() {
2400            let mut plugin = Plugin::new(
2401                GameId::Starfield,
2402                Path::new("testing-plugins/Starfield/Data/Blank.small.esm"),
2403            );
2404            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
2405
2406            match plugin.is_valid_as_light_plugin().unwrap_err() {
2407                Error::UnresolvedRecordIds(path) => assert_eq!(plugin.path, path),
2408                _ => panic!("Expected unresolved FormIDs error"),
2409            }
2410        }
2411
2412        #[test]
2413        fn is_valid_as_light_plugin_should_be_true_if_the_plugin_has_no_form_ids_outside_the_valid_range(
2414        ) {
2415            let mut plugin = Plugin::new(
2416                GameId::Starfield,
2417                Path::new("testing-plugins/Starfield/Data/Blank.full.esm"),
2418            );
2419            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
2420            assert!(plugin.resolve_record_ids(&[]).is_ok());
2421
2422            assert!(plugin.is_valid_as_light_plugin().unwrap());
2423        }
2424
2425        #[test]
2426        fn is_valid_as_medium_plugin_should_be_false_if_form_ids_are_unresolved() {
2427            let mut plugin = Plugin::new(
2428                GameId::Starfield,
2429                Path::new("testing-plugins/Starfield/Data/Blank.medium.esm"),
2430            );
2431            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
2432
2433            match plugin.is_valid_as_medium_plugin().unwrap_err() {
2434                Error::UnresolvedRecordIds(path) => assert_eq!(plugin.path, path),
2435                _ => panic!("Expected unresolved FormIDs error"),
2436            }
2437        }
2438
2439        #[test]
2440        fn is_valid_as_medium_plugin_should_be_true_if_the_plugin_has_no_form_ids_outside_the_valid_range(
2441        ) {
2442            let mut plugin = Plugin::new(
2443                GameId::Starfield,
2444                Path::new("testing-plugins/Starfield/Data/Blank.medium.esm"),
2445            );
2446            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
2447            assert!(plugin.resolve_record_ids(&[]).is_ok());
2448
2449            assert!(plugin.is_valid_as_medium_plugin().unwrap());
2450        }
2451
2452        #[test]
2453        fn is_valid_as_update_plugin_should_be_false_if_form_ids_are_unresolved() {
2454            let mut plugin = Plugin::new(
2455                GameId::Starfield,
2456                Path::new("testing-plugins/Starfield/Data/Blank - Override.esp"),
2457            );
2458            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
2459
2460            match plugin.is_valid_as_update_plugin().unwrap_err() {
2461                Error::UnresolvedRecordIds(path) => assert_eq!(plugin.path, path),
2462                _ => panic!("Expected unresolved FormIDs error"),
2463            }
2464        }
2465
2466        #[test]
2467        fn is_valid_as_update_plugin_should_be_true_if_the_plugin_has_no_new_records() {
2468            let master_metadata = PluginMetadata {
2469                filename: "Blank.full.esm".to_owned(),
2470                scale: PluginScale::Full,
2471                record_ids: Box::new([]),
2472            };
2473            let mut plugin = Plugin::new(
2474                GameId::Starfield,
2475                Path::new("testing-plugins/Starfield/Data/Blank - Override.esp"),
2476            );
2477            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
2478            assert!(plugin.resolve_record_ids(&[master_metadata]).is_ok());
2479
2480            assert!(plugin.is_valid_as_update_plugin().unwrap());
2481        }
2482
2483        #[test]
2484        fn is_valid_as_update_plugin_should_be_false_if_the_plugin_has_new_records() {
2485            let master_metadata = PluginMetadata {
2486                filename: "Blank.full.esm".to_owned(),
2487                scale: PluginScale::Full,
2488                record_ids: Box::new([]),
2489            };
2490            let mut plugin = Plugin::new(
2491                GameId::Starfield,
2492                Path::new("testing-plugins/Starfield/Data/Blank.full.esm"),
2493            );
2494            assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_ok());
2495            assert!(plugin.resolve_record_ids(&[master_metadata]).is_ok());
2496
2497            assert!(!plugin.is_valid_as_update_plugin().unwrap());
2498        }
2499
2500        #[test]
2501        fn plugins_metadata_should_return_plugin_names_and_scales() {
2502            let mut plugin1 = Plugin::new(
2503                GameId::Starfield,
2504                Path::new("testing-plugins/Starfield/Data/Blank.full.esm"),
2505            );
2506            let mut plugin2 = Plugin::new(
2507                GameId::Starfield,
2508                Path::new("testing-plugins/Starfield/Data/Blank.medium.esm"),
2509            );
2510            let mut plugin3 = Plugin::new(
2511                GameId::Starfield,
2512                Path::new("testing-plugins/Starfield/Data/Blank.small.esm"),
2513            );
2514            assert!(plugin1.parse_file(ParseOptions::header_only()).is_ok());
2515            assert!(plugin2.parse_file(ParseOptions::header_only()).is_ok());
2516            assert!(plugin3.parse_file(ParseOptions::header_only()).is_ok());
2517
2518            let metadata = plugins_metadata(&[&plugin1, &plugin2, &plugin3]).unwrap();
2519
2520            assert_eq!(
2521                vec![
2522                    PluginMetadata {
2523                        filename: "Blank.full.esm".to_owned(),
2524                        scale: PluginScale::Full,
2525                        record_ids: Box::new([]),
2526                    },
2527                    PluginMetadata {
2528                        filename: "Blank.medium.esm".to_owned(),
2529                        scale: PluginScale::Medium,
2530                        record_ids: Box::new([]),
2531                    },
2532                    PluginMetadata {
2533                        filename: "Blank.small.esm".to_owned(),
2534                        scale: PluginScale::Small,
2535                        record_ids: Box::new([]),
2536                    },
2537                ],
2538                metadata
2539            );
2540        }
2541
2542        #[test]
2543        fn hashed_parent_should_use_full_object_index_mask_for_games_other_than_starfield() {
2544            let metadata = PluginMetadata {
2545                filename: "a".to_owned(),
2546                scale: PluginScale::Full,
2547                record_ids: Box::new([]),
2548            };
2549
2550            let plugin = hashed_parent(GameId::SkyrimSE, &metadata);
2551
2552            assert_eq!(u32::from(ObjectIndexMask::Full), plugin.mod_index_mask);
2553            assert_eq!(u32::from(ObjectIndexMask::Full), plugin.object_index_mask);
2554
2555            let metadata = PluginMetadata {
2556                filename: "a".to_owned(),
2557                scale: PluginScale::Medium,
2558                record_ids: Box::new([]),
2559            };
2560
2561            let plugin = hashed_parent(GameId::SkyrimSE, &metadata);
2562
2563            assert_eq!(u32::from(ObjectIndexMask::Full), plugin.mod_index_mask);
2564            assert_eq!(u32::from(ObjectIndexMask::Full), plugin.object_index_mask);
2565
2566            let metadata = PluginMetadata {
2567                filename: "a".to_owned(),
2568                scale: PluginScale::Small,
2569                record_ids: Box::new([]),
2570            };
2571
2572            let plugin = hashed_parent(GameId::SkyrimSE, &metadata);
2573
2574            assert_eq!(u32::from(ObjectIndexMask::Full), plugin.mod_index_mask);
2575            assert_eq!(u32::from(ObjectIndexMask::Full), plugin.object_index_mask);
2576        }
2577
2578        #[test]
2579        fn hashed_parent_should_use_object_index_mask_matching_the_plugin_scale_for_starfield() {
2580            let metadata = PluginMetadata {
2581                filename: "a".to_owned(),
2582                scale: PluginScale::Full,
2583                record_ids: Box::new([]),
2584            };
2585
2586            let plugin = hashed_parent(GameId::Starfield, &metadata);
2587
2588            assert_eq!(u32::from(ObjectIndexMask::Full), plugin.mod_index_mask);
2589            assert_eq!(u32::from(ObjectIndexMask::Full), plugin.object_index_mask);
2590
2591            let metadata = PluginMetadata {
2592                filename: "a".to_owned(),
2593                scale: PluginScale::Medium,
2594                record_ids: Box::new([]),
2595            };
2596
2597            let plugin = hashed_parent(GameId::Starfield, &metadata);
2598
2599            assert_eq!(u32::from(ObjectIndexMask::Medium), plugin.mod_index_mask);
2600            assert_eq!(u32::from(ObjectIndexMask::Medium), plugin.object_index_mask);
2601
2602            let metadata = PluginMetadata {
2603                filename: "a".to_owned(),
2604                scale: PluginScale::Small,
2605                record_ids: Box::new([]),
2606            };
2607
2608            let plugin = hashed_parent(GameId::Starfield, &metadata);
2609
2610            assert_eq!(u32::from(ObjectIndexMask::Small), plugin.mod_index_mask);
2611            assert_eq!(u32::from(ObjectIndexMask::Small), plugin.object_index_mask);
2612        }
2613
2614        #[test]
2615        fn hashed_masters_should_use_vec_index_as_mod_index() {
2616            let masters = &["a".to_owned(), "b".to_owned(), "c".to_owned()];
2617            let hashed_masters = hashed_masters(masters);
2618
2619            assert_eq!(
2620                vec![
2621                    SourcePlugin::master("a", 0, ObjectIndexMask::Full),
2622                    SourcePlugin::master("b", 0x0100_0000, ObjectIndexMask::Full),
2623                    SourcePlugin::master("c", 0x0200_0000, ObjectIndexMask::Full),
2624                ],
2625                hashed_masters
2626            );
2627        }
2628
2629        #[test]
2630        fn hashed_masters_for_starfield_should_error_if_it_cannot_find_a_masters_metadata() {
2631            let masters = &["a".to_owned(), "b".to_owned(), "c".to_owned()];
2632            let metadata = &[
2633                PluginMetadata {
2634                    filename: masters[0].clone(),
2635                    scale: PluginScale::Full,
2636                    record_ids: Box::new([]),
2637                },
2638                PluginMetadata {
2639                    filename: masters[1].clone(),
2640                    scale: PluginScale::Full,
2641                    record_ids: Box::new([]),
2642                },
2643            ];
2644
2645            match hashed_masters_for_starfield(masters, metadata).unwrap_err() {
2646                Error::PluginMetadataNotFound(master) => assert_eq!(masters[2], master),
2647                _ => panic!("Expected plugin metadata not found error"),
2648            }
2649        }
2650
2651        #[test]
2652        fn hashed_masters_for_starfield_should_match_names_to_metadata_case_insensitively() {
2653            let masters = &["a".to_owned()];
2654            let metadata = &[PluginMetadata {
2655                filename: "A".to_owned(),
2656                scale: PluginScale::Full,
2657                record_ids: Box::new([]),
2658            }];
2659
2660            let hashed_masters = hashed_masters_for_starfield(masters, metadata).unwrap();
2661
2662            assert_eq!(
2663                vec![SourcePlugin::master(&masters[0], 0, ObjectIndexMask::Full),],
2664                hashed_masters
2665            );
2666        }
2667
2668        #[test]
2669        fn hashed_masters_for_starfield_should_count_mod_indexes_separately_for_different_plugin_scales(
2670        ) {
2671            let masters: Vec<_> = (0u8..7u8).map(|i| i.to_string()).collect();
2672            let metadata = &[
2673                PluginMetadata {
2674                    filename: masters[0].clone(),
2675                    scale: PluginScale::Full,
2676                    record_ids: Box::new([]),
2677                },
2678                PluginMetadata {
2679                    filename: masters[1].clone(),
2680                    scale: PluginScale::Medium,
2681                    record_ids: Box::new([]),
2682                },
2683                PluginMetadata {
2684                    filename: masters[2].clone(),
2685                    scale: PluginScale::Small,
2686                    record_ids: Box::new([]),
2687                },
2688                PluginMetadata {
2689                    filename: masters[3].clone(),
2690                    scale: PluginScale::Medium,
2691                    record_ids: Box::new([]),
2692                },
2693                PluginMetadata {
2694                    filename: masters[4].clone(),
2695                    scale: PluginScale::Full,
2696                    record_ids: Box::new([]),
2697                },
2698                PluginMetadata {
2699                    filename: masters[5].clone(),
2700                    scale: PluginScale::Small,
2701                    record_ids: Box::new([]),
2702                },
2703                PluginMetadata {
2704                    filename: masters[6].clone(),
2705                    scale: PluginScale::Small,
2706                    record_ids: Box::new([]),
2707                },
2708            ];
2709
2710            let hashed_masters = hashed_masters_for_starfield(&masters, metadata).unwrap();
2711
2712            assert_eq!(
2713                vec![
2714                    SourcePlugin::master(&masters[0], 0, ObjectIndexMask::Full),
2715                    SourcePlugin::master(&masters[1], 0xFD00_0000, ObjectIndexMask::Medium),
2716                    SourcePlugin::master(&masters[2], 0xFE00_0000, ObjectIndexMask::Small),
2717                    SourcePlugin::master(&masters[3], 0xFD01_0000, ObjectIndexMask::Medium),
2718                    SourcePlugin::master(&masters[4], 0x0100_0000, ObjectIndexMask::Full),
2719                    SourcePlugin::master(&masters[5], 0xFE00_1000, ObjectIndexMask::Small),
2720                    SourcePlugin::master(&masters[6], 0xFE00_2000, ObjectIndexMask::Small),
2721                ],
2722                hashed_masters
2723            );
2724        }
2725    }
2726
2727    fn write_invalid_plugin(path: &Path) {
2728        use std::io::Write;
2729        let mut file = File::create(path).unwrap();
2730        let bytes = [0; MAX_RECORD_HEADER_LENGTH];
2731        file.write_all(&bytes).unwrap();
2732    }
2733
2734    #[test]
2735    fn parse_file_should_error_if_plugin_does_not_exist() {
2736        let mut plugin = Plugin::new(GameId::Skyrim, Path::new("Blank.esm"));
2737
2738        assert!(plugin.parse_file(ParseOptions::whole_plugin()).is_err());
2739    }
2740
2741    #[test]
2742    fn parse_file_should_error_if_plugin_is_not_valid() {
2743        let mut plugin = Plugin::new(
2744            GameId::Oblivion,
2745            Path::new("testing-plugins/Oblivion/Data/Blank.bsa"),
2746        );
2747
2748        let result = plugin.parse_file(ParseOptions::whole_plugin());
2749        assert!(result.is_err());
2750        assert_eq!(
2751             "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\"",
2752             result.unwrap_err().to_string()
2753         );
2754    }
2755
2756    #[test]
2757    fn parse_file_header_only_should_fail_for_a_non_plugin_file() {
2758        let tmp_dir = tempdir().unwrap();
2759
2760        let path = tmp_dir.path().join("Invalid.esm");
2761        write_invalid_plugin(&path);
2762
2763        let mut plugin = Plugin::new(GameId::Skyrim, &path);
2764
2765        let result = plugin.parse_file(ParseOptions::header_only());
2766        assert!(result.is_err());
2767        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());
2768    }
2769
2770    #[test]
2771    fn parse_file_should_fail_for_a_non_plugin_file() {
2772        let tmp_dir = tempdir().unwrap();
2773
2774        let path = tmp_dir.path().join("Invalid.esm");
2775        write_invalid_plugin(&path);
2776
2777        let mut plugin = Plugin::new(GameId::Skyrim, &path);
2778
2779        let result = plugin.parse_file(ParseOptions::whole_plugin());
2780        assert!(result.is_err());
2781        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());
2782    }
2783
2784    #[test]
2785    fn is_valid_should_return_true_for_a_valid_plugin() {
2786        let is_valid = Plugin::is_valid(
2787            GameId::Skyrim,
2788            Path::new("testing-plugins/Skyrim/Data/Blank.esm"),
2789            ParseOptions::header_only(),
2790        );
2791
2792        assert!(is_valid);
2793    }
2794
2795    #[test]
2796    fn is_valid_should_return_false_for_an_invalid_plugin() {
2797        let is_valid = Plugin::is_valid(
2798            GameId::Skyrim,
2799            Path::new("testing-plugins/Oblivion/Data/Blank.bsa"),
2800            ParseOptions::header_only(),
2801        );
2802
2803        assert!(!is_valid);
2804    }
2805
2806    #[test]
2807    fn path_should_return_the_full_plugin_path() {
2808        let path = Path::new("Data/Blank.esm");
2809        let plugin = Plugin::new(GameId::Skyrim, path);
2810
2811        assert_eq!(path, plugin.path());
2812    }
2813
2814    #[test]
2815    fn filename_should_return_filename_in_given_path() {
2816        let plugin = Plugin::new(GameId::Skyrim, Path::new("Blank.esm"));
2817
2818        assert_eq!("Blank.esm", plugin.filename().unwrap());
2819
2820        let plugin = Plugin::new(GameId::Skyrim, Path::new("Blank.esp"));
2821
2822        assert_eq!("Blank.esp", plugin.filename().unwrap());
2823    }
2824
2825    #[test]
2826    fn filename_should_not_trim_dot_ghost_extension() {
2827        let plugin = Plugin::new(GameId::Skyrim, Path::new("Blank.esp.ghost"));
2828
2829        assert_eq!("Blank.esp.ghost", plugin.filename().unwrap());
2830    }
2831
2832    #[test]
2833    fn masters_should_be_empty_for_a_plugin_with_no_masters() {
2834        let mut plugin = Plugin::new(
2835            GameId::Skyrim,
2836            Path::new("testing-plugins/Skyrim/Data/Blank.esm"),
2837        );
2838
2839        assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
2840        assert_eq!(0, plugin.masters().unwrap().len());
2841    }
2842
2843    #[test]
2844    fn masters_should_not_be_empty_for_a_plugin_with_one_or_more_masters() {
2845        let mut plugin = Plugin::new(
2846            GameId::Skyrim,
2847            Path::new(
2848                "testing-plugins/Skyrim/Data/Blank - \
2849                  Master Dependent.esm",
2850            ),
2851        );
2852
2853        assert!(plugin.parse_file(ParseOptions::header_only()).is_ok());
2854
2855        let masters = plugin.masters().unwrap();
2856        assert_eq!(1, masters.len());
2857        assert_eq!("Blank.esm", masters[0]);
2858    }
2859
2860    #[test]
2861    fn masters_should_only_read_up_to_the_first_null_for_each_master() {
2862        let mut plugin = Plugin::new(
2863            GameId::Skyrim,
2864            Path::new("testing-plugins/Skyrim/Data/Blank - Master Dependent.esm"),
2865        );
2866
2867        let mut bytes = read(plugin.path()).unwrap();
2868
2869        assert_eq!(0x2E, bytes[0x43]);
2870        bytes[0x43] = 0;
2871
2872        assert!(plugin
2873            .parse_reader(Cursor::new(bytes), ParseOptions::whole_plugin())
2874            .is_ok());
2875
2876        assert_eq!("Blank", plugin.masters().unwrap()[0]);
2877    }
2878
2879    #[test]
2880    fn description_should_error_for_a_plugin_header_subrecord_that_is_too_small() {
2881        let mut plugin = Plugin::new(
2882            GameId::Morrowind,
2883            Path::new("testing-plugins/Morrowind/Data Files/Blank.esm"),
2884        );
2885
2886        let mut data =
2887            include_bytes!("../testing-plugins/Morrowind/Data Files/Blank.esm")[..0x20].to_vec();
2888        data[0x04] = 16;
2889        data[0x05] = 0;
2890        data[0x14] = 8;
2891        data[0x15] = 0;
2892
2893        assert!(plugin
2894            .parse_reader(Cursor::new(data), ParseOptions::header_only())
2895            .is_ok());
2896
2897        let result = plugin.description();
2898        assert!(result.is_err());
2899        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());
2900    }
2901
2902    #[test]
2903    fn header_version_should_be_none_for_a_plugin_header_hedr_subrecord_that_is_too_small() {
2904        let mut plugin = Plugin::new(
2905            GameId::Morrowind,
2906            Path::new("testing-plugins/Morrowind/Data Files/Blank.esm"),
2907        );
2908
2909        let mut data =
2910            include_bytes!("../testing-plugins/Morrowind/Data Files/Blank.esm")[..0x1B].to_vec();
2911        data[0x04] = 11;
2912        data[0x05] = 0;
2913        data[0x14] = 3;
2914        data[0x15] = 0;
2915
2916        assert!(plugin
2917            .parse_reader(Cursor::new(data), ParseOptions::header_only())
2918            .is_ok());
2919        assert!(plugin.header_version().is_none());
2920    }
2921
2922    #[test]
2923    fn record_and_group_count_should_be_none_for_a_plugin_hedr_subrecord_that_is_too_small() {
2924        let mut plugin = Plugin::new(
2925            GameId::Morrowind,
2926            Path::new("testing-plugins/Morrowind/Data Files/Blank.esm"),
2927        );
2928
2929        let mut data =
2930            include_bytes!("../testing-plugins/Morrowind/Data Files/Blank.esm")[..0x140].to_vec();
2931        data[0x04] = 0x30;
2932        data[0x14] = 0x28;
2933
2934        assert!(plugin
2935            .parse_reader(Cursor::new(data), ParseOptions::header_only())
2936            .is_ok());
2937        assert!(plugin.record_and_group_count().is_none());
2938    }
2939
2940    #[test]
2941    fn resolve_form_ids_should_use_plugin_names_case_insensitively() {
2942        let raw_form_ids = vec![0x0000_0001, 0x0100_0002];
2943
2944        let masters = vec!["t\u{e9}st.esm".to_owned()];
2945        let form_ids = resolve_form_ids(
2946            GameId::SkyrimSE,
2947            &raw_form_ids,
2948            &PluginMetadata {
2949                filename: "Bl\u{e0}\u{f1}k.esp".to_owned(),
2950                scale: PluginScale::Full,
2951                record_ids: Box::new([]),
2952            },
2953            &masters,
2954            &[],
2955        )
2956        .unwrap();
2957
2958        let other_masters = vec!["T\u{c9}ST.ESM".to_owned()];
2959        let other_form_ids = resolve_form_ids(
2960            GameId::SkyrimSE,
2961            &raw_form_ids,
2962            &PluginMetadata {
2963                filename: "BL\u{c0}\u{d1}K.ESP".to_owned(),
2964                scale: PluginScale::Full,
2965                record_ids: Box::new([]),
2966            },
2967            &other_masters,
2968            &[],
2969        )
2970        .unwrap();
2971
2972        assert_eq!(form_ids, other_form_ids);
2973    }
2974}