Initial translation of libloot into Rust

This should be equivalent to libloot commit 55b341fc6c, except for:

- Error types are incomplete
- Setting a logging callback doesn't work properly
- Serialising metadata to YAML isn't implemented
- Tests are almost entirely missing
- C/C++ FFI doesn't exist
This commit is contained in:
Oliver Hamlet
2025-03-25 20:41:00 +00:00
commit 7c9558fd36
33 changed files with 8407 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/target
Generated
+1045
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "libloot"
version = "0.25.3"
edition = "2024"
license = "GPL-3.0"
[dependencies]
crc32fast = "1.4.2"
esplugin = "6.1.1"
libloadorder = { git = "https://github.com/Ortham/libloadorder.git", rev = "6245dbc6824c4751daef5ab01dfe7b9497f5446f" }
log = { version = "0.4.26", features = ["std"] }
loot-condition-interpreter = { git = "https://github.com/loot/loot-condition-interpreter.git", rev = "f7d9947fa434037743ce81e2d409184ec0bfb693" }
petgraph = "0.7.1"
fancy-regex = "0.14.0"
saphyr = "0.0.3"
saphyr-parser = "0.0.3"
unicase = "2.8.1"
rayon = "1.10.0"
[dev-dependencies]
tempfile = "3.17.1"
+674
View File
File diff suppressed because it is too large Load Diff
+49
View File
@@ -0,0 +1,49 @@
# libloot-rs
This is an **incomplete** and **experimental** reimplementation of [libloot](https://github.com/loot/libloot) using Rust instead of C++.
## Current status
Currently complete:
- [x] Public API types and function declarations (excluding errors)
- [ ] Public API error types
- [x] Public API doc comments
- [x] Library versioning
- [ ] Setting a logging callback
- [x] Parsing metadata from YAML
- [ ] Serialising metadata to YAML
- [x] Game-related functionality
- [x] Plugin-related functionality
- [x] Archive-related functionality
- [x] Metadata-related functionality (excluding writing YAML)
- [x] Sorting functionality
- [ ] Unit tests
- [ ] Integration tests
- [ ] C++ FFI
- [ ] Python FFI
The complete bits should match libloot commit [55b341fc6cbdccee52e42923c13a91eddb5ca97d](https://github.com/loot/libloot/commit/55b341fc6cbdccee52e42923c13a91eddb5ca97d), which is libloot v0.25.3 plus a few changes prompted by this translation.
## Build
Make sure you have [Rust](https://www.rust-lang.org/) installed.
To build the library, set the `LIBLOOT_REVISION` env var and then run Cargo. Using PowerShell:
```powershell
$env:LIBLOOT_REVISION = git rev-parse --short HEAD
cargo build --release
```
There aren't many tests, but those that exist can be run using:
```
cargo test
```
The public API has doc comments copied from libloot, and the API documentation can be built and viewed using:
```
cargo doc --open
```
+143
View File
@@ -0,0 +1,143 @@
use std::{
collections::{BTreeMap, BTreeSet},
hash::{DefaultHasher, Hash, Hasher},
io::{BufRead, Seek},
};
use crate::error::{GeneralError, InvalidArgumentError};
use super::parse::{to_u32, to_u64};
pub(super) const TYPE_ID: [u8; 4] = *b"BTDX";
const HEADER_SIZE: usize = 24;
const BA2_GENERAL_TYPE: [u8; 4] = *b"GNRL";
const BA2_TEXTURE_TYPE: [u8; 4] = *b"DX10";
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)]
struct Header {
type_id: [u8; 4],
version: u32,
archive_type: [u8; 4],
file_count: u32,
file_paths_offset: u64,
}
impl TryFrom<[u8; HEADER_SIZE - TYPE_ID.len()]> for Header {
type Error = InvalidArgumentError;
fn try_from(value: [u8; HEADER_SIZE - TYPE_ID.len()]) -> Result<Self, Self::Error> {
let header = Self {
type_id: TYPE_ID,
version: to_u32(&value),
archive_type: <[u8; 4]>::try_from(&value[4..8])
.expect("Bytes slice is large enough to hold a 4-byte array"),
file_count: to_u32(&value[12..]),
file_paths_offset: to_u64(&value[16..]),
};
// The header version is 1, 7 or 8 for Fallout 4 and 2 or 3 for Starfield.
if !matches!(header.version, 1 | 2 | 3 | 7 | 8) {
return Err(InvalidArgumentError {
message: "BA2 file header version is invalid".into(),
});
}
if !matches!(header.archive_type, BA2_GENERAL_TYPE | BA2_TEXTURE_TYPE) {
return Err(InvalidArgumentError {
message: "BA2 file header archive type is invalid".into(),
});
}
Ok(header)
}
}
pub(super) fn read_assets<T: BufRead + Seek>(
mut reader: T,
) -> Result<BTreeMap<u64, BTreeSet<u64>>, GeneralError> {
let mut header_buffer = [0; HEADER_SIZE - TYPE_ID.len()];
reader.read_exact(&mut header_buffer)?;
let header = Header::try_from(header_buffer)?;
let mut assets = BTreeMap::new();
reader.seek(std::io::SeekFrom::Start(header.file_paths_offset))?;
for _ in 0..header.file_count {
let mut length_buf = [0; 2];
reader.read_exact(&mut length_buf)?;
let path_length = u16::from_le_bytes(length_buf);
let mut file_path_bytes = vec![0; path_length.into()];
reader.read_exact(file_path_bytes.as_mut_slice())?;
normalise_path(&mut file_path_bytes);
let file_path_bytes = trim_slashes(&file_path_bytes);
let (folder_hash, file_hash) = rsplit_on(file_path_bytes, b'\\')
.map(|(folder_path, file_path)| (hash(&folder_path), hash(&file_path)))
.unwrap_or_else(|| (0, hash(&file_path_bytes)));
let file_hashes: &mut BTreeSet<u64> = assets.entry(folder_hash).or_default();
if !file_hashes.insert(file_hash) {
return Err(InvalidArgumentError {
message: format!(
"Unexpected collision for file name hash {:x} in set for folder name hash {:x}",
file_hash, folder_hash
),
}
.into());
}
}
Ok(assets)
}
fn normalise_path(path_bytes: &mut [u8]) {
for byte in path_bytes {
// Ignore any non-ASCII characters.
if *byte > 127 {
continue;
}
*byte = match byte {
b'/' => b'\\',
_ => byte.to_ascii_lowercase(),
}
}
}
fn trim_slashes(mut path_bytes: &[u8]) -> &[u8] {
while let [first, rest @ ..] = path_bytes {
if *first == b'\\' {
path_bytes = rest;
} else {
break;
}
}
while let [rest @ .., last] = path_bytes {
if *last == b'\\' {
path_bytes = rest;
} else {
break;
}
}
path_bytes
}
fn rsplit_on(slice: &[u8], needle: u8) -> Option<(&[u8], &[u8])> {
let index = slice.iter().rposition(|b| *b == needle)?;
Some((&slice[..index], &slice[index + 1..]))
}
fn hash<T: Hash>(value: &T) -> u64 {
let mut hasher = DefaultHasher::new();
value.hash(&mut hasher);
hasher.finish()
}
+208
View File
@@ -0,0 +1,208 @@
use std::{
collections::{BTreeMap, BTreeSet, btree_map::Entry},
io::BufRead,
};
use crate::error::{GeneralError, InvalidArgumentError};
use super::parse::{to_u32, to_u64, to_usize};
pub(super) const TYPE_ID: [u8; 4] = *b"BSA\0";
const HEADER_SIZE: usize = 36;
const FILE_RECORD_SIZE: usize = 16;
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)]
struct Header {
type_id: [u8; 4],
version: u32,
records_offset: u32,
archive_flags: u32,
folder_count: u32,
total_file_count: u32,
total_folder_names_length: u32,
total_file_names_length: u32,
content_type_flags: u32,
}
impl TryFrom<[u8; HEADER_SIZE - TYPE_ID.len()]> for Header {
type Error = InvalidArgumentError;
fn try_from(value: [u8; HEADER_SIZE - TYPE_ID.len()]) -> Result<Self, Self::Error> {
let header = Self {
type_id: TYPE_ID,
version: to_u32(&value),
records_offset: to_u32(&value[4..]),
archive_flags: to_u32(&value[8..]),
folder_count: to_u32(&value[12..]),
total_file_count: to_u32(&value[16..]),
total_folder_names_length: to_u32(&value[20..]),
total_file_names_length: to_u32(&value[24..]),
content_type_flags: to_u32(&value[28..]),
};
if header.records_offset != 36 {
return Err(InvalidArgumentError {
message: format!(
"BSA file has an invalid records offset value: {}",
header.records_offset
),
});
}
if (header.archive_flags & 0x40) != 0 {
return Err(InvalidArgumentError {
message: "BSA file uses big-endian numbers".into(),
});
}
Ok(header)
}
}
struct FolderRecord {
name_hash: u64,
file_count: u32,
file_records_offset: u32,
}
// Also used for v104 BSAs.
mod v103 {
use crate::archive::parse::{to_u32, to_u64};
use super::FolderRecord;
pub(super) const FOLDER_RECORD_SIZE: usize = 16;
pub(super) fn read_folder_record(value: &[u8]) -> FolderRecord {
assert!(value.len() >= FOLDER_RECORD_SIZE);
FolderRecord {
name_hash: to_u64(value),
file_count: to_u32(&value[8..]),
file_records_offset: to_u32(&value[12..]),
}
}
}
mod v105 {
use crate::archive::parse::{to_u32, to_u64};
use super::FolderRecord;
pub(super) const FOLDER_RECORD_SIZE: usize = 24;
pub(super) fn read_folder_record(value: &[u8]) -> FolderRecord {
assert!(value.len() >= FOLDER_RECORD_SIZE);
FolderRecord {
name_hash: to_u64(value),
file_count: to_u32(&value[8..]),
file_records_offset: to_u32(&value[16..]),
}
}
}
pub(super) fn read_assets<T: BufRead>(
mut reader: T,
) -> Result<BTreeMap<u64, BTreeSet<u64>>, GeneralError> {
let mut header_buffer = [0; HEADER_SIZE - TYPE_ID.len()];
reader.read_exact(&mut header_buffer)?;
let header = Header::try_from(header_buffer)?;
match header.version {
103 | 104 => read_assets_with_header::<T, { v103::FOLDER_RECORD_SIZE }>(
reader,
&header,
v103::read_folder_record,
),
105 => read_assets_with_header::<T, { v105::FOLDER_RECORD_SIZE }>(
reader,
&header,
v105::read_folder_record,
),
_ => Err(InvalidArgumentError {
message: format!("BSA file has an unrecognised version: {}", header.version),
}
.into()),
}
}
fn read_assets_with_header<T: BufRead, const U: usize>(
mut reader: T,
header: &Header,
read_folder_record: impl Fn(&[u8]) -> FolderRecord,
) -> Result<BTreeMap<u64, BTreeSet<u64>>, GeneralError> {
let mut folders_buffer: Vec<u8> = vec![0; U * to_usize(header.folder_count)];
reader.read_exact(folders_buffer.as_mut_slice())?;
let file_records_size = to_usize(header.folder_count)
+ to_usize(header.total_folder_names_length)
+ to_usize(header.total_file_count) * FILE_RECORD_SIZE;
let mut file_records_buffer: Vec<u8> = vec![0; file_records_size];
reader.read_exact(file_records_buffer.as_mut_slice())?;
let folder_record_offset_baseline =
HEADER_SIZE + folders_buffer.len() + to_usize(header.total_file_names_length);
let mut assets = BTreeMap::new();
for chunk in folders_buffer.chunks_exact(U) {
let folder_record = read_folder_record(chunk);
let entry = assets.entry(folder_record.name_hash);
if let Entry::Occupied(_) = entry {
return Err(InvalidArgumentError {
message: format!(
"Unexpected collision for folder name hash {:x}",
folder_record.name_hash
),
}
.into());
}
let file_records_offset = if (header.archive_flags & 0x1) == 0 {
to_usize(folder_record.file_records_offset) - folder_record_offset_baseline
} else {
let folder_name_length_offset =
to_usize(folder_record.file_records_offset) - folder_record_offset_baseline;
if let Some(folder_name_length) = file_records_buffer.get(folder_name_length_offset) {
folder_name_length_offset + 1 + to_usize(u32::from(*folder_name_length))
} else {
return Err(InvalidArgumentError {
message: "BSA file contains an invalid folder name length offset".into(),
}
.into());
}
};
let file_records_buffer = match file_records_buffer.get(file_records_offset..) {
Some(s) => s,
None => {
return Err(InvalidArgumentError {
message: "BSA file contains an invalid file records offset".into(),
}
.into());
}
};
let file_hashes: &mut BTreeSet<u64> = entry.or_default();
for file_chunk in file_records_buffer
.chunks_exact(FILE_RECORD_SIZE)
.take(to_usize(folder_record.file_count))
{
let file_hash = to_u64(file_chunk);
if !file_hashes.insert(file_hash) {
return Err(InvalidArgumentError { message: format!("Unexpected collision for file name hash {:x} in set for folder name hash {:x}", file_hash, folder_record.name_hash)}.into());
}
}
}
Ok(assets)
}
+185
View File
@@ -0,0 +1,185 @@
use std::{
fs::OpenOptions,
path::{Path, PathBuf},
};
use crate::{GameType, game::GameCache, plugin::has_ascii_extension};
const BSA_FILE_EXTENSION: &str = "bsa";
pub fn find_associated_archives(
game_type: GameType,
game_cache: &GameCache,
plugin_path: &Path,
) -> Vec<PathBuf> {
match game_type {
GameType::TES3 | GameType::OpenMW => Vec::new(),
// Skyrim (non-SE) plugins can only load BSAs that have exactly the same
// basename, ignoring file extensions.
GameType::TES5 => find_associated_archive(plugin_path),
// Skyrim SE can load BSAs that have exactly the same basename, ignoring
// file extensions, and also BSAs with filenames of the form "<basename>
// - Textures.bsa" (case-insensitively). This assumes that Skyrim VR
// works the same way as Skyrim SE.
GameType::TES5SE | GameType::TES5VR => find_associated_archives_with_suffixes(plugin_path, BSA_FILE_EXTENSION, &["", " - Textures"]),
// Oblivion .esp files can load archives which begin with the plugin
// basename.
GameType::TES4 => {
if has_ascii_extension(plugin_path, "esp") {
Vec::new()
} else {
find_associated_archives_with_arbitrary_suffixes(plugin_path, game_cache)
}
},
// FO3, FNV, FO4 plugins can load archives which begin with the plugin
// basename. This assumes that FO4 VR works the same way as FO4.
GameType::FO3 | GameType::FONV | GameType::FO4 | GameType::FO4VR =>
find_associated_archives_with_arbitrary_suffixes(plugin_path, game_cache)
,
// The game will load a BA2 that's suffixed with " - Voices_<language>"
// where <language> is whatever language Starfield is configured to use
// (sLanguage in the ini), so this isn't exactly correct but will work
// so long as a plugin with voices has voices for English, which seems
// likely.
GameType::Starfield => find_associated_archives_with_suffixes(plugin_path, "ba2", &[" - Main", " - Textures", " - Localization", " - Voices_en"]),
}
}
fn find_associated_archive(plugin_path: &Path) -> Vec<PathBuf> {
let archive_path = plugin_path.with_extension(BSA_FILE_EXTENSION);
if archive_path.exists() {
vec![archive_path]
} else {
Vec::new()
}
}
fn find_associated_archives_with_suffixes(
plugin_path: &Path,
archive_extension: &str,
supported_suffixes: &[&str],
) -> Vec<PathBuf> {
let file_stem = match plugin_path.file_stem() {
Some(s) => s,
None => return Vec::new(),
};
supported_suffixes
.iter()
.map(|suffix| {
let mut filename = file_stem.to_os_string();
filename.push(suffix);
filename.push(".");
filename.push(archive_extension);
plugin_path.with_file_name(filename)
})
.filter(|p| p.exists())
.collect()
}
fn find_associated_archives_with_arbitrary_suffixes(
plugin_path: &Path,
game_cache: &GameCache,
) -> Vec<PathBuf> {
let plugin_stem_len = match plugin_path.file_stem().and_then(|s| s.to_str()) {
Some(s) => s.len(),
None => return Vec::new(),
};
game_cache
.archives()
.filter(|path| {
// Need to check if it starts with the given plugin's basename,
// but case insensitively. This is hard to do accurately, so
// instead check if the plugin with the same length basename and
// and the given plugin's file extension is equivalent.
let archive_filename = match path.file_name().and_then(|s| s.to_str()) {
Some(f) => f,
None => return false,
};
// Can't just slice the archive filename to the same length as the plugin file stem directly because that might not slice on a character boundary, so truncate the byte slice and then check it's still valid UTF-8.
let filename =
match std::str::from_utf8(&archive_filename.as_bytes()[..plugin_stem_len]) {
Ok(f) => f,
Err(_) => return false,
};
let archive_plugin_path = plugin_path.with_file_name(filename);
are_file_paths_equivalent(&archive_plugin_path, plugin_path)
})
.cloned()
.collect()
}
#[cfg(windows)]
fn are_file_paths_equivalent(lhs: &Path, rhs: &Path) -> bool {
// See <https://learn.microsoft.com/en-us/windows/win32/debug/system-error-codes--0-499->
// Or windows::Win32::Foundation::ERROR_SHARING_VIOLATION in the "windows" crate.
const ERROR_SHARING_VIOLATION: i32 = 32;
use std::os::windows::fs::OpenOptionsExt;
let lhs_file = match OpenOptions::new().read(true).share_mode(0).open(lhs) {
Ok(f) => f,
Err(_) => return false,
};
let result = match OpenOptions::new().read(true).share_mode(0).open(rhs) {
Ok(f) => {
dbg!(f);
false
}
Err(e) => {
if let Some(error_code) = e.raw_os_error() {
error_code == ERROR_SHARING_VIOLATION
} else {
false
}
}
};
drop(lhs_file);
result
}
#[cfg(not(windows))]
fn are_file_paths_equivalent(lhs: &Path, rhs: &Path) -> bool {
use std::fs::unix::fs::MetadataExt;
let lhs_metadata = match lhs.metadata() {
Ok(m) => m,
_ => return false,
};
let rhs_metadata = match rhs.metadata() {
Ok(m) => m,
_ => return false,
};
lhs_metadata.dev() == rhs_metadata.dev() && lhs_metadata.ino() == rhs_metadata.ino()
}
#[cfg(test)]
mod tests {
use tempfile::tempdir;
use super::*;
#[test]
fn are_file_paths_equivalent_should_be_true_if_given_the_same_path_twice() {
let temp_dir = tempdir().unwrap();
let file_path = temp_dir.path().join("test");
std::fs::write(&file_path, "").unwrap();
assert!(are_file_paths_equivalent(&file_path, &file_path))
}
}
+7
View File
@@ -0,0 +1,7 @@
mod ba2;
mod bsa;
mod find;
mod parse;
pub use find::find_associated_archives;
pub use parse::assets_in_archives;
+106
View File
@@ -0,0 +1,106 @@
use std::{
collections::{BTreeMap, BTreeSet},
fs::File,
io::{BufReader, Read},
path::{Path, PathBuf},
};
use crate::{
error::{GeneralError, InvalidArgumentError},
plugin::has_ascii_extension,
};
use super::{ba2, bsa};
pub fn assets_in_archives(archive_paths: &[PathBuf]) -> BTreeMap<u64, BTreeSet<u64>> {
let mut archive_assets: BTreeMap<u64, BTreeSet<u64>> = BTreeMap::new();
for archive_path in archive_paths {
log::trace!(
"Getting assets loaded from the Bethesda archive at \"{}\"",
archive_path.display()
);
let assets = match get_assets_in_archive(archive_path) {
Ok(a) => a,
Err(e) => {
log::error!(
"Encountered an error while trying to read the Bethesda archive at \"{}\": {}",
archive_path.display(),
e
);
continue;
}
};
let warn_on_hash_collisions = should_warn_on_hash_collisions(archive_path);
for (folder_hash, file_hashes) in assets {
let entry_file_hashes = archive_assets.entry(folder_hash).or_default();
for file_hash in file_hashes {
if !entry_file_hashes.insert(file_hash) && warn_on_hash_collisions {
log::warn!(
"The folder and file with hashes {:x} and {:x} in \"{}\" are present in another Bethesda archive.",
folder_hash,
file_hash,
archive_path.display()
);
}
}
}
}
archive_assets
}
fn should_warn_on_hash_collisions(archive_path: &Path) -> bool {
if !has_ascii_extension(archive_path, "ba2") {
return true;
}
let filename = archive_path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_ascii_lowercase();
filename.starts_with("fallout4 - ") || filename.starts_with("dlcultrahighresolution - ")
}
fn get_assets_in_archive(
archive_path: &Path,
) -> Result<BTreeMap<u64, BTreeSet<u64>>, GeneralError> {
let mut reader = BufReader::new(File::open(archive_path)?);
let mut type_id: [u8; 4] = [0; 4];
reader.read_exact(&mut type_id)?;
match type_id {
bsa::TYPE_ID => bsa::read_assets(reader),
ba2::TYPE_ID => ba2::read_assets(reader),
_ => Err(InvalidArgumentError {
message: format!(
"Bethesda archive at \"{}\" has an unrecognised type ID",
archive_path.display()
),
}
.into()),
}
}
pub(super) fn to_u32(bytes: &[u8]) -> u32 {
let array =
<[u8; 4]>::try_from(&bytes[..4]).expect("Bytes slice is large enough to hold a u32");
u32::from_le_bytes(array)
}
pub(super) fn to_u64(bytes: &[u8]) -> u64 {
let array =
<[u8; 8]>::try_from(&bytes[..4]).expect("Bytes slice is large enough to hold a u64");
u64::from_le_bytes(array)
}
pub(super) fn to_usize(size: u32) -> usize {
usize::try_from(size).expect("usize can hold a u32")
}
+455
View File
@@ -0,0 +1,455 @@
use std::{path::Path, str::FromStr};
use loot_condition_interpreter::Expression;
use crate::{
error::{FileAccessError, GeneralError, InvalidArgumentError},
metadata::{
File, Group, Message, PluginCleaningData, PluginMetadata,
metadata_document::MetadataDocument,
},
sorting::{
groups::{build_groups_graph, find_path},
vertex::Vertex,
},
};
/// The interface through which metadata can be accessed.
#[derive(Debug)]
pub struct Database {
masterlist: MetadataDocument,
userlist: MetadataDocument,
condition_evaluator_state: loot_condition_interpreter::State,
}
impl Database {
#[must_use]
pub(crate) fn new(condition_evaluator_state: loot_condition_interpreter::State) -> Self {
Self {
masterlist: MetadataDocument::default(),
userlist: MetadataDocument::default(),
condition_evaluator_state,
}
}
pub(crate) fn condition_evaluator_state_mut(
&mut self,
) -> &mut loot_condition_interpreter::State {
&mut self.condition_evaluator_state
}
/// Loads the masterlist from the given path.
///
/// Replaces any existing data that was previously loaded from a masterlist.
pub fn load_masterlist(&mut self, path: &Path) -> Result<(), GeneralError> {
if path.exists() {
self.masterlist.load(path)
} else {
Err(FileAccessError {
message: format!(
"The given masterlist path does not exist: {}",
path.display()
),
}
.into())
}
}
/// Loads the masterlist from the given path, using the prelude at the given
/// path.
///
/// Replaces any existing data that was previously loaded from a masterlist.
pub fn load_masterlist_with_prelude(
&mut self,
masterlist_path: &Path,
prelude_path: &Path,
) -> Result<(), GeneralError> {
if !masterlist_path.exists() {
Err(FileAccessError {
message: format!(
"The given masterlist path does not exist: {}",
masterlist_path.display()
),
}
.into())
} else if !prelude_path.exists() {
Err(FileAccessError {
message: format!(
"The given prelude path does not exist: {}",
prelude_path.display()
),
}
.into())
} else {
self.masterlist
.load_with_prelude(masterlist_path, prelude_path)
}
}
/// Loads the userlist from the given path.
///
/// Replaces any existing data that was previously loaded from a userlist.
pub fn load_userlist(&mut self, path: &Path) -> Result<(), GeneralError> {
if path.exists() {
self.userlist.load(path)
} else {
Err(FileAccessError {
message: format!("The given userlist path does not exist: {}", path.display()),
}
.into())
}
}
/// Writes a metadata file containing all loaded user-added metadata.
///
/// If `output_path` already exists, it will be written if `overwrite` is
/// `true`, otherwise no data will be written.
pub fn write_user_metadata(
&self,
output_path: &Path,
overwrite: bool,
) -> Result<(), GeneralError> {
validate_write_path(output_path, overwrite)?;
self.userlist.save(output_path)
}
/// Writes a metadata file that only contains plugin Bash Tag suggestions
/// and dirty info.
///
/// If `output_path` already exists, it will be written if `overwrite` is
/// `true`, otherwise no data will be written.
pub fn write_minimal_list(
&self,
output_path: &Path,
overwrite: bool,
) -> Result<(), GeneralError> {
validate_write_path(output_path, overwrite)?;
let mut doc = MetadataDocument::default();
for plugin in self.masterlist.plugins() {
let mut minimal_plugin = PluginMetadata::new(plugin.name())?;
minimal_plugin.set_tags(plugin.tags().to_vec());
minimal_plugin.set_dirty_info(plugin.dirty_info().to_vec());
doc.set_plugin_metadata(minimal_plugin);
}
doc.save(output_path)
}
/// Gets the Bash Tags that are listed in the loaded metadata lists.
///
/// Bash Tag suggestions can include Bash Tags not in this list.
pub fn known_bash_tags(&self) -> Vec<String> {
let mut tags = self.masterlist.bash_tags().to_vec();
tags.extend_from_slice(self.userlist.bash_tags());
tags
}
/// Get all general messages listed in the loaded metadata lists.
///
/// If `evaluate_conditions` is `true`, any metadata conditions are
/// evaluated before the metadata is returned, otherwise unevaluated
/// metadata is returned. Evaluating general message conditions also clears
/// the condition cache before evaluating conditions.
pub fn general_messages(
&mut self,
evaluate_conditions: bool,
) -> Result<Vec<Message>, GeneralError> {
let messages_iter = self
.masterlist
.messages()
.iter()
.chain(self.userlist.messages());
if evaluate_conditions {
self.condition_evaluator_state.clear_condition_cache()?;
let messages = messages_iter
.filter_map(|m| {
filter_map_on_condition(m, m.condition(), &self.condition_evaluator_state)
})
.collect::<Result<Vec<_>, _>>()?;
Ok(messages)
} else {
Ok(messages_iter.cloned().collect())
}
}
/// Gets the groups that are defined in the loaded metadata lists.
///
/// If `include_user_metadata` is `true`, any group metadata present in the
/// userlist is included in the returned metadata, otherwise the metadata
/// returned only includes metadata from the masterlist.
pub fn groups(&self, include_user_metadata: bool) -> Vec<Group> {
if include_user_metadata {
merge_groups(self.masterlist.groups(), self.userlist.groups())
} else {
self.masterlist.groups().to_vec()
}
}
/// Gets the groups that are defined or extended in the loaded userlist.
pub fn user_groups(&self) -> &[Group] {
self.userlist.groups()
}
/// Sets the group definitions to store in the userlist, replacing any
/// definitions already loaded from the userlist.
pub fn set_user_groups(&mut self, groups: Vec<Group>) {
self.userlist.set_groups(groups);
}
/// Get the "shortest" path between the two given groups according to their
/// "load after" metadata.
///
/// The "shortest" path is defined as the path that maximises the amount of
/// user metadata involved while minimising the amount of masterlist
/// metadata involved. It's not the path involving the fewest groups.
///
/// If there is no path between the two groups, the returned [Vec] will be
/// empty.
pub fn groups_path(
&self,
from_group_name: &str,
to_group_name: &str,
) -> Result<Vec<Vertex>, GeneralError> {
let graph = build_groups_graph(self.masterlist.groups(), self.userlist.groups())?;
find_path(&graph, from_group_name, to_group_name)
}
/// Get all of a plugin's loaded metadata.
///
/// If `include_user_metadata` is `true`, any user metadata the plugin has
/// is included in the returned metadata, otherwise the metadata returned
/// only includes metadata from the masterlist.
///
/// If `evaluateConditions` is `true`, any metadata conditions are evaluated
/// before the metadata otherwise unevaluated metadata is returned.
/// Evaluating plugin metadata conditions does **not** clear the condition
/// cache.
pub fn plugin_metadata(
&self,
plugin_name: &str,
include_user_metadata: bool,
evaluate_conditions: bool,
) -> Result<Option<PluginMetadata>, GeneralError> {
let mut metadata = self.masterlist.find_plugin(plugin_name)?;
if include_user_metadata {
if let Some(mut user_metadata) = self.userlist.find_plugin(plugin_name)? {
if let Some(metadata) = metadata {
user_metadata.merge_metadata(&metadata);
}
metadata = Some(user_metadata);
}
}
if evaluate_conditions {
if let Some(metadata) = metadata {
return evaluate_all_conditions(metadata, &self.condition_evaluator_state)
.map_err(Into::into);
}
}
Ok(metadata)
}
/// Get a plugin's metadata loaded from the given userlist.
///
/// If `evaluateConditions` is `true`, any metadata conditions are evaluated
/// before the metadata otherwise unevaluated metadata is returned.
/// Evaluating plugin metadata conditions does **not** clear the condition
/// cache.
pub fn plugin_user_metadata(
&self,
plugin_name: &str,
evaluate_conditions: bool,
) -> Result<Option<PluginMetadata>, GeneralError> {
let metadata = self.userlist.find_plugin(plugin_name);
if evaluate_conditions {
if let Ok(Some(metadata)) = metadata {
return evaluate_all_conditions(metadata, &self.condition_evaluator_state)
.map_err(Into::into);
}
}
metadata
}
/// Sets a plugin's user metadata, replacing any loaded user metadata for
/// that plugin.
pub fn set_plugin_user_metadata(&mut self, plugin_metadata: PluginMetadata) {
self.userlist.set_plugin_metadata(plugin_metadata);
}
/// Discards all loaded user metadata for the plugin with the given
/// filename.
pub fn discard_plugin_user_metadata(&mut self, plugin: &str) {
self.userlist.remove_plugin_metadata(plugin);
}
/// Discards all loaded user metadata for all groups, plugins, and any
/// user-added general messages and known bash tags.
pub fn discard_all_user_metadata(&mut self) {
self.userlist.clear();
}
}
fn validate_write_path(output_path: &Path, overwrite: bool) -> Result<(), GeneralError> {
if !output_path.parent().map(|p| p.exists()).unwrap_or(false) {
Err(InvalidArgumentError {
message: "The output directory does not exist.".into(),
}
.into())
} else if !overwrite && output_path.exists() {
Err(FileAccessError {
message: "Output file exists but overwrite is not set to true.".into(),
}
.into())
} else {
Ok(())
}
}
fn merge_groups(lhs: &[Group], rhs: &[Group]) -> Vec<Group> {
let mut groups = lhs.to_vec();
let mut new_groups = Vec::new();
for rhs_group in rhs {
if let Some(group) = groups.iter_mut().find(|g| g.name() == rhs_group.name()) {
if rhs_group.description().is_some() || !rhs_group.after_groups().is_empty() {
let mut new_group = group.clone();
if let Some(description) = rhs_group.description() {
new_group = new_group.with_description(description.to_string());
}
if !rhs_group.after_groups().is_empty() {
let mut after_groups = new_group.after_groups().to_vec();
after_groups.extend_from_slice(rhs_group.after_groups());
new_group = new_group.with_after_groups(after_groups);
}
*group = new_group;
}
} else {
new_groups.push(rhs_group.clone());
}
}
groups.extend(new_groups);
groups
}
fn evaluate_all_conditions(
mut metadata: PluginMetadata,
state: &loot_condition_interpreter::State,
) -> Result<Option<PluginMetadata>, loot_condition_interpreter::Error> {
metadata.set_load_after_files(filter_files_on_conditions(
metadata.load_after_files(),
state,
)?);
metadata.set_requirements(filter_files_on_conditions(metadata.requirements(), state)?);
metadata.set_incompatibilities(filter_files_on_conditions(
metadata.incompatibilities(),
state,
)?);
metadata.set_messages(
metadata
.messages()
.iter()
.filter_map(|m| filter_map_on_condition(m, m.condition(), state))
.collect::<Result<Vec<_>, _>>()?,
);
metadata.set_tags(
metadata
.tags()
.iter()
.filter_map(|t| filter_map_on_condition(t, t.condition(), state))
.collect::<Result<Vec<_>, _>>()?,
);
if !metadata.is_regex_plugin() {
metadata.set_dirty_info(filter_cleaning_data_on_conditions(
metadata.name(),
metadata.dirty_info(),
state,
)?);
metadata.set_clean_info(filter_cleaning_data_on_conditions(
metadata.name(),
metadata.clean_info(),
state,
)?);
}
if metadata.has_name_only() {
Ok(None)
} else {
Ok(Some(metadata))
}
}
fn evaluate_condition(
condition: Option<&str>,
state: &loot_condition_interpreter::State,
) -> Result<bool, loot_condition_interpreter::Error> {
if let Some(condition) = condition {
Expression::from_str(condition).and_then(|e| e.eval(state))
} else {
Ok(true)
}
}
fn filter_map_on_condition<T: Clone>(
item: &T,
condition: Option<&str>,
state: &loot_condition_interpreter::State,
) -> Option<Result<T, loot_condition_interpreter::Error>> {
evaluate_condition(condition, state)
.map(|r| r.then(|| item.clone()))
.transpose()
}
fn filter_files_on_conditions(
files: &[File],
state: &loot_condition_interpreter::State,
) -> Result<Vec<File>, loot_condition_interpreter::Error> {
files
.iter()
.filter_map(|file| filter_map_on_condition(file, file.condition(), state))
.collect::<Result<Vec<_>, _>>()
}
fn filter_cleaning_data_on_conditions(
plugin_name: &str,
cleaning_info: &[PluginCleaningData],
state: &loot_condition_interpreter::State,
) -> Result<Vec<PluginCleaningData>, loot_condition_interpreter::Error> {
if plugin_name.is_empty() {
return Ok(Vec::new());
}
cleaning_info
.iter()
.filter_map(|i| {
let condition = format!("checksum(\"{}\", {:08X})", plugin_name, i.crc());
filter_map_on_condition(i, Some(condition.as_str()), state)
})
.collect::<Result<Vec<_>, _>>()
}
+353
View File
@@ -0,0 +1,353 @@
use std::fmt::Display;
use petgraph::graph::NodeIndex;
use saphyr::Marker;
use crate::{
metadata::{
MessageContent,
yaml::{YamlObjectType, to_yaml},
},
sorting::vertex::Vertex,
};
#[derive(Debug)]
pub struct CyclicInteractionError {
pub cycle: Vec<Vertex>,
}
impl CyclicInteractionError {
pub fn new(cycle: Vec<Vertex>) -> Self {
Self { cycle }
}
}
impl Display for CyclicInteractionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let cycle: String = self
.cycle
.iter()
.map(|v| {
if let Some(edge_type) = v.out_edge_type() {
format!("{} --[{}]-> ", v.name(), edge_type)
} else {
v.name().to_string()
}
})
.chain(self.cycle.first().iter().map(|v| v.name().to_string()))
.collect();
write!(f, "Cyclic interaction detected: {}", cycle)
}
}
impl std::error::Error for CyclicInteractionError {}
#[derive(Debug)]
pub struct FileAccessError {
pub message: String,
}
impl FileAccessError {
pub(crate) fn new(message: String) -> Self {
FileAccessError { message }
}
}
impl std::fmt::Display for FileAccessError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for FileAccessError {}
#[derive(Debug)]
pub struct UndefinedGroupError {
pub group_name: String,
}
impl UndefinedGroupError {
pub fn new(group_name: String) -> Self {
Self { group_name }
}
}
impl Display for UndefinedGroupError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "The group \"{}\" does not exist", self.group_name)
}
}
impl std::error::Error for UndefinedGroupError {}
#[derive(Debug)]
pub struct InvalidArgumentError {
pub message: String,
}
impl std::fmt::Display for InvalidArgumentError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for InvalidArgumentError {}
#[derive(Debug)]
pub(crate) struct YamlMergeKeyError {
value: saphyr::MarkedYaml,
}
impl YamlMergeKeyError {
pub(crate) fn new(value: saphyr::MarkedYaml) -> Self {
YamlMergeKeyError { value }
}
}
impl std::fmt::Display for YamlMergeKeyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut output = String::new();
let yaml = to_yaml(&self.value);
if saphyr::YamlEmitter::new(&mut output).dump(&yaml).is_ok() {
write!(
f,
"Invalid YAML merge key value at line {} column {}: {}",
self.value.span.start.line(),
self.value.span.start.col(),
output
)
} else {
write!(
f,
"Invalid YAML merge key value at line {} column {}: {:?}",
self.value.span.start.line(),
self.value.span.start.col(),
self.value
)
}
}
}
impl std::error::Error for YamlMergeKeyError {}
#[derive(Debug)]
pub struct YamlParseError {
marker: saphyr::Marker,
message: String,
}
impl YamlParseError {
pub fn new(marker: Marker, message: String) -> Self {
YamlParseError { marker, message }
}
pub fn missing_key(marker: Marker, key: &str, yaml_type: YamlObjectType) -> Self {
YamlParseError::new(
marker,
format!("'{}' key missing from '{}' map object", key, yaml_type),
)
}
}
impl std::fmt::Display for YamlParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Encountered a YAML parsing error at line {} column {}: {}",
self.marker.line(),
self.marker.col(),
self.message
)
}
}
impl std::error::Error for YamlParseError {}
#[derive(Debug)]
pub struct InvalidMultilingualMessageContents {}
impl std::fmt::Display for InvalidMultilingualMessageContents {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Multilingual messages must contain a content string that uses the {} language code",
MessageContent::DEFAULT_LANGUAGE
)
}
}
impl std::error::Error for InvalidMultilingualMessageContents {}
#[derive(Debug)]
pub struct PoisonedMutexError;
impl std::fmt::Display for PoisonedMutexError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "A mutex is poisoned",)
}
}
impl std::error::Error for PoisonedMutexError {}
#[derive(Debug)]
pub struct PathfindingError {
message: String,
}
impl PathfindingError {
pub fn new(message: String) -> Self {
Self { message }
}
}
impl std::fmt::Display for PathfindingError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for PathfindingError {}
#[derive(Debug)]
pub struct SortingLogicError {
message: String,
}
impl SortingLogicError {
pub fn new(message: String) -> Self {
Self { message }
}
}
impl std::fmt::Display for SortingLogicError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for SortingLogicError {}
#[derive(Debug)]
pub struct GeneralError(Box<dyn std::error::Error + Send + Sync + 'static>);
impl std::fmt::Display for GeneralError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
impl std::error::Error for GeneralError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(self.0.as_ref())
}
}
impl From<std::io::Error> for GeneralError {
fn from(value: std::io::Error) -> Self {
GeneralError(Box::new(value))
}
}
impl From<saphyr::ScanError> for GeneralError {
fn from(value: saphyr::ScanError) -> Self {
GeneralError(Box::new(value))
}
}
impl From<YamlMergeKeyError> for GeneralError {
fn from(value: YamlMergeKeyError) -> Self {
GeneralError(Box::new(value))
}
}
impl From<FileAccessError> for GeneralError {
fn from(value: FileAccessError) -> Self {
GeneralError(Box::new(value))
}
}
impl From<YamlParseError> for GeneralError {
fn from(value: YamlParseError) -> Self {
GeneralError(Box::new(value))
}
}
impl From<loot_condition_interpreter::Error> for GeneralError {
fn from(value: loot_condition_interpreter::Error) -> Self {
GeneralError(Box::new(value))
}
}
impl From<std::num::TryFromIntError> for GeneralError {
fn from(value: std::num::TryFromIntError) -> Self {
GeneralError(Box::new(value))
}
}
impl From<InvalidMultilingualMessageContents> for GeneralError {
fn from(value: InvalidMultilingualMessageContents) -> Self {
GeneralError(Box::new(value))
}
}
impl From<InvalidArgumentError> for GeneralError {
fn from(value: InvalidArgumentError) -> Self {
GeneralError(Box::new(value))
}
}
impl From<loadorder::Error> for GeneralError {
fn from(value: loadorder::Error) -> Self {
GeneralError(Box::new(value))
}
}
impl<T> From<std::sync::PoisonError<T>> for GeneralError {
fn from(_: std::sync::PoisonError<T>) -> Self {
GeneralError(Box::new(PoisonedMutexError))
}
}
impl From<esplugin::Error> for GeneralError {
fn from(value: esplugin::Error) -> Self {
GeneralError(Box::new(value))
}
}
impl From<Box<fancy_regex::Error>> for GeneralError {
fn from(value: Box<fancy_regex::Error>) -> Self {
GeneralError(value)
}
}
impl From<UndefinedGroupError> for GeneralError {
fn from(value: UndefinedGroupError) -> Self {
GeneralError(Box::new(value))
}
}
impl From<CyclicInteractionError> for GeneralError {
fn from(value: CyclicInteractionError) -> Self {
GeneralError(Box::new(value))
}
}
impl From<PathfindingError> for GeneralError {
fn from(value: PathfindingError) -> Self {
GeneralError(Box::new(value))
}
}
impl From<petgraph::algo::Cycle<NodeIndex>> for GeneralError {
fn from(_: petgraph::algo::Cycle<NodeIndex>) -> Self {
GeneralError(Box::new(CyclicInteractionError { cycle: Vec::new() }))
}
}
impl From<SortingLogicError> for GeneralError {
fn from(value: SortingLogicError) -> Self {
GeneralError(Box::new(value))
}
}
+745
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
mod archive;
mod database;
pub mod error;
mod game;
mod logging;
pub mod metadata;
mod plugin;
mod sorting;
mod version;
pub use database::Database;
use fancy_regex::{Regex, RegexBuilder};
pub use game::{Game, GameType};
pub use logging::{LogLevel, set_logging_callback};
pub use plugin::Plugin;
pub use sorting::vertex::{EdgeType, Vertex};
pub use version::{
LIBLOOT_VERSION_MAJOR, LIBLOOT_VERSION_MINOR, LIBLOOT_VERSION_PATCH, is_compatible,
libloot_revision, libloot_version,
};
fn regex(name: &str) -> Result<Regex, Box<fancy_regex::Error>> {
Ok(RegexBuilder::new(name).case_insensitive(true).build()?)
}
+74
View File
@@ -0,0 +1,74 @@
use log::{Metadata, Record};
// const LOGGER: OnceCell<CallbackLogger<Box<dyn Fn(LogLevel, &str)>>> = OnceCell::new();
/// Set the callback function that is called when logging.
///
/// The `callback` function's first parameter is the level of the message being
/// logged, and the second is the message itself.
pub fn set_logging_callback<T>(callback: T)
where
T: Fn(LogLevel, &str) + Send + Sync + 'static,
{
// FIXME: set_boxed_logger can only be called once, and it's not possible to retrieve and downcast the logger from log once set.
let logger = Box::new(CallbackLogger { callback });
log::set_boxed_logger(logger)
.map(|_| log::set_max_level(log::LevelFilter::Trace))
.unwrap();
}
/// Codes used to specify different levels of API logging.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
pub enum LogLevel {
Trace,
Debug,
Info,
Warning,
Error,
Fatal,
}
impl std::fmt::Display for LogLevel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LogLevel::Trace => write!(f, "trace"),
LogLevel::Debug => write!(f, "debug"),
LogLevel::Info => write!(f, "info"),
LogLevel::Warning => write!(f, "warning"),
LogLevel::Error => write!(f, "error"),
LogLevel::Fatal => write!(f, "fatal"),
}
}
}
impl From<log::Level> for LogLevel {
fn from(value: log::Level) -> Self {
match value {
log::Level::Trace => LogLevel::Trace,
log::Level::Debug => LogLevel::Debug,
log::Level::Info => LogLevel::Info,
log::Level::Warn => LogLevel::Warning,
log::Level::Error => LogLevel::Error,
}
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)]
struct CallbackLogger<T: Fn(LogLevel, &str)> {
callback: T,
}
impl<T: Fn(LogLevel, &str) + Send + Sync> log::Log for CallbackLogger<T> {
fn enabled(&self, _metadata: &Metadata) -> bool {
true
}
fn log(&self, record: &Record) {
if self.enabled(record.metadata()) {
(self.callback)(record.level().into(), &format!("{}", record.args()));
}
}
fn flush(&self) {}
}
+163
View File
@@ -0,0 +1,163 @@
use std::str::FromStr;
use loot_condition_interpreter::Expression;
use saphyr::{MarkedYaml, YamlData};
use unicase::UniCase;
use crate::error::{GeneralError, InvalidMultilingualMessageContents, YamlParseError};
use super::{
message::{MessageContent, parse_message_contents_yaml, validate_message_contents},
yaml::{YamlObjectType, as_string_node, get_required_string_value, get_string_value},
};
/// Represents a file in a game's Data folder, including files in
/// subdirectories.
#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)]
pub struct File {
name: Filename,
display_name: Option<String>,
detail: Vec<MessageContent>,
condition: Option<String>,
}
impl File {
/// Construct a [File] with the given name. This can also be a relative path.
#[must_use]
pub fn new(name: String) -> Self {
Self {
name: Filename::new(name),
..Default::default()
}
}
/// Set the name to be displayed for the file in messages, formatted using
/// CommonMark.
#[must_use]
pub fn with_display_name(mut self, display_name: String) -> Self {
self.display_name = Some(display_name);
self
}
/// Set the condition string.
#[must_use]
pub fn with_condition(mut self, condition: String) -> Self {
self.condition = Some(condition);
self
}
/// Set the detail message content, which may be appended to any messages
/// generated for this file. If multilingual, one language must be
/// [MessageContent::DEFAULT_LANGUAGE].
pub fn with_detail(
mut self,
detail: Vec<MessageContent>,
) -> Result<Self, InvalidMultilingualMessageContents> {
validate_message_contents(&detail)?;
self.detail = detail;
Ok(self)
}
/// Gets the name of the file (which may actually be a path).
pub fn name(&self) -> &Filename {
&self.name
}
/// Get the display name of the file.
pub fn display_name(&self) -> Option<&str> {
self.display_name.as_deref()
}
/// Get the detail message content of the file.
///
/// If this file causes an error message to be displayed, the detail message
/// content should be appended to that message, as it provides more detail
/// about the error (e.g. suggestions for how to resolve it).
pub fn detail(&self) -> &[MessageContent] {
&self.detail
}
/// Get the condition string.
pub fn condition(&self) -> Option<&str> {
self.condition.as_deref()
}
}
/// Represents a case-insensitive filename.
#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)]
pub struct Filename(UniCase<String>);
impl Filename {
/// Construct a Filename using the given string.
#[must_use]
pub fn new(s: String) -> Self {
Filename(UniCase::new(s))
}
/// Get this Filename as a string.
pub fn as_str(&self) -> &str {
&self.0
}
}
impl AsRef<str> for &Filename {
fn as_ref(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for Filename {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl TryFrom<&MarkedYaml> for File {
type Error = GeneralError;
fn try_from(value: &MarkedYaml) -> Result<Self, Self::Error> {
match &value.data {
YamlData::String(s) => Ok(File {
name: Filename(UniCase::new(s.to_string())),
display_name: None,
detail: Vec::new(),
condition: None,
}),
YamlData::Hash(h) => {
let name =
get_required_string_value(value.span.start, h, "name", YamlObjectType::File)?;
let display_name = get_string_value(h, "display", YamlObjectType::File)?;
let detail = match h.get(&as_string_node("detail")) {
Some(n) => parse_message_contents_yaml(
n,
"detail",
YamlObjectType::PluginCleaningData,
)?,
None => Vec::new(),
};
let condition = match get_string_value(h, "condition", YamlObjectType::File)? {
Some(n) => {
Expression::from_str(n)?;
Some(n.to_string())
}
None => None,
};
Ok(File {
name: Filename(UniCase::new(name.to_string())),
display_name: display_name.map(|s| s.to_string()),
detail,
condition,
})
}
_ => Err(YamlParseError::new(
value.span.start,
"'file' object must be a map or string".into(),
)
.into()),
}
}
}
+91
View File
@@ -0,0 +1,91 @@
use saphyr::MarkedYaml;
use super::yaml::{
YamlObjectType, get_as_hash, get_required_string_value, get_string_value, get_strings_vec_value,
};
use crate::error::YamlParseError;
/// Represents a group to which plugin metadata objects can belong.
#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
pub struct Group {
name: String,
description: Option<String>,
after_groups: Vec<String>,
}
impl Group {
/// Construct a [Group] with the given name.
#[must_use]
pub fn new(name: String) -> Self {
Self {
name,
..Default::default()
}
}
/// Set a description for the group.
#[must_use]
pub fn with_description(mut self, description: String) -> Self {
self.description = Some(description);
self
}
/// Set the names of the groups that this group loads after.
#[must_use]
pub fn with_after_groups(mut self, after_groups: Vec<String>) -> Self {
self.after_groups = after_groups;
self
}
/// The name of the group to which all plugins belong by default.
pub const DEFAULT_NAME: &'static str = "default";
/// Get the name of the group.
pub fn name(&self) -> &str {
&self.name
}
/// Get the description of the group.
pub fn description(&self) -> Option<&str> {
self.description.as_deref()
}
/// Get the names of the groups that this group loads after.
pub fn after_groups(&self) -> &[String] {
&self.after_groups
}
}
impl std::default::Default for Group {
/// Construct a Group with the default name and an empty set of groups to
/// load after.
#[must_use]
fn default() -> Self {
Self {
name: Group::DEFAULT_NAME.to_string(),
description: Default::default(),
after_groups: Default::default(),
}
}
}
impl TryFrom<&MarkedYaml> for Group {
type Error = YamlParseError;
fn try_from(value: &MarkedYaml) -> Result<Self, Self::Error> {
let hash = get_as_hash(value, YamlObjectType::Group)?;
let name =
get_required_string_value(value.span.start, hash, "name", YamlObjectType::Group)?;
let description = get_string_value(hash, "description", YamlObjectType::Group)?;
let after = get_strings_vec_value(hash, "after", YamlObjectType::Group)?;
Ok(Group {
name: name.to_string(),
description: description.map(|d| d.to_string()),
after_groups: after.iter().map(|a| a.to_string()).collect(),
})
}
}
+77
View File
@@ -0,0 +1,77 @@
use saphyr::{MarkedYaml, YamlData};
use crate::error::{GeneralError, YamlParseError};
use super::yaml::{YamlObjectType, get_required_string_value};
/// Represents a URL at which the parent plugin can be found.
#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)]
pub struct Location {
url: String,
name: Option<String>,
}
impl Location {
/// Construct a [Location] with the given URL.
#[must_use]
pub fn new(url: String) -> Self {
Location {
url,
..Default::default()
}
}
/// Set a name for the URL, eg. the page or site name.
#[must_use]
pub fn with_name(mut self, name: String) -> Self {
self.name = Some(name);
self
}
/// Get the URL.
pub fn url(&self) -> &str {
&self.url
}
/// Get the descriptive name of this location.
pub fn name(&self) -> Option<&str> {
self.name.as_deref()
}
}
impl TryFrom<&MarkedYaml> for Location {
type Error = GeneralError;
fn try_from(value: &MarkedYaml) -> Result<Self, Self::Error> {
match &value.data {
YamlData::String(s) => Ok(Location {
url: s.clone(),
name: None,
}),
YamlData::Hash(h) => {
let link = get_required_string_value(
value.span.start,
h,
"link",
YamlObjectType::Location,
)?;
let name = get_required_string_value(
value.span.start,
h,
"name",
YamlObjectType::Location,
)?;
Ok(Location {
url: link.to_string(),
name: Some(name.to_string()),
})
}
_ => Err(YamlParseError::new(
value.span.start,
"'tag' object must be a map or string".into(),
)
.into()),
}
}
}
+322
View File
@@ -0,0 +1,322 @@
use std::str::FromStr;
use loot_condition_interpreter::Expression;
use saphyr::{MarkedYaml, YamlData};
use super::yaml::{
YamlObjectType, as_string_node, get_as_hash, get_required_string_value, get_string_value,
get_strings_vec_value,
};
use crate::error::{GeneralError, InvalidMultilingualMessageContents, YamlParseError};
/// Codes used to indicate the type of a message.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)]
pub enum MessageType {
/// A notification message that is of no significant severity.
#[default]
Say,
/// A warning message, used to indicate that an issue may be present that
/// the user may wish to act on.
Warn,
/// An error message, used to indicate that an issue that requires user
/// action is present.
Error,
}
/// Represents a message's localised text content.
#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
pub struct MessageContent {
text: String,
language: String,
}
impl MessageContent {
/// The code for the default language assumed for message content.
pub const DEFAULT_LANGUAGE: &'static str = "en";
/// Construct a [MessageContent] object with the given text in the default
/// language.
#[must_use]
pub fn new(text: String) -> Self {
MessageContent {
text,
..Default::default()
}
}
/// Set the language code to the given value.
#[must_use]
pub fn with_language(mut self, language: String) -> Self {
self.language = language;
self
}
/// Get the message text.
pub fn text(&self) -> &str {
&self.text
}
/// Get the text's language code.
pub fn language(&self) -> &str {
&self.language
}
}
impl std::default::Default for MessageContent {
/// Construct a [MessageContent] object with an empty message string and the
/// default language.
#[must_use]
fn default() -> Self {
Self {
text: Default::default(),
language: MessageContent::DEFAULT_LANGUAGE.into(),
}
}
}
/// Choose a [MessageContent] object from those given in `content` based on the
/// given `language`.
///
/// The locale or language code for the preferred language to select. Codes are
/// of the form `[language code]_[country code]`.
///
/// * If the vector only contains a single element, that element is returned.
/// * If content with a language that exactly matches the given locale or
/// language code is present, that content is returned.
/// * If a locale code is given and there is no exact match but content for that
/// locale's language is present, that content is returned.
/// * If a language code is given and there is no exact match but content for a
/// locale in that language is present, that content is returned.
/// * If no locale or language code matches are found and content in the default
/// language is present, that content is returned.
/// * Otherwise, an empty [Option] is returned.
pub fn select_message_content<'a>(
content: &'a [MessageContent],
language: &str,
) -> Option<&'a MessageContent> {
if content.is_empty() {
None
} else if let [c] = content {
Some(c)
} else {
let language_code = language.split_once('_').map(|p| p.0);
let mut matched = None;
let mut english = None;
for mc in content {
if mc.language == language {
return Some(mc);
} else if matched.is_none() {
if language_code.is_some_and(|c| c == mc.language) {
matched = Some(mc);
} else if language_code.is_none() {
if let Some((content_language_code, _)) = mc.language.split_once('_') {
if content_language_code == language {
matched = Some(mc);
}
}
}
if mc.language == MessageContent::DEFAULT_LANGUAGE {
english = Some(mc);
}
}
}
if matched.is_some() {
matched
} else if english.is_some() {
english
} else {
None
}
}
}
/// Represents a message with localisable text content.
#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)]
pub struct Message {
message_type: MessageType,
content: Vec<MessageContent>,
condition: Option<String>,
}
impl Message {
/// Construct a [Message] with the given type and a content string in the
/// language given by [MessageContent::DEFAULT_LANGUAGE].
#[must_use]
pub fn new(message_type: MessageType, content: String) -> Self {
Self {
message_type,
content: vec![MessageContent {
text: content,
language: MessageContent::DEFAULT_LANGUAGE.to_string(),
}],
condition: None,
}
}
/// Construct a [Message] with the given type and content. If more than one
/// [MessageContent] object is given, one must use
/// the language code given by [MessageContent::DEFAULT_LANGUAGE].
pub fn multilingual(
message_type: MessageType,
content: Vec<MessageContent>,
) -> Result<Self, GeneralError> {
validate_message_contents(&content)?;
Ok(Self {
message_type,
content,
condition: None,
})
}
/// Set the condition string.
#[must_use]
pub fn with_condition(mut self, condition: String) -> Self {
self.condition = Some(condition);
self
}
/// Get the message type.
pub fn message_type(&self) -> MessageType {
self.message_type
}
/// Get the message content.
pub fn content(&self) -> &[MessageContent] {
&self.content
}
/// Get the condition string.
pub fn condition(&self) -> Option<&str> {
self.condition.as_deref()
}
}
pub(crate) fn validate_message_contents(
contents: &[MessageContent],
) -> Result<(), InvalidMultilingualMessageContents> {
if contents.len() > 1 {
let english_string_exists = contents
.iter()
.any(|c| c.language == MessageContent::DEFAULT_LANGUAGE);
if !english_string_exists {
return Err(InvalidMultilingualMessageContents {});
}
}
Ok(())
}
impl TryFrom<&MarkedYaml> for MessageContent {
type Error = YamlParseError;
fn try_from(value: &MarkedYaml) -> Result<Self, Self::Error> {
let hash = get_as_hash(value, YamlObjectType::MessageContent)?;
let text =
get_required_string_value(value.span.start, hash, "text", YamlObjectType::Message)?;
let language =
get_required_string_value(value.span.start, hash, "lang", YamlObjectType::Message)?;
Ok(MessageContent {
text: text.to_string(),
language: language.to_string(),
})
}
}
pub(crate) fn parse_message_contents_yaml(
value: &MarkedYaml,
key: &str,
parent_yaml_type: YamlObjectType,
) -> Result<Vec<MessageContent>, GeneralError> {
let contents = match &value.data {
YamlData::String(s) => {
vec![MessageContent {
text: s.to_string(),
language: MessageContent::DEFAULT_LANGUAGE.to_string(),
}]
}
YamlData::Array(a) => a
.iter()
.map(MessageContent::try_from)
.collect::<Result<Vec<MessageContent>, _>>()?,
_ => {
return Err(YamlParseError::new(
value.span.start,
format!(
"'{}' key in '{}' map is not a list or string",
key, parent_yaml_type
),
)
.into());
}
};
validate_message_contents(&contents)?;
Ok(contents)
}
impl TryFrom<&MarkedYaml> for Message {
type Error = GeneralError;
fn try_from(value: &MarkedYaml) -> Result<Self, Self::Error> {
let hash = get_as_hash(value, YamlObjectType::Message)?;
let message_type =
get_required_string_value(value.span.start, hash, "type", YamlObjectType::Message)?;
let message_type = match message_type {
"warn" => MessageType::Warn,
"error" => MessageType::Error,
_ => MessageType::Say,
};
let mut content = match hash.get(&as_string_node("content")) {
Some(n) => parse_message_contents_yaml(n, "content", YamlObjectType::Message)?,
None => {
return Err(YamlParseError::missing_key(
value.span.start,
"content",
YamlObjectType::Message,
)
.into());
}
};
let subs = get_strings_vec_value(hash, "subs", YamlObjectType::Message)?;
if !subs.is_empty() {
for mc in &mut content {
for (index, sub) in subs.iter().enumerate() {
let placeholder = format!("{}", index);
if !mc.text.contains(&placeholder) {
return Err(YamlParseError::new(value.span.start, format!("Failed to substitute \"{}\" into message, no placeholder \"{}\" was found", sub, placeholder)).into());
}
mc.text = mc.text.replace(&placeholder, sub);
}
}
}
let condition = match get_string_value(hash, "condition", YamlObjectType::Message)? {
Some(c) => {
Expression::from_str(c)?;
Some(c.to_string())
}
None => None,
};
Ok(Message {
message_type,
content,
condition,
})
}
}
+469
View File
@@ -0,0 +1,469 @@
use std::{
collections::{HashMap, HashSet},
path::Path,
sync::LazyLock,
};
use saphyr::{MarkedYaml, YamlData};
use crate::error::{FileAccessError, GeneralError, YamlMergeKeyError, YamlParseError};
use super::{
file::Filename,
group::Group,
message::Message,
plugin_metadata::PluginMetadata,
yaml::{YamlObjectType, as_string_node, get_as_slice},
};
static MERGE_KEY: LazyLock<MarkedYaml> = LazyLock::new(|| as_string_node("<<"));
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct MetadataDocument {
bash_tags: Vec<String>,
groups: Vec<Group>,
messages: Vec<Message>,
plugins: HashMap<Filename, PluginMetadata>,
regex_plugins: Vec<PluginMetadata>,
}
impl MetadataDocument {
pub fn load(&mut self, file_path: &Path) -> Result<(), GeneralError> {
log::trace!("Loading file: {:?}", file_path);
let content = std::fs::read_to_string(file_path)?;
self.load_from_str(&content)?;
log::trace!(
"Successfully loaded metadata from file at \"{:?}\".",
file_path
);
Ok(())
}
pub fn load_with_prelude(
&mut self,
masterlist_path: &Path,
prelude_path: &Path,
) -> Result<(), GeneralError> {
let masterlist = std::fs::read_to_string(masterlist_path)?;
let prelude = std::fs::read_to_string(prelude_path)?;
let masterlist = replace_prelude(masterlist, prelude);
self.load_from_str(&masterlist)?;
log::trace!(
"Successfully loaded metadata from file at \"{:?}\".",
masterlist_path
);
Ok(())
}
fn load_from_str(&mut self, string: &str) -> Result<(), GeneralError> {
let mut docs = MarkedYaml::load_from_str(string)?;
let doc = docs
.pop()
.ok_or_else(|| FileAccessError::new("No documents in the loaded YAML".into()))?;
if !docs.is_empty() {
return Err(FileAccessError::new(format!(
"YAML file contained more than one document, found {}",
docs.len() + 1
))
.into());
}
let doc = process_merge_keys(doc)?;
let doc = match doc.data {
YamlData::Hash(h) => h,
_ => {
return Err(YamlParseError::new(
doc.span.start,
"The root of the YAML document is not a map.".into(),
)
.into());
}
};
let mut plugins: HashMap<Filename, PluginMetadata> = HashMap::new();
let mut regex_plugins: Vec<PluginMetadata> = Vec::new();
for plugin in get_as_slice(&doc, "plugins", YamlObjectType::MetadataDocument)? {
let plugin = PluginMetadata::try_from(plugin)?;
if plugin.is_regex_plugin() {
regex_plugins.push(plugin);
} else {
let filename = Filename::new(plugin.name().to_string());
if plugins.contains_key(&filename) {
return Err(FileAccessError::new(format!(
"More than one entry exists for plugin \"{}\"",
plugin.name()
))
.into());
}
plugins.insert(filename, plugin);
}
}
let messages = get_as_slice(&doc, "globals", YamlObjectType::MetadataDocument)?
.iter()
.map(Message::try_from)
.collect::<Result<Vec<_>, _>>()?;
let mut bash_tags = Vec::new();
let mut str_set = HashSet::new();
for bash_tag_yaml in get_as_slice(&doc, "bash_tags", YamlObjectType::MetadataDocument)? {
let bash_tag = match bash_tag_yaml.data.as_str() {
Some(b) => b,
None => {
return Err(YamlParseError::new(
bash_tag_yaml.span.start,
"Found a non-string Bash Tag.".into(),
)
.into());
}
};
if str_set.contains(bash_tag) {
return Err(YamlParseError::new(
bash_tag_yaml.span.start,
format!("More than one entry exists for Bash Tag \"{}\"", bash_tag),
)
.into());
}
bash_tags.push(bash_tag.to_string());
str_set.insert(bash_tag);
}
let mut group_names = HashSet::new();
let mut groups = Vec::new();
for group_yaml in get_as_slice(&doc, "groups", YamlObjectType::MetadataDocument)? {
let group = Group::try_from(group_yaml)?;
let name = group.name().to_string();
if group_names.contains(&name) {
return Err(YamlParseError::new(
group_yaml.span.start,
format!("More than one entry exists for group \"{}\"", group.name()),
)
.into());
}
groups.push(group);
group_names.insert(name);
}
if !group_names.contains(Group::DEFAULT_NAME) {
groups.insert(0, Group::default());
}
self.plugins = plugins;
self.regex_plugins = regex_plugins;
self.messages = messages;
self.bash_tags = bash_tags;
self.groups = groups;
Ok(())
}
pub fn save(&self, file_path: &Path) -> Result<(), GeneralError> {
// let mut hash = saphyr::Hash::new();
// hash.insert(
// Yaml::String("bash_tags".into()),
// Yaml::Array(
// self.bash_tags
// .iter()
// .map(|b| Yaml::String(b.to_string()))
// .collect(),
// ),
// );
// let mut yaml = Yaml::Hash(hash);
// let mut output = String::new();
// FIXME: Can't handle the error because it's a type that's not exported by saphyr.
// let emitter = YamlEmitter::new(&mut output).dump(&yaml)?;
todo!()
}
pub fn bash_tags(&self) -> &[String] {
&self.bash_tags
}
pub fn groups(&self) -> &[Group] {
&self.groups
}
pub fn messages(&self) -> &[Message] {
&self.messages
}
pub fn plugins(&self) -> impl Iterator<Item = &PluginMetadata> {
self.plugins.values().chain(self.regex_plugins.iter())
}
pub fn find_plugin(&self, plugin_name: &str) -> Result<Option<PluginMetadata>, GeneralError> {
let mut metadata = match self.plugins.get(&Filename::new(plugin_name.to_string())) {
Some(m) => m.clone(),
None => PluginMetadata::new(plugin_name)?,
};
// Now we want to also match possibly multiple regex entries.
for regex_plugin in &self.regex_plugins {
if regex_plugin.name_matches(plugin_name) {
metadata.merge_metadata(regex_plugin);
}
}
if metadata.has_name_only() {
Ok(None)
} else {
Ok(Some(metadata))
}
}
pub fn set_groups(&mut self, groups: Vec<Group>) {
self.groups = groups;
}
pub fn set_plugin_metadata(&mut self, plugin_metadata: PluginMetadata) {
self.plugins.insert(
Filename::new(plugin_metadata.name().to_string()),
plugin_metadata,
);
}
pub fn remove_plugin_metadata(&mut self, plugin_name: &str) {
self.plugins.remove(&Filename::new(plugin_name.to_string()));
}
pub fn clear(&mut self) {
self.bash_tags.clear();
self.groups.clear();
self.messages.clear();
self.plugins.clear();
self.regex_plugins.clear();
}
}
fn process_merge_keys(mut yaml: MarkedYaml) -> Result<MarkedYaml, YamlMergeKeyError> {
match yaml.data {
YamlData::Alias(_) => panic!("Alias encountered!"),
YamlData::Array(a) => {
yaml.data = merge_array_elements(a).map(YamlData::Array)?;
Ok(yaml)
}
YamlData::Hash(h) => {
yaml.data = merge_hash_keys(h).map(YamlData::Hash)?;
Ok(yaml)
}
_ => Ok(yaml),
}
}
fn merge_array_elements(
array: saphyr::AnnotatedArray<MarkedYaml>,
) -> Result<saphyr::AnnotatedArray<MarkedYaml>, YamlMergeKeyError> {
array.into_iter().map(process_merge_keys).collect()
}
fn merge_hash_keys(
hash: saphyr::AnnotatedHash<MarkedYaml>,
) -> Result<saphyr::AnnotatedHash<MarkedYaml>, YamlMergeKeyError> {
let mut hash: saphyr::AnnotatedHash<MarkedYaml> = hash
.into_iter()
.map(|(key, value)| {
process_merge_keys(key)
.and_then(|key| process_merge_keys(value).map(|value| (key, value)))
})
.collect::<Result<_, _>>()?;
if let Some(value) = hash.remove(&MERGE_KEY) {
merge_into_hash(hash, value)
} else {
Ok(hash)
}
}
fn merge_into_hash(
hash: saphyr::AnnotatedHash<MarkedYaml>,
value: MarkedYaml,
) -> Result<saphyr::AnnotatedHash<MarkedYaml>, YamlMergeKeyError> {
match value.data {
YamlData::<MarkedYaml>::Array(a) => a.into_iter().try_fold(hash, |acc, e| {
if let YamlData::Hash(h) = e.data {
Ok(merge_hashes(acc, h))
} else {
Err(YamlMergeKeyError::new(e))
}
}),
YamlData::<MarkedYaml>::Hash(h) => Ok(merge_hashes(hash, h)),
_ => Err(YamlMergeKeyError::new(value)),
}
}
fn merge_hashes(
mut hash1: saphyr::AnnotatedHash<MarkedYaml>,
hash2: saphyr::AnnotatedHash<MarkedYaml>,
) -> saphyr::AnnotatedHash<MarkedYaml> {
for (key, value) in hash2 {
hash1.entry(key).or_insert(value);
}
hash1
}
fn replace_prelude(masterlist: String, prelude: String) -> String {
if let Some((start, end)) = find_prelude_bounds(&masterlist) {
let prelude = indent_prelude(prelude);
masterlist[..start].to_string() + &prelude + &masterlist[end..]
} else {
masterlist
}
}
fn find_prelude_bounds(masterlist: &str) -> Option<(usize, usize)> {
let prelude_on_first_line = "prelude:";
let prelude_on_new_line = "\nprelude:";
let start = if masterlist.starts_with(prelude_on_first_line) {
prelude_on_first_line.len()
} else if let Some(pos) = masterlist.find(prelude_on_new_line) {
pos + prelude_on_new_line.len()
} else {
return None;
};
let mut pos = start;
while let Some(next_line_break_pos) = masterlist[pos..].find('\n') {
if next_line_break_pos == masterlist.len() - 1 {
break;
}
pos = next_line_break_pos + 1;
if let Some(c) = masterlist.as_bytes().get(pos) {
if *c != b' ' && *c != b'#' && *c != b'\n' {
return Some((start, next_line_break_pos));
}
}
}
Some((start, masterlist.len()))
}
fn indent_prelude(prelude: String) -> String {
let prelude = ("\n ".to_string() + &prelude.replace("\n", "\n ")).replace(" \n", "\n");
if prelude.ends_with("\n ") {
prelude[..prelude.len() - 2].to_string()
} else {
prelude
}
}
#[cfg(test)]
mod tests {
use tempfile::tempdir;
use super::*;
const METADATA_LIST_YAML: &str = r#"bash_tags:
- 'C.Climate'
- 'Relev'
groups:
- name: group1
after:
- group2
- name: group2
after:
- default
globals:
- type: say
content: 'A global message.'
plugins:
- name: 'Blank.esm'
priority: -100
msg:
- type: warn
content: 'This is a warning.'
- type: say
content: 'This message should be removed when evaluating conditions.'
condition: 'active("Blank - Different.esm")'
- name: 'Blank.+\.esp'
after:
- 'Blank.esm'
- name: 'Blank.+(Different)?.*\.esp'
inc:
- 'Blank.esp'
- name: 'Blank.esp'
group: group2
dirty:
- crc: 0xDEADBEEF
util: utility
"#;
#[test]
fn load_should_resolve_aliases() {
let tmp_dir = tempdir().unwrap();
let yaml = r#"
prelude:
- &anchor
type: say
content: test message
globals:
- *anchor
"#;
let path = tmp_dir.path().join("masterlist.yaml");
std::fs::write(&path, yaml).unwrap();
let mut metadata_list = MetadataDocument::default();
metadata_list.load(&path).unwrap();
}
#[test]
fn load_should_resolve_merge_keys() {
let tmp_dir = tempdir().unwrap();
let yaml = r#"
prelude:
- &anchor
type: say
content: test message
globals:
- <<: *anchor
condition: file("test.esp")
"#;
let path = tmp_dir.path().join("masterlist.yaml");
std::fs::write(&path, yaml).unwrap();
let mut metadata_list = MetadataDocument::default();
metadata_list.load(&path).unwrap();
}
#[test]
fn load_should_deserialise_masterlist() {
let tmp_dir = tempdir().unwrap();
let path = tmp_dir.path().join("masterlist.yaml");
std::fs::write(&path, METADATA_LIST_YAML).unwrap();
let mut metadata_list = MetadataDocument::default();
metadata_list.load(&path).unwrap();
}
}

Some files were not shown because too many files have changed in this diff Show More