mirror of
https://github.com/loot/libloot.git
synced 2026-07-27 14:16:01 -07:00
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:
@@ -0,0 +1 @@
|
||||
/target
|
||||
Generated
+1045
File diff suppressed because it is too large
Load Diff
+21
@@ -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"
|
||||
@@ -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
|
||||
```
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
mod ba2;
|
||||
mod bsa;
|
||||
mod find;
|
||||
mod parse;
|
||||
|
||||
pub use find::find_associated_archives;
|
||||
pub use parse::assets_in_archives;
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+24
@@ -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()?)
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user