diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5f0e3eb5..9637c77d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,217 +8,186 @@ on: pull_request: env: - BOOST_VERSION: 1.87.0 - CBINDGEN_VERSION: 0.28.0 CARGO_TERM_COLOR: always - MSVC_CONFIG: RelWithDebInfo + CMAKE_CONFIG: RelWithDebInfo jobs: - linux: + rustfmt: runs-on: ubuntu-24.04 - steps: - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Cache cargo - uses: actions/cache@v4 - with: - path: | - ~/.cargo/bin/ - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - ~/.cargo/.crates.toml - ~/.cargo/.crates2.json - key: ${{ runner.os }}-cargo-cbindgen-${{ env.CBINDGEN_VERSION }} - - - name: Get descriptive libloot version - id: get-libloot-version - shell: bash + - name: Check formatting run: | - GIT_DESCRIBE=$(git describe --tags --long --abbrev=7) - LIBLOOT_DESC_REF=${GIT_DESCRIBE}_${GITHUB_REF#refs/*/} - LIBLOOT_SAFE_DESC_REF=${LIBLOOT_DESC_REF//[\/<>\"|]/_} - echo "version=$LIBLOOT_SAFE_DESC_REF" >> $GITHUB_OUTPUT + cargo fmt -- --version + cargo fmt --all -- --check - - name: Get Boost metadata - id: boost-metadata + clippy: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - name: Run clippy run: | - BOOST_ROOT="${{ github.workspace }}/boost_${BOOST_VERSION//./_}" - echo "root=$BOOST_ROOT" >> $GITHUB_OUTPUT - - - name: Boost cache - id: boost-cache - uses: actions/cache@v4 - with: - path: | - ${{ steps.boost-metadata.outputs.root }}/boost - ${{ steps.boost-metadata.outputs.root }}/stage - key: ${{ runner.os }}-Boost-${{ env.BOOST_VERSION }} - - # Need to build Boost's 'system' stub to generate the CMake config file. - - name: Download & build Boost - run: | - curl -sSfLO https://raw.githubusercontent.com/Ortham/ci-scripts/2.2.1/install_boost.py - python install_boost.py --directory ${{ github.workspace }} --boost-version $BOOST_VERSION -a 64 system - if: steps.boost-cache.outputs.cache-hit != 'true' - - - name: Install APT package dependencies - run: | - sudo apt-get update - sudo apt-get install -y --no-upgrade \ - doxygen \ - language-pack-el \ - language-pack-tr \ - libtbb-dev \ - libicu-dev - - # The version in apt is too old. - - name: Install cbindgen - run: | - wget https://github.com/mozilla/cbindgen/releases/download/$CBINDGEN_VERSION/cbindgen - BIN_PATH="$HOME/.local/bin/" - mkdir -p "$BIN_PATH" - mv cbindgen "$BIN_PATH" - chmod +x "$BIN_PATH/cbindgen" - echo "$BIN_PATH" >> "$GITHUB_PATH" - - - name: Run CMake - run: | - cmake \ - -DCMAKE_PREFIX_PATH="${{ steps.boost-metadata.outputs.root }}\stage" \ - -DCPACK_PACKAGE_VERSION="${{ steps.get-libloot-version.outputs.version }}" \ - -DCPACK_THREADS=0 \ - -B build - cmake --build build --parallel - - - name: Run tests - run: ctest --test-dir build --output-on-failure --parallel - - - uses: actions/setup-python@v5 - with: - python-version: '3.10' - - - name: Install packages for building docs - shell: bash - working-directory: docs - run: | - pipx install uv - uv sync - - - name: Build docs - working-directory: docs - run: uv run -- sphinx-build -b html . ../build/docs/html - - - name: Build archive - id: build-archive - shell: bash - run: | - cd build - cpack - - VERSION="${{ steps.get-libloot-version.outputs.version }}" - echo "filename=libloot-${VERSION}-Linux.tar.xz" >> $GITHUB_OUTPUT - - - name: Import GPG key - run: echo -n "${{ secrets.GPG_SIGNING_KEY }}" | gpg --import - if: github.event_name == 'push' - - - name: Sign archive - run: gpg --output "build/package/${{ steps.build-archive.outputs.filename }}.sig" --detach-sig "build/package/${{ steps.build-archive.outputs.filename }}" - if: github.event_name == 'push' - - - name: Upload archive - uses: actions/upload-artifact@v4 - with: - name: ${{ steps.build-archive.outputs.filename }} - path: | - build/package/${{ steps.build-archive.outputs.filename }} - build/package/${{ steps.build-archive.outputs.filename }}.sig - if: github.event_name == 'push' - - windows: - runs-on: windows-2025 + cargo clippy -- --version + cargo clippy --all --all-targets -- -Dwarnings + rust: strategy: matrix: - platform: [Win32, x64] + target: + - os: windows-2025 + platform: Win32 + triple: i686-pc-windows-msvc + architecture: x86 + + - os: windows-2025 + platform: x64 + triple: x86_64-pc-windows-msvc + architecture: x64 + + - os: ubuntu-24.04 + platform: x86_64 + triple: x86_64-unknown-linux-gnu + architecture: x64 + + runs-on: ${{ matrix.target.os }} steps: - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Cache cargo - uses: actions/cache@v4 + - uses: actions/cache@v4 with: path: | ~/.cargo/bin/ ~/.cargo/registry/index/ ~/.cargo/registry/cache/ ~/.cargo/git/db/ - ~/.cargo/.crates.toml - ~/.cargo/.crates2.json - key: ${{ runner.os }}-cargo-cbindgen-${{ env.CBINDGEN_VERSION }} + target/ + key: ${{ github.job }}-${{ runner.os }}-${{ matrix.target.architecture }}-cargo-${{ hashFiles('**/Cargo.lock') }} - name: Install Rust i686-pc-windows-msvc target run: rustup target add i686-pc-windows-msvc - if: matrix.platform == 'Win32' + if: matrix.target.platform == 'Win32' - - name: Get Boost metadata - id: boost-metadata + - name: Install cargo-llvm-cov + uses: taiki-e/install-action@cargo-llvm-cov + + - name: Prepare test resources + shell: pwsh run: | - $BOOST_ROOT="${{ github.workspace }}/boost_" + $env:BOOST_VERSION -replace "\.", "_" - echo "root=$BOOST_ROOT" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + Invoke-WebRequest https://github.com/Ortham/testing-plugins/archive/refs/tags/1.6.2.zip -OutFile testing-plugins-1.6.2.zip + Expand-Archive testing-plugins-1.6.2.zip . + Move-Item testing-plugins-1.6.2 testing-plugins + Remove-Item testing-plugins-1.6.2.zip - - name: Boost cache - id: boost-cache - uses: actions/cache@v4 + - name: Set LIBLOOT_REVISION + shell: bash + run: echo "LIBLOOT_REVISION=$(git rev-parse --short HEAD)" >> $GITHUB_ENV + + - name: Build and run tests with code coverage + run: cargo llvm-cov --lcov --output-path lcov.info --target ${{ matrix.target.triple }} + + - name: Upload code coverage to Coveralls + uses: coverallsapp/github-action@v2 + if: github.event_name == 'push' + + cpp: + strategy: + matrix: + target: + - os: windows-2025 + platform: Win32 + triple: i686-pc-windows-msvc + architecture: x86 + + - os: windows-2025 + platform: x64 + triple: x86_64-pc-windows-msvc + architecture: x64 + + - os: ubuntu-24.04 + platform: x86_64 + triple: x86_64-unknown-linux-gnu + architecture: x64 + + runs-on: ${{ matrix.target.os }} + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - uses: actions/cache@v4 with: path: | - ${{ steps.boost-metadata.outputs.root }}/boost - ${{ steps.boost-metadata.outputs.root }}/stage - key: ${{ runner.os }}-x64-Boost-${{ env.BOOST_VERSION }} + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ github.job }}-${{ runner.os }}-${{ matrix.target.architecture }}-cargo-${{ hashFiles('**/Cargo.lock') }} - # Need to build Boost's 'system' stub to generate the CMake config file. - - name: Download & build Boost - run: | - curl -sSfLO https://raw.githubusercontent.com/Ortham/ci-scripts/2.2.1/install_boost.py - python install_boost.py --directory ${{ github.workspace }} --boost-version ${{ env.BOOST_VERSION }} -a 64 system - if: steps.boost-cache.outputs.cache-hit != 'true' + - name: Install Rust i686-pc-windows-msvc target + run: rustup target add i686-pc-windows-msvc + if: matrix.target.platform == 'Win32' + + - name: Set LIBLOOT_REVISION + shell: bash + run: echo "LIBLOOT_REVISION=$(git rev-parse --short HEAD)" >> $GITHUB_ENV - name: Get descriptive libloot version id: get-libloot-version shell: bash run: | - GIT_DESCRIBE=$(git describe --tags --long --abbrev=7) + GIT_DESCRIBE=$(git describe --tags --long --always --abbrev=7) LIBLOOT_DESC_REF=${GIT_DESCRIBE}_${GITHUB_REF#refs/*/} LIBLOOT_SAFE_DESC_REF=${LIBLOOT_DESC_REF//[\/<>\"|]/_} echo "version=$LIBLOOT_SAFE_DESC_REF" >> $GITHUB_OUTPUT - - name: Install cbindgen - run: cargo install --version $env:CBINDGEN_VERSION cbindgen + - name: Build the C++ wrapper (Linux) + working-directory: cpp + run: | + cmake \ + -DCMAKE_BUILD_TYPE=${{ env.CMAKE_CONFIG }} \ + -DCPACK_PACKAGE_VERSION="${{ steps.get-libloot-version.outputs.version }}" \ + -DCPACK_THREADS=0 \ + -DRUST_TARGET="${{ matrix.target.triple }}" \ + -B build + cmake --build build --parallel + if: runner.os == 'Linux' - - name: Run CMake + - name: Build the C++ wrapper (Windows) + working-directory: cpp run: | cmake -G "Visual Studio 17 2022" ` - -A ${{ matrix.platform }} ` - -DCMAKE_PREFIX_PATH="${{ steps.boost-metadata.outputs.root }}\stage" ` + -A ${{ matrix.target.platform }} ` -DCPACK_PACKAGE_VERSION="${{ steps.get-libloot-version.outputs.version }}" ` -DCPACK_THREADS=0 ` + -DRUST_TARGET="${{ matrix.target.triple }}" ` -B build - cmake --build build --parallel --config ${{ env.MSVC_CONFIG }} + cmake --build build --parallel --config ${{ env.CMAKE_CONFIG }} + if: runner.os == 'Windows' - - name: Run tests - run: ctest --test-dir build --build-config ${{ env.MSVC_CONFIG }} --output-on-failure --parallel + - name: Run the C++ wrapper tests + working-directory: cpp + run: ctest --test-dir build --build-config ${{ env.CMAKE_CONFIG }} --output-on-failure --parallel - - uses: actions/setup-python@v5 - with: - python-version: '3.10' + - name: Install packages for building docs (Linux) + working-directory: docs + run: | + sudo apt-get update + sudo apt-get install -y --no-upgrade doxygen + pipx install uv + uv sync + if: runner.os == 'Linux' - - name: Install packages for building docs + - name: Install packages for building docs (Windows) + working-directory: docs run: | curl -sSfLO https://github.com/doxygen/doxygen/releases/download/Release_1_13_2/doxygen-1.13.2.windows.x64.bin.zip Expand-Archive doxygen-1.13.2.windows.x64.bin.zip @@ -226,43 +195,169 @@ jobs: echo "${{ github.workspace }}\doxygen-1.13.2.windows.x64.bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append pipx install uv - cd docs uv sync + if: runner.os == 'Windows' - name: Build docs working-directory: docs - run: uv run -- sphinx-build -b html . ../build/docs/html + run: uv run -- sphinx-build -b html . build/html - - name: Build archive - id: build-archive + - name: Package the C++ wrapper + working-directory: cpp/build + run: cpack -C ${{ env.CMAKE_CONFIG }} + + - name: Get archive name + id: archive-name shell: bash run: | - cd build - cpack -C ${{ env.MSVC_CONFIG }} - VERSION="${{ steps.get-libloot-version.outputs.version }}" - if [[ "${{ matrix.platform }}" == "Win32" ]] + + if [[ "${{ runner.os }}" == "Windows" ]] then - PLATFORM=win32 + EXTENSION=7z + if [[ "${{ matrix.target.platform }}" == "Win32" ]] + then + PLATFORM=win32 + else + PLATFORM=win64 + fi else - PLATFORM=win64 + EXTENSION=tar.xz + PLATFORM=Linux fi - echo "filename=libloot-${VERSION}-${PLATFORM}.7z" >> $GITHUB_OUTPUT + echo "filename=libloot-${VERSION}-${PLATFORM}.${EXTENSION}" >> $GITHUB_OUTPUT - name: Import GPG key run: echo -n "${{ secrets.GPG_SIGNING_KEY }}" | gpg --import if: github.event_name == 'push' - name: Sign archive - run: gpg --output "build/package/${{ steps.build-archive.outputs.filename }}.sig" --detach-sig "build/package/${{ steps.build-archive.outputs.filename }}" + working-directory: cpp/build/package + run: gpg --output "${{ steps.archive-name.outputs.filename }}.sig" --detach-sig "${{ steps.archive-name.outputs.filename }}" if: github.event_name == 'push' - name: Upload archive uses: actions/upload-artifact@v4 with: - name: ${{ steps.build-archive.outputs.filename }} + name: ${{ steps.archive-name.outputs.filename }} path: | - build/package/${{ steps.build-archive.outputs.filename }} - build/package/${{ steps.build-archive.outputs.filename }}.sig + cpp/build/package/${{ steps.archive-name.outputs.filename }} + cpp/build/package/${{ steps.archive-name.outputs.filename }}.sig if: github.event_name == 'push' + + python: + strategy: + matrix: + target: + - os: windows-2025 + platform: Win32 + triple: i686-pc-windows-msvc + architecture: x86 + + - os: windows-2025 + platform: x64 + triple: x86_64-pc-windows-msvc + architecture: x64 + + - os: ubuntu-24.04 + platform: x86_64 + triple: x86_64-unknown-linux-gnu + architecture: x64 + + runs-on: ${{ matrix.target.os }} + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.13' + architecture: ${{ matrix.target.architecture }} + + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ github.job }}-${{ runner.os }}-${{ matrix.target.architecture }}-cargo-${{ hashFiles('**/Cargo.lock') }} + + - name: Install Rust i686-pc-windows-msvc target + run: rustup target add i686-pc-windows-msvc + if: matrix.target.platform == 'Win32' + + - name: Set LIBLOOT_REVISION + shell: bash + run: echo "LIBLOOT_REVISION=$(git rev-parse --short HEAD)" >> $GITHUB_ENV + + - name: Build the Python wrapper + shell: bash + working-directory: python + run: | + python -m venv .venv + + if [[ "${{ runner.os }}" == "Windows" ]] + then + ./.venv/Scripts/activate + else + . .venv/bin/activate + fi + + pip install maturin + + maturin build --target ${{ matrix.target.triple }} --release + + nodejs: + strategy: + matrix: + target: + - os: windows-2025 + platform: Win32 + triple: i686-pc-windows-msvc + architecture: x86 + + - os: windows-2025 + platform: x64 + triple: x86_64-pc-windows-msvc + architecture: x64 + + - os: ubuntu-24.04 + platform: x86_64 + triple: x86_64-unknown-linux-gnu + architecture: x64 + + runs-on: ${{ matrix.target.os }} + + steps: + - uses: actions/checkout@v4 + + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ github.job }}-${{ runner.os }}-${{ matrix.target.architecture }}-cargo-${{ hashFiles('**/Cargo.lock') }} + + - name: Install Rust i686-pc-windows-msvc target + run: rustup target add i686-pc-windows-msvc + if: matrix.target.platform == 'Win32' + + - name: Set LIBLOOT_REVISION + shell: bash + run: echo "LIBLOOT_REVISION=$(git rev-parse --short HEAD)" >> $GITHUB_ENV + + - name: Build the Node.js wrapper + working-directory: nodejs + run: | + npm install + npm run build + + - name: Run the Node.js wrapper's tests + working-directory: nodejs + run: npm test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8f8dbc78..f27a2746 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,10 +5,8 @@ on: tags: '*' env: - BOOST_VERSION: 1.87.0 - CBINDGEN_VERSION: 0.28.0 CARGO_TERM_COLOR: always - MSVC_CONFIG: RelWithDebInfo + CMAKE_CONFIG: RelWithDebInfo jobs: create_release: @@ -42,7 +40,12 @@ jobs: strategy: matrix: - platform: [Win32, x64] + target: + - platform: Win32 + triple: i686-pc-windows-msvc + + - platform: x64 + triple: x86_64-pc-windows-msvc steps: - uses: actions/checkout@v4 @@ -57,54 +60,34 @@ jobs: ~/.cargo/registry/index/ ~/.cargo/registry/cache/ ~/.cargo/git/db/ - ~/.cargo/.crates.toml - ~/.cargo/.crates2.json - key: ${{ runner.os }}-cargo-cbindgen-${{ env.CBINDGEN_VERSION }} + target/ + key: ${{ runner.os }}-${{ matrix.target.platform }}-cargo-${{ hashFiles('**/Cargo.lock') }} - name: Install Rust i686-pc-windows-msvc target run: rustup target add i686-pc-windows-msvc - if: matrix.platform == 'Win32' + if: matrix.target.platform == 'Win32' - - name: Get Boost metadata - id: boost-metadata - run: | - $BOOST_ROOT="${{ github.workspace }}/boost_" + $env:BOOST_VERSION -replace "\.", "_" - echo "root=$BOOST_ROOT" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - - - name: Boost cache - id: boost-cache - uses: actions/cache@v4 - with: - path: | - ${{ steps.boost-metadata.outputs.root }}/boost - ${{ steps.boost-metadata.outputs.root }}/stage - key: ${{ runner.os }}-x64-Boost-${{ env.BOOST_VERSION }} - - # Need to build Boost's 'system' stub to generate the CMake config file. - - name: Download & build Boost - run: | - curl -sSfLO https://raw.githubusercontent.com/Ortham/ci-scripts/2.2.1/install_boost.py - python install_boost.py --directory ${{ github.workspace }} --boost-version ${{ env.BOOST_VERSION }} -a 64 system - if: steps.boost-cache.outputs.cache-hit != 'true' - - - name: Install cbindgen - run: cargo install --version $env:CBINDGEN_VERSION cbindgen + - name: Set LIBLOOT_REVISION + shell: bash + run: echo "LIBLOOT_REVISION=$(git rev-parse --short HEAD)" >> "$GITHUB_ENV" - name: Run CMake + working-directory: cpp run: | cmake -G "Visual Studio 17 2022" ` - -A ${{ matrix.platform }} ` - -DCMAKE_PREFIX_PATH="${{ steps.boost-metadata.outputs.root }}\stage" ` + -A ${{ matrix.target.platform }} ` -DCPACK_PACKAGE_VERSION="${{ needs.create_release.outputs.git_tag }}" ` -DCPACK_THREADS=0 ` + -DRUST_TARGET="${{ matrix.target.triple }}" ` -B build - cmake --build build --parallel --config ${{ env.MSVC_CONFIG }} + cmake --build build --parallel --config ${{ env.CMAKE_CONFIG }} - uses: actions/setup-python@v5 with: - python-version: '3.10' + python-version: '3.13' - name: Install packages for building docs + working-directory: docs run: | curl -sSfLO https://github.com/doxygen/doxygen/releases/download/Release_1_13_2/doxygen-1.13.2.windows.x64.bin.zip Expand-Archive doxygen-1.13.2.windows.x64.bin.zip @@ -112,28 +95,27 @@ jobs: echo "${{ github.workspace }}\doxygen-1.13.2.windows.x64.bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append pipx install uv - cd docs uv sync - name: Build docs working-directory: docs - run: uv run -- sphinx-build -b html . ../build/docs/html + run: uv run -- sphinx-build -b html . build/html - name: Build archive id: build-archive shell: bash + working-directory: cpp/build run: | - cd build - cpack -C ${{ env.MSVC_CONFIG }} + cpack -C ${{ env.CMAKE_CONFIG }} - if [[ "${{ matrix.platform }}" == "Win32" ]] + if [[ "${{ matrix.target.platform }}" == "Win32" ]] then PLATFORM=win32 else PLATFORM=win64 fi - echo "filename=libloot-${{ needs.create_release.outputs.git_tag }}-${PLATFORM}.7z" >> $GITHUB_OUTPUT + echo "filename=libloot-${{ needs.create_release.outputs.git_tag }}-${PLATFORM}.7z" >> "$GITHUB_OUTPUT" - name: Upload Archive uses: actions/upload-release-asset@v1 @@ -141,6 +123,6 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ needs.create_release.outputs.upload_url }} - asset_path: build/package/${{ steps.build-archive.outputs.filename }} + asset_path: cpp/build/package/${{ steps.build-archive.outputs.filename }} asset_name: ${{ steps.build-archive.outputs.filename }} asset_content_type: application/x-7z-compressed diff --git a/.gitignore b/.gitignore index 3cd32a3b..c14a209c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,54 +1,4 @@ -# Compiled source # -################### -*.com -*.class -*.dll -*.exe -*.o -*.so - -# Packages # -############ -# it's better to unpack these files and commit the raw source -# git has its own built in compression methods -*.7z -*.dmg -*.gz -*.iso -*.jar -*.rar -*.tar -*.zip - -# Logs and databases # -###################### -*.log -*.sql -*.sqlite - -# Temporary files # -##################3 -*~ - -# OS generated files # -###################### -.DS_Store -.DS_Store? -._* -.Spotlight-V100 -.Trashes -Icon? -ehthumbs.db -Thumbs.db - -# Other # -######### - -*.user -*.suo -*.sdf -*.opensdf -build +/target +/testing-plugins .venv/ -.vscode/ -.idea/ \ No newline at end of file +build/ diff --git a/CMakeLists.txt b/CMakeLists.txt deleted file mode 100644 index 6c483fad..00000000 --- a/CMakeLists.txt +++ /dev/null @@ -1,498 +0,0 @@ -cmake_minimum_required(VERSION 3.24) -if(POLICY CMP0135) - cmake_policy(SET CMP0135 NEW) -endif() -if(POLICY CMP0167) - cmake_policy(SET CMP0167 NEW) -endif() -project(libloot) -include(ExternalProject) -include(FetchContent) -include(CMakePackageConfigHelpers) -include(GNUInstallDirs) - -option(BUILD_SHARED_LIBS "Build a shared library" ON) -option(RUN_CLANG_TIDY "Whether or not to run clang-tidy during build. Has no effect when using CMake's MSVC generator." OFF) -option(LIBLOOT_BUILD_TESTS "Whether or not to build libloot's tests." ON) -option(LIBLOOT_INSTALL_DOCS "Whether or not to install libloot's docs (which need to be built separately)." ON) - -set(CMAKE_POSITION_INDEPENDENT_CODE ON) -set(CMAKE_CXX_STANDARD 17) -set(CMAKE_CXX_STANDARD_REQUIRED ON) - -############################## -# Get Build Revision -############################## - -find_package(Git) - -if(GIT_FOUND) - execute_process(COMMAND ${GIT_EXECUTABLE} rev-parse --short HEAD - WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} - OUTPUT_VARIABLE GIT_COMMIT_STRING - OUTPUT_STRIP_TRAILING_WHITESPACE) -endif() - -if(NOT GIT_COMMIT_STRING) - set(GIT_COMMIT_STRING "unknown") -endif() - -message(STATUS "Git revision: ${GIT_COMMIT_STRING}") - -# Write to file. -configure_file("${CMAKE_SOURCE_DIR}/src/api/loot_version.cpp.in" "${CMAKE_BINARY_DIR}/generated/loot_version.cpp" @ONLY) - -############################## -# External Projects -############################## - -set(Boost_USE_STATIC_LIBS ON) -set(Boost_USE_MULTITHREADED ON) -set(Boost_USE_STATIC_RUNTIME OFF) -find_package(Boost REQUIRED CONFIG) - -if(NOT DEFINED RUST_TARGET AND WIN32) - if(MSVC) - set(RUST_TARGET_ENV "msvc") - elseif(MINGW) - set(RUST_TARGET_ENV "gnu") - endif() - - if(CMAKE_SIZEOF_VOID_P EQUAL 4) - set(RUST_TARGET_ARCH "i686") - elseif(CMAKE_SIZEOF_VOID_P EQUAL 8) - set(RUST_TARGET_ARCH "x86_64") - endif() - - if(DEFINED RUST_TARGET_ENV AND DEFINED RUST_TARGET_ARCH) - set(RUST_TARGET "${RUST_TARGET_ARCH}-pc-windows-${RUST_TARGET_ENV}") - endif() -endif() - -if(DEFINED RUST_TARGET) - set(RUST_TARGET_ARGS --target ${RUST_TARGET}) - set(RUST_CLEANUP_COMMAND ${CMAKE_COMMAND} -E rm -r "target/${RUST_TARGET}/release/deps" "target/release") -else() - set(RUST_CLEANUP_COMMAND ${CMAKE_COMMAND} -E rm -r "target/release/build" "target/release/deps") -endif() - -if(NOT CMAKE_SYSTEM_NAME STREQUAL "Windows") - find_package(ICU REQUIRED COMPONENTS data uc) - find_package(TBB REQUIRED) -endif() - -if(NOT DEFINED ESPLUGIN_URL) - set(ESPLUGIN_URL "https://github.com/Ortham/esplugin/archive/refs/tags/6.1.3.tar.gz") - set(ESPLUGIN_HASH "SHA256=d11373b9108036e8d8453ff77000ae2961f9d1243c99c42dd732dad0c83d8618") -endif() - -ExternalProject_Add(esplugin - PREFIX "external" - URL ${ESPLUGIN_URL} - URL_HASH ${ESPLUGIN_HASH} - CONFIGURE_COMMAND "" - BUILD_IN_SOURCE 1 - BUILD_COMMAND cargo build --release --config debug="limited" --manifest-path ffi/Cargo.toml ${RUST_TARGET_ARGS} && - cbindgen ffi/ -o ffi/include/esplugin.hpp - COMMAND ${RUST_CLEANUP_COMMAND} - INSTALL_COMMAND "" - BUILD_BYPRODUCTS "/target/${RUST_TARGET}/release/${CMAKE_STATIC_LIBRARY_PREFIX}esplugin_ffi${CMAKE_STATIC_LIBRARY_SUFFIX}") -ExternalProject_Get_Property(esplugin SOURCE_DIR) -set(ESPLUGIN_INCLUDE_DIRS "${SOURCE_DIR}/ffi/include") -set(ESPLUGIN_LIBRARIES "${SOURCE_DIR}/target/${RUST_TARGET}/release/${CMAKE_STATIC_LIBRARY_PREFIX}esplugin_ffi${CMAKE_STATIC_LIBRARY_SUFFIX}") -if(CMAKE_SYSTEM_NAME STREQUAL "Windows") - set(ESPLUGIN_LIBRARIES ${ESPLUGIN_LIBRARIES} userenv ntdll) -else() - set(ESPLUGIN_LIBRARIES ${ESPLUGIN_LIBRARIES} dl) -endif() - -if(NOT DEFINED LIBLOADORDER_URL) - set(LIBLOADORDER_URL "https://github.com/Ortham/libloadorder/archive/refs/tags/18.4.0.tar.gz") - set(LIBLOADORDER_HASH "SHA256=c83f1f48829e013f2634041b62fed8c4957fb7469a401250b3793953ed3c3d2c") -endif() - -ExternalProject_Add(libloadorder - PREFIX "external" - URL ${LIBLOADORDER_URL} - URL_HASH ${LIBLOADORDER_HASH} - CONFIGURE_COMMAND "" - BUILD_IN_SOURCE 1 - BUILD_COMMAND cargo build --release --config debug="limited" --manifest-path ffi/Cargo.toml ${RUST_TARGET_ARGS} && - cbindgen ffi/ -l c++ -o ffi/include/libloadorder.hpp - COMMAND ${RUST_CLEANUP_COMMAND} - INSTALL_COMMAND "" - BUILD_BYPRODUCTS "/target/${RUST_TARGET}/release/${CMAKE_STATIC_LIBRARY_PREFIX}loadorder_ffi${CMAKE_STATIC_LIBRARY_SUFFIX}") -ExternalProject_Get_Property(libloadorder SOURCE_DIR) -set(LIBLOADORDER_INCLUDE_DIRS "${SOURCE_DIR}/ffi/include") -set(LIBLOADORDER_LIBRARIES "${SOURCE_DIR}/target/${RUST_TARGET}/release/${CMAKE_STATIC_LIBRARY_PREFIX}loadorder_ffi${CMAKE_STATIC_LIBRARY_SUFFIX}") -if(CMAKE_SYSTEM_NAME STREQUAL "Windows") - set(LIBLOADORDER_LIBRARIES ${LIBLOADORDER_LIBRARIES} ntdll propsys userenv windowsapp) -else() - set(LIBLOADORDER_LIBRARIES ${LIBLOADORDER_LIBRARIES} dl) -endif() - -if(NOT DEFINED LOOT_CONDITION_INTERPRETER_URL) - set(LOOT_CONDITION_INTERPRETER_URL "https://github.com/loot/loot-condition-interpreter/archive/refs/tags/5.3.2.tar.gz") - set(LOOT_CONDITION_INTERPRETER_HASH "SHA256=11a2c62898f6450cdae6db607c63fd7898f62bf67d4874b7ceed3d3693331af3") -endif() - -ExternalProject_Add(loot-condition-interpreter - PREFIX "external" - URL ${LOOT_CONDITION_INTERPRETER_URL} - URL_HASH ${LOOT_CONDITION_INTERPRETER_HASH} - CONFIGURE_COMMAND "" - BUILD_IN_SOURCE 1 - BUILD_COMMAND cargo build --release --config debug="limited" --manifest-path ffi/Cargo.toml ${RUST_TARGET_ARGS} && - cbindgen ffi/ -o ffi/include/loot_condition_interpreter.h - COMMAND ${RUST_CLEANUP_COMMAND} - INSTALL_COMMAND "" - BUILD_BYPRODUCTS "/target/${RUST_TARGET}/release/${CMAKE_STATIC_LIBRARY_PREFIX}loot_condition_interpreter_ffi${CMAKE_STATIC_LIBRARY_SUFFIX}") -ExternalProject_Get_Property(loot-condition-interpreter SOURCE_DIR) -set(LCI_INCLUDE_DIRS "${SOURCE_DIR}/ffi/include") -set(LCI_LIBRARIES "${SOURCE_DIR}/target/${RUST_TARGET}/release/${CMAKE_STATIC_LIBRARY_PREFIX}loot_condition_interpreter_ffi${CMAKE_STATIC_LIBRARY_SUFFIX}") -if(CMAKE_SYSTEM_NAME STREQUAL "Windows") - set(LCI_LIBRARIES ${LCI_LIBRARIES} userenv) -else() - set(LCI_LIBRARIES ${LCI_LIBRARIES} dl) -endif() - -FetchContent_Declare( - fmt - URL "https://github.com/fmtlib/fmt/archive/refs/tags/11.2.0.tar.gz" - URL_HASH "SHA256=bc23066d87ab3168f27cef3e97d545fa63314f5c79df5ea444d41d56f962c6af" - FIND_PACKAGE_ARGS) - -set(SPDLOG_FMT_EXTERNAL ON) -FetchContent_Declare( - spdlog - URL "https://github.com/gabime/spdlog/archive/v1.15.3.tar.gz" - URL_HASH "SHA256=15a04e69c222eb6c01094b5c7ff8a249b36bb22788d72519646fb85feb267e67" - FIND_PACKAGE_ARGS) - -set(YAML_MSVC_SHARED_RT ON) -set(YAML_CPP_BUILD_CONTRIB OFF) -set(YAML_CPP_BUILD_TOOLS OFF) -set(YAML_BUILD_SHARED_LIBS OFF) -set(YAML_CPP_BUILD_TESTS OFF) -# Suppress additional targets added by CTest, which is pulled in by yaml-cpp. -set_property(GLOBAL PROPERTY CTEST_TARGETS_ADDED 1) -FetchContent_Declare( - yaml-cpp - URL "https://github.com/loot/yaml-cpp/archive/0.8.0+merge-key-support.3.tar.gz" - URL_HASH "SHA256=e2067f1ab8f658aeb8a8795fd0e06a753b4150b9352cae047280e659078dd44f") - -# Set BUILD_SHARED_LIBS=OFF to prevent fmt and spdlog from being built as shared -# libraries. -set(LIBLOOT_BUILD_SHARED_LIBS ${BUILD_SHARED_LIBS}) -set(BUILD_SHARED_LIBS OFF) - -FetchContent_MakeAvailable(fmt spdlog yaml-cpp) - -# Restore the original value of BUILD_SHARED_LIBS. -set(BUILD_SHARED_LIBS ${LIBLOOT_BUILD_SHARED_LIBS}) - -############################## -# General Settings -############################## - -set(LIBLOOT_SRC_API_CPP_FILES - "${CMAKE_SOURCE_DIR}/src/api/api.cpp" - "${CMAKE_SOURCE_DIR}/src/api/api_database.cpp" - "${CMAKE_SOURCE_DIR}/src/api/bsa.cpp" - "${CMAKE_SOURCE_DIR}/src/api/exception/cyclic_interaction_error.cpp" - "${CMAKE_SOURCE_DIR}/src/api/exception/undefined_group_error.cpp" - "${CMAKE_SOURCE_DIR}/src/api/metadata/condition_evaluator.cpp" - "${CMAKE_SOURCE_DIR}/src/api/metadata/file.cpp" - "${CMAKE_SOURCE_DIR}/src/api/metadata/filename.cpp" - "${CMAKE_SOURCE_DIR}/src/api/metadata/group.cpp" - "${CMAKE_SOURCE_DIR}/src/api/metadata/location.cpp" - "${CMAKE_SOURCE_DIR}/src/api/metadata/message.cpp" - "${CMAKE_SOURCE_DIR}/src/api/metadata/message_content.cpp" - "${CMAKE_SOURCE_DIR}/src/api/metadata/plugin_cleaning_data.cpp" - "${CMAKE_SOURCE_DIR}/src/api/metadata/plugin_metadata.cpp" - "${CMAKE_SOURCE_DIR}/src/api/metadata/tag.cpp" - "${CMAKE_SOURCE_DIR}/src/api/game/game.cpp" - "${CMAKE_SOURCE_DIR}/src/api/game/game_cache.cpp" - "${CMAKE_SOURCE_DIR}/src/api/game/load_order_handler.cpp" - "${CMAKE_SOURCE_DIR}/src/api/metadata_list.cpp" - "${CMAKE_SOURCE_DIR}/src/api/plugin.cpp" - "${CMAKE_SOURCE_DIR}/src/api/sorting/group_sort.cpp" - "${CMAKE_SOURCE_DIR}/src/api/sorting/plugin_sort.cpp" - "${CMAKE_SOURCE_DIR}/src/api/sorting/plugin_graph.cpp" - "${CMAKE_SOURCE_DIR}/src/api/sorting/plugin_sorting_data.cpp" - "${CMAKE_SOURCE_DIR}/src/api/helpers/crc.cpp" - "${CMAKE_SOURCE_DIR}/src/api/helpers/logging.cpp" - "${CMAKE_SOURCE_DIR}/src/api/helpers/text.cpp" - "${CMAKE_SOURCE_DIR}/src/api/vertex.cpp") - -set(LIBLOOT_INCLUDE_H_FILES - "${CMAKE_SOURCE_DIR}/include/loot/api.h" - "${CMAKE_SOURCE_DIR}/include/loot/api_decorator.h" - "${CMAKE_SOURCE_DIR}/include/loot/database_interface.h" - "${CMAKE_SOURCE_DIR}/include/loot/exception/cyclic_interaction_error.h" - "${CMAKE_SOURCE_DIR}/include/loot/exception/plugin_not_loaded_error.h" - "${CMAKE_SOURCE_DIR}/include/loot/exception/undefined_group_error.h" - "${CMAKE_SOURCE_DIR}/include/loot/enum/edge_type.h" - "${CMAKE_SOURCE_DIR}/include/loot/enum/game_type.h" - "${CMAKE_SOURCE_DIR}/include/loot/enum/log_level.h" - "${CMAKE_SOURCE_DIR}/include/loot/enum/message_type.h" - "${CMAKE_SOURCE_DIR}/include/loot/game_interface.h" - "${CMAKE_SOURCE_DIR}/include/loot/loot_version.h" - "${CMAKE_SOURCE_DIR}/include/loot/metadata/file.h" - "${CMAKE_SOURCE_DIR}/include/loot/metadata/filename.h" - "${CMAKE_SOURCE_DIR}/include/loot/metadata/group.h" - "${CMAKE_SOURCE_DIR}/include/loot/metadata/location.h" - "${CMAKE_SOURCE_DIR}/include/loot/metadata/message.h" - "${CMAKE_SOURCE_DIR}/include/loot/metadata/message_content.h" - "${CMAKE_SOURCE_DIR}/include/loot/metadata/plugin_cleaning_data.h" - "${CMAKE_SOURCE_DIR}/include/loot/metadata/plugin_metadata.h" - "${CMAKE_SOURCE_DIR}/include/loot/metadata/tag.h" - "${CMAKE_SOURCE_DIR}/include/loot/plugin_interface.h" - "${CMAKE_SOURCE_DIR}/include/loot/vertex.h") - -set(LIBLOOT_SRC_API_H_FILES - "${CMAKE_SOURCE_DIR}/src/api/api_database.h" - "${CMAKE_SOURCE_DIR}/src/api/bsa.h" - "${CMAKE_SOURCE_DIR}/src/api/bsa_detail.h" - "${CMAKE_SOURCE_DIR}/src/api/metadata/condition_evaluator.h" - "${CMAKE_SOURCE_DIR}/src/api/metadata/yaml/file.h" - "${CMAKE_SOURCE_DIR}/src/api/metadata/yaml/group.h" - "${CMAKE_SOURCE_DIR}/src/api/metadata/yaml/location.h" - "${CMAKE_SOURCE_DIR}/src/api/metadata/yaml/message.h" - "${CMAKE_SOURCE_DIR}/src/api/metadata/yaml/message_content.h" - "${CMAKE_SOURCE_DIR}/src/api/metadata/yaml/plugin_cleaning_data.h" - "${CMAKE_SOURCE_DIR}/src/api/metadata/yaml/plugin_metadata.h" - "${CMAKE_SOURCE_DIR}/src/api/metadata/yaml/set.h" - "${CMAKE_SOURCE_DIR}/src/api/metadata/yaml/tag.h" - "${CMAKE_SOURCE_DIR}/src/api/game/game.h" - "${CMAKE_SOURCE_DIR}/src/api/game/game_cache.h" - "${CMAKE_SOURCE_DIR}/src/api/game/load_order_handler.h" - "${CMAKE_SOURCE_DIR}/src/api/metadata_list.h" - "${CMAKE_SOURCE_DIR}/src/api/plugin.h" - "${CMAKE_SOURCE_DIR}/src/api/sorting/group_sort.h" - "${CMAKE_SOURCE_DIR}/src/api/sorting/plugin_sort.h" - "${CMAKE_SOURCE_DIR}/src/api/sorting/plugin_graph.h" - "${CMAKE_SOURCE_DIR}/src/api/sorting/plugin_sorting_data.h" - "${CMAKE_SOURCE_DIR}/src/api/helpers/crc.h" - "${CMAKE_SOURCE_DIR}/src/api/helpers/logging.h" - "${CMAKE_SOURCE_DIR}/src/api/helpers/text.h") - -source_group(TREE "${CMAKE_SOURCE_DIR}/src/api" - PREFIX "Source Files" - FILES ${LIBLOOT_SRC_API_CPP_FILES}) - -source_group(TREE "${CMAKE_SOURCE_DIR}/include" - PREFIX "Header Files" - FILES ${LIBLOOT_INCLUDE_H_FILES}) - -source_group(TREE "${CMAKE_SOURCE_DIR}/src/api" - PREFIX "Header Files" - FILES ${LIBLOOT_SRC_API_H_FILES}) - -set(LIBLOOT_ALL_SOURCES - ${LIBLOOT_SRC_API_CPP_FILES} - ${LIBLOOT_INCLUDE_H_FILES} - ${LIBLOOT_SRC_API_H_FILES} - "${CMAKE_BINARY_DIR}/generated/loot_version.cpp" - "${CMAKE_SOURCE_DIR}/src/api/resource.rc") - -############################## -# Define Targets -############################## - -# Build API. -add_library(loot ${LIBLOOT_ALL_SOURCES}) -add_dependencies(loot - esplugin - libloadorder - loot-condition-interpreter) -target_link_libraries(loot PRIVATE - Boost::headers - ${ESPLUGIN_LIBRARIES} - ${LIBLOADORDER_LIBRARIES} - ${LCI_LIBRARIES} - fmt::fmt - spdlog::spdlog - yaml-cpp::yaml-cpp) - -############################## -# Set Target-Specific Flags -############################## - -set(LIBLOOT_INCLUDE_DIRS - "${CMAKE_SOURCE_DIR}/src" - "${CMAKE_SOURCE_DIR}/include") - -set(LIBLOOT_COMMON_SYSTEM_INCLUDE_DIRS - ${LIBLOADORDER_INCLUDE_DIRS} - ${ESPLUGIN_INCLUDE_DIRS} - ${LCI_INCLUDE_DIRS}) - -target_include_directories(loot PUBLIC - "$" - "$") -target_include_directories(loot PRIVATE ${LIBLOOT_INCLUDE_DIRS}) -target_include_directories(loot SYSTEM PRIVATE - ${LIBLOOT_COMMON_SYSTEM_INCLUDE_DIRS}) - -if(CMAKE_SYSTEM_NAME STREQUAL "Windows") - target_compile_definitions(loot PRIVATE - UNICODE _UNICODE LOOT_EXPORT YAML_CPP_STATIC_DEFINE) - - set(LOOT_LIBS ws2_32 bcrypt) - - if(NOT CMAKE_HOST_SYSTEM_NAME STREQUAL "Windows") - set(LOOT_LIBS ${LOOT_LIBS} tbb_static) - endif() - - target_link_libraries(loot PRIVATE ${LOOT_LIBS}) -else() - set(LOOT_LIBS ICU::data ICU::uc pthread TBB::tbb) - - target_link_libraries(loot PUBLIC ${LOOT_LIBS}) -endif() - -if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID STREQUAL "Clang") - target_compile_options(loot PRIVATE "-Wall" "-Wextra") -endif() - -if(MSVC) - target_compile_options(loot PRIVATE - "/Zc:__cplusplus" - "/permissive-" - "/W4" - "/GL") -endif() - -############################## -# Configure clang-tidy -############################## - -if(RUN_CLANG_TIDY) - set(CLANG_TIDY_COMMON_CHECKS - "cppcoreguidelines-avoid-c-arrays" - "cppcoreguidelines-c-copy-assignment-signature" - "cppcoreguidelines-explicit-virtual-functions" - "cppcoreguidelines-init-variables" - "cppcoreguidelines-interfaces-global-init" - "cppcoreguidelines-macro-usage" - "cppcoreguidelines-narrowing-conventions" - "cppcoreguidelines-no-malloc" - "cppcoreguidelines-pro-bounds-array-to-pointer-decay" - "cppcoreguidelines-pro-bounds-constant-array-index" - "cppcoreguidelines-pro-bounds-pointer-arithmetic" - "cppcoreguidelines-pro-type-const-cast" - "cppcoreguidelines-pro-type-cstyle-cast" - "cppcoreguidelines-pro-type-member-init" - "cppcoreguidelines-pro-type-reinterpret-cast" - "cppcoreguidelines-pro-type-static-cast-downcast" - "cppcoreguidelines-pro-type-union-access" - "cppcoreguidelines-pro-type-vararg" - "cppcoreguidelines-pro-type-slicing") - - set(CLANG_TIDY_LIB_CHECKS - ${CLANG_TIDY_COMMON_CHECKS} - "cppcoreguidelines-avoid-goto" - "cppcoreguidelines-avoid-magic-numbers" - "cppcoreguidelines-non-private-member-variables-in-classes" - "cppcoreguidelines-special-member-functions") - - list(JOIN CLANG_TIDY_LIB_CHECKS "," CLANG_TIDY_LIB_CHECKS_JOINED) - - set(CLANG_TIDY_LIB - clang-tidy "-header-filter=.*" "-checks=${CLANG_TIDY_LIB_CHECKS_JOINED}") - - set_target_properties(loot - PROPERTIES - CXX_CLANG_TIDY "${CLANG_TIDY_LIB}") -endif() - -############################## -# Tests -############################## - -if(LIBLOOT_BUILD_TESTS) - include("cmake/tests.cmake") -endif() - -######################################## -# Install -######################################## - -set(LIBLOOT_VERSION "0.27.0") - -set_property(TARGET loot PROPERTY VERSION ${LIBLOOT_VERSION}) -set_property(TARGET loot PROPERTY SOVERSION 0) -set_property(TARGET loot PROPERTY INTERFACE_libloot_MAJOR_VERSION 0) -set_property(TARGET loot APPEND PROPERTY COMPATIBLE_INTERFACE_STRING libloot_MAJOR_VERSION) - -configure_package_config_file( - ${CMAKE_CURRENT_SOURCE_DIR}/cmake/Config.cmake.in - "${CMAKE_CURRENT_BINARY_DIR}/liblootConfig.cmake" - INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/libloot -) - -write_basic_package_version_file( - "${CMAKE_CURRENT_BINARY_DIR}/liblootConfigVersion.cmake" - VERSION "${LIBLOOT_VERSION}" - COMPATIBILITY AnyNewerVersion -) - -install(TARGETS loot - EXPORT liblootTargets - LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} - ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} - RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} - INCLUDES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) - -if(MSVC) - install(FILES $ - DESTINATION ${CMAKE_INSTALL_LIBDIR} - OPTIONAL - CONFIGURATIONS RelWithDebInfo) -endif() - -install(DIRECTORY "${CMAKE_SOURCE_DIR}/include/" - DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) - -if(LIBLOOT_INSTALL_DOCS) - install(DIRECTORY "${CMAKE_BINARY_DIR}/docs/html/" - DESTINATION ${CMAKE_INSTALL_DOCDIR}) -endif() - -install(EXPORT liblootTargets - FILE liblootTargets.cmake - NAMESPACE libloot:: - DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/libloot) - -install(FILES - "${CMAKE_CURRENT_BINARY_DIR}/liblootConfig.cmake" - "${CMAKE_CURRENT_BINARY_DIR}/liblootConfigVersion.cmake" - DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/libloot) - -######################################## -# CPack -######################################## - -if(NOT DEFINED CPACK_PACKAGE_VERSION) - if(GIT_FOUND) - execute_process(COMMAND ${GIT_EXECUTABLE} describe --tags --long --always --abbrev=7 - WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} - OUTPUT_VARIABLE GIT_DESCRIBE_STRING - OUTPUT_STRIP_TRAILING_WHITESPACE) - else() - set(GIT_DESCRIBE_STRING "unknown-version") - endif() - - set(CPACK_PACKAGE_VERSION ${GIT_DESCRIBE_STRING}) -endif() - -if(CMAKE_SYSTEM_NAME STREQUAL "Windows") - set(CPACK_GENERATOR "7Z") -else() - set(CPACK_GENERATOR "TXZ") -endif() - -set(CPACK_PACKAGE_DIRECTORY "${CMAKE_BINARY_DIR}/package") - -include(CPack) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e0cb873c..eb2099df 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,20 +20,3 @@ The best way to get started is to comment on something in GitHub's commit log or Surprise pull requests aren't recommended because everything you touched may have been rewritten, making your changes a pain to integrate, or obsolete. There are only generally a few contributors at any one time though, so there's not much chance of painful conflicts requiring resolution, provided you're working off the correct branch. When you do make a pull request, please do so from a branch which doesn't have the same name as they branch you're requesting your changes to be merged into. It's a lot easier to keep track of what pull request branches do when they're named something like `you:specific-cool-feature` rather than `you:master`. - -## Code Style - -libloot's code style is based on the [Google C++ Style Guide](https://google.github.io/styleguide/cppguide.html). Formatting style is codified in the repository's `.clang-format` file, but is not enforced. - -### C++ Features - -* Static variables may contain non-POD types. -* Reference arguments don't need to be `const` (ie. they can be used for output variables). -* Exceptions can be used. -* Unsigned integer types can be used. -* There's no restriction on which Boost libraries can be used. -* Specialising `std::hash` is allowed. - -### Naming - -* Constant, enumerator and variable names should use `camelCase` or `underscore_separators`, but they should be consistent within the same scope. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..2865ec20 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1510 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anstyle" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" + +[[package]] +name = "arc-swap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" + +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cc" +version = "1.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525046617d8376e3db1deffb079e91cef90a89fc3ca5c185bbf8c9ecdd15cd5c" +dependencies = [ + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "clap" +version = "4.5.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8aa86934b44c19c50f87cc2790e19f54f7a67aedb64101c2e1a2e5ecfb73944" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.5.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2414dbb2dd0695280da6ea9261e327479e9d37b0630f6b53ba2a11c60c679fd9" +dependencies = [ + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_lex" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" + +[[package]] +name = "codespan-reporting" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.15", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "ctor" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "cxx" +version = "1.0.156" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa3a202fc4f3dd6d2ce5a2f87b04fb2becc00f5643ee9c4743ba10777efb314f" +dependencies = [ + "cc", + "cxxbridge-cmd", + "cxxbridge-flags", + "cxxbridge-macro", + "foldhash", + "link-cplusplus", +] + +[[package]] +name = "cxx-build" +version = "1.0.156" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "644bdf46f34f6325783f76a8ad8e737ab995a302d7868b5236a1ba55008883e0" +dependencies = [ + "cc", + "codespan-reporting", + "proc-macro2", + "quote", + "scratch", + "syn", +] + +[[package]] +name = "cxxbridge-cmd" +version = "1.0.156" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e8cefbebcb74ed0b4a08b76139e6c29d8884a0bb94d02c6f35de821a14a6e39" +dependencies = [ + "clap", + "codespan-reporting", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "cxxbridge-flags" +version = "1.0.156" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "604e3eff62e2f27289d618f621491a068330c3c9f8eb06555dabc292c123596e" + +[[package]] +name = "cxxbridge-macro" +version = "1.0.156" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "130c3a05501d9c15dedbf08f2ff9af60f8e78422e3dffac1f43e2d83c5b489a1" +dependencies = [ + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "dataview" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50eb3a329e19d78c3a3dfa4ec5a51ecb84fa3a20c06edad04be25356018218f9" +dependencies = [ + "derive_pod", +] + +[[package]] +name = "delegate" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9b6483c2bbed26f97861cf57651d4f2b731964a28cd2257f934a4b452480d21" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_pod" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2ea6706d74fca54e15f1d40b5cf7fe7f764aaec61352a9fcec58fe27e042fc8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "976dd42dc7e85965fe702eb8164f21f450704bdde31faefd6471dba214cb594e" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "esplugin" +version = "6.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c334f10e3dc2e21d45e208f5b6b232a6876ea6352130d90a42bdb05246f7fcc" +dependencies = [ + "encoding_rs", + "memchr", + "nom", + "unicase", +] + +[[package]] +name = "fancy-regex" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi 0.14.2+wasi-0.2.4", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.2", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indexmap" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" +dependencies = [ + "equivalent", + "hashbrown 0.15.2", +] + +[[package]] +name = "indoc" +version = "2.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" + +[[package]] +name = "keyvalues-parser" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e4c8354918309196302015ac9cae43362f1a13d0d5c5539a33b4c2fd2cd6d25" +dependencies = [ + "pest", + "pest_derive", + "thiserror 1.0.69", +] + +[[package]] +name = "libc" +version = "0.2.171" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6" + +[[package]] +name = "libloading" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34" +dependencies = [ + "cfg-if", + "windows-targets", +] + +[[package]] +name = "libloadorder" +version = "18.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc4de694d488b4ce2a775d59825ec30d206546ec87fe0866717e6db25c46a164" +dependencies = [ + "dirs", + "encoding_rs", + "esplugin", + "keyvalues-parser", + "rayon", + "regex", + "rust-ini", + "unicase", + "windows", +] + +[[package]] +name = "libloot" +version = "0.27.0" +dependencies = [ + "crc32fast", + "esplugin", + "fancy-regex", + "libloadorder", + "log", + "loot-condition-interpreter", + "parameterized-test", + "petgraph", + "rayon", + "rustc-hash", + "saphyr", + "tempfile", + "unicase", + "windows", +] + +[[package]] +name = "libloot-cpp" +version = "0.27.0" +dependencies = [ + "cxx", + "cxx-build", + "delegate", + "libloot", + "libloot-ffi-errors", +] + +[[package]] +name = "libloot-ffi-errors" +version = "0.27.0" +dependencies = [ + "libloot", +] + +[[package]] +name = "libloot-nodejs" +version = "0.27.0" +dependencies = [ + "libloot", + "libloot-ffi-errors", + "napi", + "napi-build", + "napi-derive", +] + +[[package]] +name = "libloot-python" +version = "0.27.0" +dependencies = [ + "libloot", + "libloot-ffi-errors", + "pyo3", + "pyo3-log", +] + +[[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] +name = "link-cplusplus" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6f6da007f968f9def0d65a05b187e2960183de70c160204ecfccf0ee330212" +dependencies = [ + "cc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe7db12097d22ec582439daf8618b8fdd1a7bef6270e9af3b1ebcd30893cf413" + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "loot-condition-interpreter" +version = "5.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec12dc709a84442632efdc064e3463612bf7ddcc39d804de021cf56e557be04" +dependencies = [ + "crc32fast", + "esplugin", + "nom", + "pelite", + "regex", + "unicase", +] + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "napi" +version = "2.16.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55740c4ae1d8696773c78fdafd5d0e5fe9bc9f1b071c7ba493ba5c413a9184f3" +dependencies = [ + "bitflags", + "ctor", + "napi-derive", + "napi-sys", + "once_cell", +] + +[[package]] +name = "napi-build" +version = "2.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e28acfa557c083f6e254a786e01ba253fc56f18ee000afcd4f79af735f73a6da" + +[[package]] +name = "napi-derive" +version = "2.16.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cbe2585d8ac223f7d34f13701434b9d5f4eb9c332cccce8dee57ea18ab8ab0c" +dependencies = [ + "cfg-if", + "convert_case", + "napi-derive-backend", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "napi-derive-backend" +version = "1.0.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1639aaa9eeb76e91c6ae66da8ce3e89e921cd3885e99ec85f4abacae72fc91bf" +dependencies = [ + "convert_case", + "once_cell", + "proc-macro2", + "quote", + "regex", + "semver", + "syn", +] + +[[package]] +name = "napi-sys" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "427802e8ec3a734331fec1035594a210ce1ff4dc5bc1950530920ab717964ea3" +dependencies = [ + "libloading", +] + +[[package]] +name = "no-std-compat" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c" + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-float" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2c1f9f56e534ac6a9b8a4600bdf0f530fb393b5f393e7b4d03489c3cf0c3f01" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + +[[package]] +name = "parameterized-test" +version = "0.27.0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pelite" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88dccf4bd32294364aeb7bd55d749604450e9db54605887551f21baea7617685" +dependencies = [ + "dataview", + "libc", + "no-std-compat", + "pelite-macros", + "winapi", +] + +[[package]] +name = "pelite-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a7cf3f8ecebb0f4895f4892a8be0a0dc81b498f9d56735cb769dc31bf00815b" + +[[package]] +name = "pest" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "198db74531d58c70a361c42201efde7e2591e976d518caf7662a47dc5720e7b6" +dependencies = [ + "memchr", + "thiserror 2.0.12", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d725d9cfd79e87dccc9341a2ef39d1b6f6353d68c4b33c177febbe1a402c97c5" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db7d01726be8ab66ab32f9df467ae8b1148906685bbe75c82d1e65d7f5b3f841" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f9f832470494906d1fca5329f8ab5791cc60beb230c74815dff541cbd2b5ca0" +dependencies = [ + "once_cell", + "pest", + "sha2", +] + +[[package]] +name = "petgraph" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a98c6720655620a521dcc722d0ad66cd8afd5d86e34a89ef691c50b7b24de06" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.2", + "indexmap", + "serde", +] + +[[package]] +name = "portable-atomic" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e" + +[[package]] +name = "proc-macro2" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pyo3" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17da310086b068fbdcefbba30aeb3721d5bb9af8db4987d6735b2183ca567229" +dependencies = [ + "cfg-if", + "indoc", + "libc", + "memoffset", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e27165889bd793000a098bb966adc4300c312497ea25cf7a690a9f0ac5aa5fc1" +dependencies = [ + "once_cell", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05280526e1dbf6b420062f3ef228b78c0c54ba94e157f5cb724a609d0f2faabc" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-log" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7079e412e909af5d6be7c04a7f29f6a2837a080410e1c529c9dee2c367383db4" +dependencies = [ + "arc-swap", + "log", + "pyo3", +] + +[[package]] +name = "pyo3-macros" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3ce5686aa4d3f63359a5100c62a127c9f15e8398e5fdeb5deef1fed5cd5f44" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4cf6faa0cbfb0ed08e89beb8103ae9724eb4750e3a78084ba4017cbe94f3855" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" + +[[package]] +name = "rayon" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_users" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" +dependencies = [ + "getrandom 0.2.15", + "libredox", + "thiserror 2.0.12", +] + +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "rust-ini" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e310ef0e1b6eeb79169a1171daf9abcb87a2e17c03bee2c4bb100b55c75409f" +dependencies = [ + "cfg-if", + "ordered-multimap", + "trim-in-place", + "unicase", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustix" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d97817398dd4bb2e6da002002db259209759911da105da92bec29ccb12cf58bf" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" + +[[package]] +name = "saphyr" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9235237b78e93229dfceefd307ca041d6e1752ac7eaaf392531c3778fa4810a1" +dependencies = [ + "arraydeque", + "encoding_rs", + "hashlink", + "ordered-float", + "saphyr-parser", +] + +[[package]] +name = "saphyr-parser" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f5c8d67f5937b6b8a42c0d6038d90571ca3d8a99cc4adae69e490ba0acc6bf1" +dependencies = [ + "arraydeque", + "hashlink", +] + +[[package]] +name = "scratch" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f6280af86e5f559536da57a45ebc84948833b3bee313a7dd25232e09c878a52" + +[[package]] +name = "semver" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "target-lexicon" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e502f78cdbb8ba4718f566c418c52bc729126ffd16baee5baa718cf25dd5a69a" + +[[package]] +name = "tempfile" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7437ac7763b9b123ccf33c338a5cc1bac6f69b45a136c19bdd8a65e3916435bf" +dependencies = [ + "fastrand", + "getrandom 0.3.2", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +dependencies = [ + "thiserror-impl 2.0.12", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "trim-in-place" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "343e926fc669bc8cde4fa3129ab681c63671bae288b1f1081ceee6d9d37904fc" + +[[package]] +name = "typenum" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[package]] +name = "unindent" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.61.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5ee8f3d025738cb02bad7868bbb5f8a6327501e870bf51f1b455b0a2454a419" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-link", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.61.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a1d6bbefcb7b60acd19828e1bc965da6fcf18a7e39490c5f8be71e54a19ba32" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fd11a4fd95df68efcfee5f44a294fe71b8bc6a91993e2791938abcc712252" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ba9642430ee452d5a7aa78d72907ebe8cfda358e8cb7918a2050581322f97" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..2d14270f --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "libloot" +version = "0.27.0" +edition = "2024" +license = "GPL-3.0-or-later" + +[dependencies] +crc32fast = "1.4.2" +esplugin = "6.1.3" +fancy-regex = "0.14.0" +libloadorder = "18.4.0" +log = { version = "0.4.26", features = ["std"] } +loot-condition-interpreter = "5.3.2" +petgraph = "0.8.1" +rayon = "1.10.0" +rustc-hash = "2.1.1" +saphyr = "0.0.4" +unicase = "2.8.1" + +[target.'cfg(windows)'.dependencies] +windows = { version = "0.61.1", features = ["Win32_Storage_FileSystem"] } + +[dev-dependencies] +parameterized-test = { path = "./parameterized-test" } +tempfile = "3.17.1" + +[workspace] +members = ["cpp", "ffi-errors", "nodejs", "parameterized-test", "python"] + +[profile.release] +debug = "limited" +lto = "thin" + +[profile.rel-with-deb-info] +inherits = "release" +opt-level = 2 +debug = "limited" diff --git a/README.md b/README.md index c3f5aedb..c9c76dd8 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,12 @@ LOOT also provides some load order error checking, including checks for requirem libloot provides access to LOOT's metadata and sorting functionality, and the LOOT application is built using it. +libloot's core is written in Rust, and C++, Python and Node.js wrappers can be found in the `cpp`, `python` and `nodejs` subdirectories respectively. + ## Downloads Releases are hosted on [GitHub](https://github.com/loot/libloot/releases). - Snapshot builds are available as artifacts from [GitHub Actions runs](https://github.com/loot/libloot/actions), though they are only kept for 90 days and can only be downloaded when logged into a GitHub account. To mitigate these restrictions, snapshot build artifacts include a GPG signature that can be verified using the public key hosted [here](https://loot.github.io/.well-known/openpgpkey/hu/mj86by43a9hz8y8rbddtx54n3bwuuucg), which means it's possible to re-upload the artifacts elsewhere and still prove their authenticity. The snapshot build artifacts are named like so: @@ -24,48 +25,59 @@ The snapshot build artifacts are named like so: libloot---g_-. ``` -## Building libloot +## Build -Refer to `.github/workflows/release.yml` for the build process. +Make sure you have [Rust](https://www.rust-lang.org/) installed. -### Linux +To build the library, set the `LIBLOOT_REVISION` env var and then run Cargo. -The build process assumes that you have already cloned the libloot repository, -that the current working directory is its root, and that the following -applications are already installed: +Using PowerShell: -- `cmake` -- `curl` -- `git` -- `pip3` (and therefore Python 3) -- `cargo` and the rest of the Rust toolchain (e.g. via - [rustup](https://rustup.rs/)) -- `wget` +```powershell +$env:LIBLOOT_REVISION = git rev-parse --short HEAD +cargo build --release +``` -The list above may be incomplete. +Using a POSIX shell: -### CMake Variables +```sh +export LIBLOOT_REVISION=$(git rev-parse --short HEAD) +cargo build --release +``` -libloot uses the following CMake variables to set build parameters: +`LIBLOOT_REVISION` is used to embed the commit hash into the build, if it's not defined then `unknown` will be used instead. -Parameter | Values | Default |Description -----------|--------|---------|----------- -`BUILD_SHARED_LIBS` | `ON`, `OFF` | `ON` | Whether or not to build a shared libloot binary. -`LIBLOOT_BUILD_TESTS` | `ON`, `OFF` | `ON` | Whether or not to build libloot's tests. -`LIBLOOT_INSTALL_DOCS` | `ON`, `OFF` | `ON` | Whether or not to install libloot's docs (which need to be built separately). -`RUN_CLANG_TIDY` | `ON`, `OFF` | `OFF` | Whether or not to run clang-tidy during build. Has no effect when using CMake's MSVC generator. -`ESPLUGIN_URL` | A URL | A GitHub release archive URL | The URL to get a source code archive from. This can be used to supply a local path if the archive has already been downloaded (e.g. for offline builds). -`LIBLOADORDER_URL` | A URL | A GitHub release archive URL | The URL to get a source code archive from. This can be used to supply a local path if the archive has already been downloaded (e.g. for offline builds). -`LOOT_CONDITION_INTERPRETER_URL` | A URL | A GitHub release archive URL | The URL to get a source code archive from. This can be used to supply a local path if the archive has already been downloaded (e.g. for offline builds). -`FETCHCONTENT_SOURCE_DIR_YAML-CPP` | A path | Unset | The path to an existing yaml-cpp source folder to build yaml-cpp from. Note that libloot relies on [a fork of yaml-cpp](https://github.com/loot/yaml-cpp) to support YAML merge keys in metadata files. If unset, CMake will download the source from GitHub when the libloot build is configured. +### Tests -You may also need to set `CMAKE_PREFIX_PATH` if CMake cannot find Boost. +Before running the tests, first extract the [testing-plugins](https://github.com/Ortham/testing-plugins) archive to this readme's directory (so that there's a `testing-plugins` directory there). -## Building The Documentation +To do that using `curl` and `tar` in a POSIX shell: -Install [Doxygen](https://www.doxygen.nl/), Python and [uv](https://docs.astral.sh/uv/getting-started/installation/) and make sure they're accessible from your `PATH`, then run: +```sh +curl -sSfL https://github.com/Ortham/testing-plugins/archive/refs/tags/1.6.2.tar.gz | tar -xz --strip=1 --one-top-level=testing-plugins +``` + +To do that in PowerShell: + +```powershell +Invoke-WebRequest https://github.com/Ortham/testing-plugins/archive/refs/tags/1.6.2.zip -OutFile testing-plugins-1.6.2.zip +Expand-Archive testing-plugins-1.6.2.zip . +Move-Item testing-plugins-1.6.2 testing-plugins +Remove-Item testing-plugins-1.6.2.zip +``` + +The tests can then be run using: ``` -cd docs -uv run -- sphinx-build -b html . ../build/docs/html +cargo test ``` + +### API documentation + +The Rust API's reference documentation can be built and viewed using: + +``` +cargo doc --open +``` + +The `docs` directory contains more general documentation. diff --git a/cpp/.cargo/msvc-debug-config.toml b/cpp/.cargo/msvc-debug-config.toml new file mode 100644 index 00000000..c2632a08 --- /dev/null +++ b/cpp/.cargo/msvc-debug-config.toml @@ -0,0 +1,6 @@ +# Config for building the CXX wrapper against the debug MSVC runtime, so that it +# can be used from Debug builds of C++ code. +# From +[env] +CFLAGS = "/MDd" +CXXFLAGS = "/MDd" diff --git a/.clang-format b/cpp/.clang-format similarity index 100% rename from .clang-format rename to cpp/.clang-format diff --git a/cpp/CMakeLists.txt b/cpp/CMakeLists.txt new file mode 100644 index 00000000..704e58ff --- /dev/null +++ b/cpp/CMakeLists.txt @@ -0,0 +1,315 @@ +cmake_minimum_required(VERSION 3.24) +if(POLICY CMP0135) + cmake_policy(SET CMP0135 NEW) +endif() +if(POLICY CMP0167) + cmake_policy(SET CMP0167 NEW) +endif() +project(libloot) +include(ExternalProject) +include(FetchContent) +include(CMakePackageConfigHelpers) +include(GNUInstallDirs) + +option(BUILD_SHARED_LIBS "Build a shared library" ON) +option(RUN_CLANG_TIDY "Whether or not to run clang-tidy during build. Has no effect when using CMake's MSVC generator." OFF) +option(LIBLOOT_BUILD_TESTS "Whether or not to build libloot's tests." ON) +option(LIBLOOT_INSTALL_DOCS "Whether or not to install libloot's docs (which need to be built separately)." ON) + +set(CMAKE_POSITION_INDEPENDENT_CODE ON) +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +############################## +# External Projects +############################## + +if(MSVC) + set(LIBLOOT_CPP_FILENAME "libloot_cpp.lib") +else() + set(LIBLOOT_CPP_FILENAME "liblibloot_cpp.a") +endif() + +if(DEFINED RUST_TARGET) + set(TARGET_PATH "${CMAKE_SOURCE_DIR}/../target/${RUST_TARGET}") +else() + set(TARGET_PATH "${CMAKE_SOURCE_DIR}/../target") +endif() + +add_custom_target(libloot-cpp-build + COMMAND cargo build + $<$>:--release> + $<$,$>:--config> + $<$,$>:${CMAKE_SOURCE_DIR}/.cargo/msvc-debug-config.toml> + $<$:--target> + $<$:${RUST_TARGET}> + BYPRODUCTS + "${TARGET_PATH}/$,debug,release>/${LIBLOOT_CPP_FILENAME}" + "${TARGET_PATH}/cxxbridge/libloot-cpp/src/lib.rs.cc" + "${TARGET_PATH}/cxxbridge/libloot-cpp/src/lib.rs.h" + "${TARGET_PATH}/cxxbridge/rust/cxx.h" +) + +add_library(libloot-cpp INTERFACE) +add_dependencies(libloot-cpp libloot-cpp-build) + +target_link_libraries(libloot-cpp INTERFACE + optimized "${TARGET_PATH}/release/${LIBLOOT_CPP_FILENAME}" + debug "${TARGET_PATH}/debug/${LIBLOOT_CPP_FILENAME}") +target_include_directories(libloot-cpp INTERFACE "${TARGET_PATH}/cxxbridge") + + +############################## +# General Settings +############################## + +set(LIBLOOT_SRC_API_CPP_FILES + "${CMAKE_SOURCE_DIR}/src/api/api.cpp" + "${CMAKE_SOURCE_DIR}/src/api/convert.cpp" + "${CMAKE_SOURCE_DIR}/src/api/database.cpp" + "${CMAKE_SOURCE_DIR}/src/api/exception/cyclic_interaction_error.cpp" + "${CMAKE_SOURCE_DIR}/src/api/exception/exception.cpp" + "${CMAKE_SOURCE_DIR}/src/api/exception/undefined_group_error.cpp" + "${CMAKE_SOURCE_DIR}/src/api/metadata/file.cpp" + "${CMAKE_SOURCE_DIR}/src/api/metadata/filename.cpp" + "${CMAKE_SOURCE_DIR}/src/api/metadata/group.cpp" + "${CMAKE_SOURCE_DIR}/src/api/metadata/location.cpp" + "${CMAKE_SOURCE_DIR}/src/api/metadata/message.cpp" + "${CMAKE_SOURCE_DIR}/src/api/metadata/message_content.cpp" + "${CMAKE_SOURCE_DIR}/src/api/metadata/plugin_cleaning_data.cpp" + "${CMAKE_SOURCE_DIR}/src/api/metadata/plugin_metadata.cpp" + "${CMAKE_SOURCE_DIR}/src/api/metadata/tag.cpp" + "${CMAKE_SOURCE_DIR}/src/api/game.cpp" + "${CMAKE_SOURCE_DIR}/src/api/plugin.cpp" + "${CMAKE_SOURCE_DIR}/src/api/vertex.cpp") + +set(LIBLOOT_INCLUDE_H_FILES + "${CMAKE_SOURCE_DIR}/include/loot/api.h" + "${CMAKE_SOURCE_DIR}/include/loot/api_decorator.h" + "${CMAKE_SOURCE_DIR}/include/loot/database_interface.h" + "${CMAKE_SOURCE_DIR}/include/loot/exception/cyclic_interaction_error.h" + "${CMAKE_SOURCE_DIR}/include/loot/exception/plugin_not_loaded_error.h" + "${CMAKE_SOURCE_DIR}/include/loot/exception/undefined_group_error.h" + "${CMAKE_SOURCE_DIR}/include/loot/enum/edge_type.h" + "${CMAKE_SOURCE_DIR}/include/loot/enum/game_type.h" + "${CMAKE_SOURCE_DIR}/include/loot/enum/log_level.h" + "${CMAKE_SOURCE_DIR}/include/loot/enum/message_type.h" + "${CMAKE_SOURCE_DIR}/include/loot/game_interface.h" + "${CMAKE_SOURCE_DIR}/include/loot/loot_version.h" + "${CMAKE_SOURCE_DIR}/include/loot/metadata/file.h" + "${CMAKE_SOURCE_DIR}/include/loot/metadata/filename.h" + "${CMAKE_SOURCE_DIR}/include/loot/metadata/group.h" + "${CMAKE_SOURCE_DIR}/include/loot/metadata/location.h" + "${CMAKE_SOURCE_DIR}/include/loot/metadata/message.h" + "${CMAKE_SOURCE_DIR}/include/loot/metadata/message_content.h" + "${CMAKE_SOURCE_DIR}/include/loot/metadata/plugin_cleaning_data.h" + "${CMAKE_SOURCE_DIR}/include/loot/metadata/plugin_metadata.h" + "${CMAKE_SOURCE_DIR}/include/loot/metadata/tag.h" + "${CMAKE_SOURCE_DIR}/include/loot/plugin_interface.h" + "${CMAKE_SOURCE_DIR}/include/loot/vertex.h") + +set(LIBLOOT_SRC_API_H_FILES + "${CMAKE_SOURCE_DIR}/src/api/convert.h" + "${CMAKE_SOURCE_DIR}/src/api/database.h" + "${CMAKE_SOURCE_DIR}/src/api/exception/exception.h" + "${CMAKE_SOURCE_DIR}/src/api/game.h" + "${CMAKE_SOURCE_DIR}/src/api/plugin.h") + +source_group(TREE "${CMAKE_SOURCE_DIR}/src/api" + PREFIX "Source Files" + FILES ${LIBLOOT_SRC_API_CPP_FILES}) + +source_group(TREE "${CMAKE_SOURCE_DIR}/include" + PREFIX "Header Files" + FILES ${LIBLOOT_INCLUDE_H_FILES}) + +source_group(TREE "${CMAKE_SOURCE_DIR}/src/api" + PREFIX "Header Files" + FILES ${LIBLOOT_SRC_API_H_FILES}) + +set(LIBLOOT_ALL_SOURCES + ${LIBLOOT_SRC_API_CPP_FILES} + ${LIBLOOT_INCLUDE_H_FILES} + ${LIBLOOT_SRC_API_H_FILES} + "${CMAKE_SOURCE_DIR}/src/api/resource.rc") + +############################## +# Define Targets +############################## + +# Build API. +add_library(loot ${LIBLOOT_ALL_SOURCES}) +target_link_libraries(loot PRIVATE libloot-cpp) + +############################## +# Set Target-Specific Flags +############################## + +set(LIBLOOT_INCLUDE_DIRS + "${CMAKE_SOURCE_DIR}/src" + "${CMAKE_SOURCE_DIR}/include") + +set(LIBLOOT_COMMON_SYSTEM_INCLUDE_DIRS + ${LIBLOADORDER_INCLUDE_DIRS} + ${ESPLUGIN_INCLUDE_DIRS} + ${LCI_INCLUDE_DIRS}) + +target_include_directories(loot PUBLIC + "$" + "$") +target_include_directories(loot PRIVATE ${LIBLOOT_INCLUDE_DIRS}) +target_include_directories(loot SYSTEM PRIVATE + ${LIBLOOT_COMMON_SYSTEM_INCLUDE_DIRS}) + +if(CMAKE_SYSTEM_NAME STREQUAL "Windows") + target_compile_definitions(loot PRIVATE UNICODE _UNICODE LOOT_EXPORT) + + set(LOOT_LIBS ntdll ws2_32 bcrypt) + + target_link_libraries(loot PRIVATE ${LOOT_LIBS}) +endif() + +if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID STREQUAL "Clang") + target_compile_options(loot PRIVATE "-Wall" "-Wextra") +endif() + +if(MSVC) + # Turn off permissive mode to be more standards-compliant and avoid compiler errors. + target_compile_options(loot PRIVATE "/permissive-" "/W4" "/Zc:__cplusplus" "/GL") + target_link_options(loot PRIVATE "/LTCG") +endif() + +############################## +# Configure clang-tidy +############################## + +if(RUN_CLANG_TIDY) + set(CLANG_TIDY_COMMON_CHECKS + "cppcoreguidelines-avoid-c-arrays" + "cppcoreguidelines-c-copy-assignment-signature" + "cppcoreguidelines-explicit-virtual-functions" + "cppcoreguidelines-init-variables" + "cppcoreguidelines-interfaces-global-init" + "cppcoreguidelines-macro-usage" + "cppcoreguidelines-narrowing-conventions" + "cppcoreguidelines-no-malloc" + "cppcoreguidelines-pro-bounds-array-to-pointer-decay" + "cppcoreguidelines-pro-bounds-constant-array-index" + "cppcoreguidelines-pro-bounds-pointer-arithmetic" + "cppcoreguidelines-pro-type-const-cast" + "cppcoreguidelines-pro-type-cstyle-cast" + "cppcoreguidelines-pro-type-member-init" + "cppcoreguidelines-pro-type-reinterpret-cast" + "cppcoreguidelines-pro-type-static-cast-downcast" + "cppcoreguidelines-pro-type-union-access" + "cppcoreguidelines-pro-type-vararg" + "cppcoreguidelines-pro-type-slicing") + + set(CLANG_TIDY_LIB_CHECKS + ${CLANG_TIDY_COMMON_CHECKS} + "cppcoreguidelines-avoid-goto" + "cppcoreguidelines-avoid-magic-numbers" + "cppcoreguidelines-non-private-member-variables-in-classes" + "cppcoreguidelines-special-member-functions") + + list(JOIN CLANG_TIDY_LIB_CHECKS "," CLANG_TIDY_LIB_CHECKS_JOINED) + + set(CLANG_TIDY_LIB + clang-tidy "-header-filter=.*" "-checks=${CLANG_TIDY_LIB_CHECKS_JOINED}") + + set_target_properties(loot + PROPERTIES + CXX_CLANG_TIDY "${CLANG_TIDY_LIB}") +endif() + +############################## +# Tests +############################## + +if(LIBLOOT_BUILD_TESTS) + include("cmake/tests.cmake") +endif() + +######################################## +# Install +######################################## + +set(LIBLOOT_VERSION "0.27.0") + +set_property(TARGET loot PROPERTY VERSION ${LIBLOOT_VERSION}) +set_property(TARGET loot PROPERTY SOVERSION 0) +set_property(TARGET loot PROPERTY INTERFACE_libloot_MAJOR_VERSION 0) +set_property(TARGET loot APPEND PROPERTY COMPATIBLE_INTERFACE_STRING libloot_MAJOR_VERSION) + +configure_package_config_file( + ${CMAKE_CURRENT_SOURCE_DIR}/cmake/Config.cmake.in + "${CMAKE_CURRENT_BINARY_DIR}/liblootConfig.cmake" + INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/libloot +) + +write_basic_package_version_file( + "${CMAKE_CURRENT_BINARY_DIR}/liblootConfigVersion.cmake" + VERSION "${LIBLOOT_VERSION}" + COMPATIBILITY AnyNewerVersion +) + +install(TARGETS loot + EXPORT liblootTargets + LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} + ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} + INCLUDES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) + +if(MSVC) + install(FILES $ + DESTINATION ${CMAKE_INSTALL_LIBDIR} + OPTIONAL + CONFIGURATIONS RelWithDebInfo) +endif() + +install(DIRECTORY "${CMAKE_SOURCE_DIR}/include/" + DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) + +if(LIBLOOT_INSTALL_DOCS) + install(DIRECTORY "${CMAKE_SOURCE_DIR}/../docs/build/html/" + DESTINATION ${CMAKE_INSTALL_DOCDIR}) +endif() + +install(EXPORT liblootTargets + FILE liblootTargets.cmake + NAMESPACE libloot:: + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/libloot) + +install(FILES + "${CMAKE_CURRENT_BINARY_DIR}/liblootConfig.cmake" + "${CMAKE_CURRENT_BINARY_DIR}/liblootConfigVersion.cmake" + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/libloot) + +######################################## +# CPack +######################################## + +if(NOT DEFINED CPACK_PACKAGE_VERSION) + find_package(Git) + + if(GIT_FOUND) + execute_process(COMMAND ${GIT_EXECUTABLE} describe --tags --long --always --abbrev=7 + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} + OUTPUT_VARIABLE GIT_DESCRIBE_STRING + OUTPUT_STRIP_TRAILING_WHITESPACE) + else() + set(GIT_DESCRIBE_STRING "unknown-version") + endif() + + set(CPACK_PACKAGE_VERSION ${GIT_DESCRIBE_STRING}) +endif() + +if(CMAKE_SYSTEM_NAME STREQUAL "Windows") + set(CPACK_GENERATOR "7Z") +else() + set(CPACK_GENERATOR "TXZ") +endif() + +set(CPACK_PACKAGE_DIRECTORY "${CMAKE_BINARY_DIR}/package") + +include(CPack) diff --git a/cpp/Cargo.toml b/cpp/Cargo.toml new file mode 100644 index 00000000..b7c037a2 --- /dev/null +++ b/cpp/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "libloot-cpp" +version = "0.27.0" +edition = "2024" +license = "GPL-3.0-or-later" + +[dependencies] +cxx = { version = "1.0", features = ["c++17"] } +delegate = "0.13.2" +libloot = { path = ".." } +libloot-ffi-errors = { path = "../ffi-errors" } + +[build-dependencies] +cxx-build = "1.0" + +[lib] +crate-type = ["staticlib"] diff --git a/docs/api/Doxyfile b/cpp/Doxyfile similarity index 100% rename from docs/api/Doxyfile rename to cpp/Doxyfile diff --git a/cpp/README.md b/cpp/README.md new file mode 100644 index 00000000..efe11083 --- /dev/null +++ b/cpp/README.md @@ -0,0 +1,79 @@ +# libloot-rs C++ wrapper + +This is a wrapper around libloot that provides a C++ interface that's ABI-compatible with libloot v0.27.0. + +The wrapper has two layers: + +- a static library built using Cargo, which provides a C++ interface +- a shared library built using CMake, which wraps that C++ interface to provide another that is ABI-compatible with C++ libloot. + +## Building + +libloot uses the following CMake variables to set build parameters: + +Parameter | Values | Default |Description +----------|--------|---------|----------- +`BUILD_SHARED_LIBS` | `ON`, `OFF` | `ON` | Whether or not to build a shared libloot binary. +`LIBLOOT_BUILD_TESTS` | `ON`, `OFF` | `ON` | Whether or not to build libloot's tests. +`LIBLOOT_INSTALL_DOCS` | `ON`, `OFF` | `ON` | Whether or not to install libloot's docs (which need to be built separately). +`RUN_CLANG_TIDY` | `ON`, `OFF` | `OFF` | Whether or not to run clang-tidy during build. Has no effect when using CMake's MSVC generator. + +### Windows + +To build a release build with debug info: + +``` +cmake -B build . +cmake --build build --parallel --config RelWithDebInfo +``` + +To build a debug build: + +``` +cmake -B build . +cmake --build build --parallel --config Debug +``` + +### Linux + +To build a release build with debug info: + +``` +cmake -B build . -DCMAKE_BUILD_TYPE=RelWithDebInfo +cmake --build build --parallel +``` + +to build a debug build: + +``` +cargo build +cmake -B build . -DCMAKE_BUILD_TYPE=Debug +cmake --build build --parallel +``` + +### Tests + +The build process also builds the test suite by default. To skip building the tests, pass `-DLIBLOOT_BUILD_TESTS=OFF` when first running CMake. + +If built, the tests can be run using: + +``` +ctest --test-dir build --output-on-failure --parallel -V +``` + +### Documentation + +Install [Doxygen](https://www.doxygen.nl/), Python and [uv](https://docs.astral.sh/uv/getting-started/installation/) and make sure they're accessible from your `PATH`, then run: + +``` +cd ../docs +uv run -- sphinx-build -b html . build/html +``` + +### Packaging + +To package the build: + +``` +cpack --config build/CPackConfig.cmake -C RelWithDebInfo +``` diff --git a/cpp/build.rs b/cpp/build.rs new file mode 100644 index 00000000..9e470942 --- /dev/null +++ b/cpp/build.rs @@ -0,0 +1,20 @@ +fn main() { + cxx_build::bridge("src/lib.rs") + .std("c++17") + .flag_if_supported("/Zc:__cplusplus") + .flag_if_supported("/permissive-") + .compile("libloot-cpp"); + + // From + if std::env::var("TARGET").is_ok_and(|s| s.contains("windows-msvc")) { + // MSVC compiler suite + if std::env::var("CFLAGS").is_ok_and(|s| s.contains("/MDd")) { + // debug runtime flag is set + + // Don't link the default CRT + println!("cargo::rustc-link-arg=/nodefaultlib:msvcrt"); + // Link the debug CRT instead + println!("cargo::rustc-link-arg=/defaultlib:msvcrtd"); + } + } +} diff --git a/cmake/Config.cmake.in b/cpp/cmake/Config.cmake.in similarity index 100% rename from cmake/Config.cmake.in rename to cpp/cmake/Config.cmake.in diff --git a/cmake/tests.cmake b/cpp/cmake/tests.cmake similarity index 75% rename from cmake/tests.cmake rename to cpp/cmake/tests.cmake index e05f3c93..f5d1ddc7 100644 --- a/cmake/tests.cmake +++ b/cpp/cmake/tests.cmake @@ -36,28 +36,7 @@ set(BUILD_SHARED_LIBS ${BUILD_SHARED_LIBS_INITIAL}) set(LIBLOOT_SRC_TESTS_INTERNALS_CPP_FILES "${CMAKE_SOURCE_DIR}/src/tests/api/internals/main.cpp") -set(LIBLOOT_SRC_TESTS_INTERNALS_H_FILES - "${CMAKE_SOURCE_DIR}/src/tests/api/internals/bsa_test.h" - "${CMAKE_SOURCE_DIR}/src/tests/api/internals/game/game_test.h" - "${CMAKE_SOURCE_DIR}/src/tests/api/internals/game/game_cache_test.h" - "${CMAKE_SOURCE_DIR}/src/tests/api/internals/game/load_order_handler_test.h" - "${CMAKE_SOURCE_DIR}/src/tests/api/internals/helpers/crc_test.h" - "${CMAKE_SOURCE_DIR}/src/tests/api/internals/helpers/text_test.h" - "${CMAKE_SOURCE_DIR}/src/tests/api/internals/metadata/condition_evaluator_test.h" - "${CMAKE_SOURCE_DIR}/src/tests/api/internals/metadata/yaml/file_test.h" - "${CMAKE_SOURCE_DIR}/src/tests/api/internals/metadata/yaml/group_test.h" - "${CMAKE_SOURCE_DIR}/src/tests/api/internals/metadata/yaml/location_test.h" - "${CMAKE_SOURCE_DIR}/src/tests/api/internals/metadata/yaml/message_test.h" - "${CMAKE_SOURCE_DIR}/src/tests/api/internals/metadata/yaml/message_content_test.h" - "${CMAKE_SOURCE_DIR}/src/tests/api/internals/metadata/yaml/plugin_cleaning_data_test.h" - "${CMAKE_SOURCE_DIR}/src/tests/api/internals/metadata/yaml/plugin_metadata_test.h" - "${CMAKE_SOURCE_DIR}/src/tests/api/internals/metadata/yaml/tag_test.h" - "${CMAKE_SOURCE_DIR}/src/tests/api/internals/plugin_test.h" - "${CMAKE_SOURCE_DIR}/src/tests/api/internals/sorting/group_sort_test.h" - "${CMAKE_SOURCE_DIR}/src/tests/api/internals/sorting/plugin_sort_test.h" - "${CMAKE_SOURCE_DIR}/src/tests/api/internals/sorting/plugin_graph_test.h" - "${CMAKE_SOURCE_DIR}/src/tests/api/internals/sorting/plugin_sorting_data_test.h" - "${CMAKE_SOURCE_DIR}/src/tests/api/internals/metadata_list_test.h") +# set(LIBLOOT_SRC_TESTS_INTERNALS_H_FILES) set(LIBLOOT_SRC_TESTS_INTERFACE_CPP_FILES "${CMAKE_SOURCE_DIR}/src/tests/api/interface/main.cpp") @@ -75,16 +54,15 @@ set(LIBLOOT_SRC_TESTS_INTERFACE_H_FILES "${CMAKE_SOURCE_DIR}/src/tests/api/interface/metadata/message_content_test.h" "${CMAKE_SOURCE_DIR}/src/tests/api/interface/metadata/plugin_cleaning_data_test.h" "${CMAKE_SOURCE_DIR}/src/tests/api/interface/metadata/plugin_metadata_test.h" - "${CMAKE_SOURCE_DIR}/src/tests/api/interface/metadata/tag_test.h" - "${CMAKE_SOURCE_DIR}/src/tests/api/interface/plugin_interface_test.h") + "${CMAKE_SOURCE_DIR}/src/tests/api/interface/metadata/tag_test.h") source_group(TREE "${CMAKE_SOURCE_DIR}/src/tests/api/internals" PREFIX "Source Files" FILES ${LIBLOOT_SRC_TESTS_INTERNALS_CPP_FILES}) -source_group(TREE "${CMAKE_SOURCE_DIR}/src/tests/api/internals" - PREFIX "Header Files" - FILES ${LIBLOOT_SRC_TESTS_INTERNALS_H_FILES}) +# source_group(TREE "${CMAKE_SOURCE_DIR}/src/tests/api/internals" +# PREFIX "Header Files" +# FILES ${LIBLOOT_SRC_TESTS_INTERNALS_H_FILES}) source_group(TREE "${CMAKE_SOURCE_DIR}/src/tests/api/interface" PREFIX "Source Files" @@ -98,7 +76,7 @@ source_group(TREE "${CMAKE_SOURCE_DIR}/src/tests/api/interface" set(LIBLOOT_INTERNALS_TESTS_ALL_SOURCES ${LIBLOOT_ALL_SOURCES} ${LIBLOOT_SRC_TESTS_INTERNALS_CPP_FILES} - ${LIBLOOT_SRC_TESTS_INTERNALS_H_FILES} + # ${LIBLOOT_SRC_TESTS_INTERNALS_H_FILES} "${CMAKE_SOURCE_DIR}/src/tests/common_game_test_fixture.h" "${CMAKE_SOURCE_DIR}/src/tests/test_helpers.h" "${CMAKE_SOURCE_DIR}/src/tests/printers.h") @@ -117,24 +95,14 @@ set(LIBLOOT_INTERFACE_TESTS_ALL_SOURCES # Build tests. add_executable(libloot_internals_tests ${LIBLOOT_INTERNALS_TESTS_ALL_SOURCES}) -add_dependencies(libloot_internals_tests - esplugin - libloadorder - loot-condition-interpreter) target_link_libraries(libloot_internals_tests PRIVATE - Boost::headers - ${ESPLUGIN_LIBRARIES} - ${LIBLOADORDER_LIBRARIES} - ${LCI_LIBRARIES} - fmt::fmt - spdlog::spdlog - yaml-cpp::yaml-cpp + libloot-cpp GTest::gtest_main) # Build API tests. add_executable(libloot_tests ${LIBLOOT_INTERFACE_TESTS_ALL_SOURCES}) add_dependencies(libloot_tests loot) -target_link_libraries(libloot_tests PRIVATE loot Boost::headers GTest::gtest_main) +target_link_libraries(libloot_tests PRIVATE loot GTest::gtest_main) enable_testing() gtest_discover_tests(libloot_internals_tests DISCOVERY_TIMEOUT 10) @@ -156,16 +124,16 @@ target_include_directories(libloot_tests SYSTEM PRIVATE if(CMAKE_SYSTEM_NAME STREQUAL "Windows") target_compile_definitions(libloot_internals_tests PRIVATE - UNICODE _UNICODE LOOT_STATIC YAML_CPP_STATIC_DEFINE) + UNICODE _UNICODE LOOT_STATIC) target_compile_definitions(libloot_tests PRIVATE UNICODE _UNICODE) if(NOT CMAKE_HOST_SYSTEM_NAME STREQUAL "Windows") target_compile_definitions(libloot_tests PRIVATE LOOT_STATIC) endif() -endif() -target_link_libraries(libloot_internals_tests PRIVATE ${LOOT_LIBS}) -target_link_libraries(libloot_tests PRIVATE ${LOOT_LIBS}) + target_link_libraries(libloot_internals_tests PRIVATE ${LOOT_LIBS}) + target_link_libraries(libloot_tests PRIVATE ${LOOT_LIBS}) +endif() if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID STREQUAL "Clang") target_compile_options(libloot_internals_tests PRIVATE "-Wall" "-Wextra") diff --git a/include/loot/api.h b/cpp/include/loot/api.h similarity index 100% rename from include/loot/api.h rename to cpp/include/loot/api.h diff --git a/include/loot/api_decorator.h b/cpp/include/loot/api_decorator.h similarity index 100% rename from include/loot/api_decorator.h rename to cpp/include/loot/api_decorator.h diff --git a/include/loot/database_interface.h b/cpp/include/loot/database_interface.h similarity index 100% rename from include/loot/database_interface.h rename to cpp/include/loot/database_interface.h diff --git a/include/loot/enum/edge_type.h b/cpp/include/loot/enum/edge_type.h similarity index 100% rename from include/loot/enum/edge_type.h rename to cpp/include/loot/enum/edge_type.h diff --git a/include/loot/enum/game_type.h b/cpp/include/loot/enum/game_type.h similarity index 100% rename from include/loot/enum/game_type.h rename to cpp/include/loot/enum/game_type.h diff --git a/include/loot/enum/log_level.h b/cpp/include/loot/enum/log_level.h similarity index 100% rename from include/loot/enum/log_level.h rename to cpp/include/loot/enum/log_level.h diff --git a/include/loot/enum/message_type.h b/cpp/include/loot/enum/message_type.h similarity index 100% rename from include/loot/enum/message_type.h rename to cpp/include/loot/enum/message_type.h diff --git a/include/loot/exception/cyclic_interaction_error.h b/cpp/include/loot/exception/cyclic_interaction_error.h similarity index 100% rename from include/loot/exception/cyclic_interaction_error.h rename to cpp/include/loot/exception/cyclic_interaction_error.h diff --git a/include/loot/exception/plugin_not_loaded_error.h b/cpp/include/loot/exception/plugin_not_loaded_error.h similarity index 100% rename from include/loot/exception/plugin_not_loaded_error.h rename to cpp/include/loot/exception/plugin_not_loaded_error.h diff --git a/include/loot/exception/undefined_group_error.h b/cpp/include/loot/exception/undefined_group_error.h similarity index 100% rename from include/loot/exception/undefined_group_error.h rename to cpp/include/loot/exception/undefined_group_error.h diff --git a/include/loot/game_interface.h b/cpp/include/loot/game_interface.h similarity index 100% rename from include/loot/game_interface.h rename to cpp/include/loot/game_interface.h diff --git a/include/loot/loot_version.h b/cpp/include/loot/loot_version.h similarity index 100% rename from include/loot/loot_version.h rename to cpp/include/loot/loot_version.h diff --git a/include/loot/metadata/file.h b/cpp/include/loot/metadata/file.h similarity index 100% rename from include/loot/metadata/file.h rename to cpp/include/loot/metadata/file.h diff --git a/include/loot/metadata/filename.h b/cpp/include/loot/metadata/filename.h similarity index 95% rename from include/loot/metadata/filename.h rename to cpp/include/loot/metadata/filename.h index a027f9f6..fb5d2b47 100644 --- a/include/loot/metadata/filename.h +++ b/cpp/include/loot/metadata/filename.h @@ -52,6 +52,10 @@ public: private: std::string filename_; + + LOOT_API friend bool operator==(const Filename& lhs, const Filename& rhs); + + LOOT_API friend bool operator<(const Filename& lhs, const Filename& rhs); }; /** diff --git a/include/loot/metadata/group.h b/cpp/include/loot/metadata/group.h similarity index 100% rename from include/loot/metadata/group.h rename to cpp/include/loot/metadata/group.h diff --git a/include/loot/metadata/location.h b/cpp/include/loot/metadata/location.h similarity index 100% rename from include/loot/metadata/location.h rename to cpp/include/loot/metadata/location.h diff --git a/include/loot/metadata/message.h b/cpp/include/loot/metadata/message.h similarity index 100% rename from include/loot/metadata/message.h rename to cpp/include/loot/metadata/message.h diff --git a/include/loot/metadata/message_content.h b/cpp/include/loot/metadata/message_content.h similarity index 100% rename from include/loot/metadata/message_content.h rename to cpp/include/loot/metadata/message_content.h diff --git a/include/loot/metadata/plugin_cleaning_data.h b/cpp/include/loot/metadata/plugin_cleaning_data.h similarity index 100% rename from include/loot/metadata/plugin_cleaning_data.h rename to cpp/include/loot/metadata/plugin_cleaning_data.h diff --git a/include/loot/metadata/plugin_metadata.h b/cpp/include/loot/metadata/plugin_metadata.h similarity index 100% rename from include/loot/metadata/plugin_metadata.h rename to cpp/include/loot/metadata/plugin_metadata.h diff --git a/include/loot/metadata/tag.h b/cpp/include/loot/metadata/tag.h similarity index 100% rename from include/loot/metadata/tag.h rename to cpp/include/loot/metadata/tag.h diff --git a/include/loot/plugin_interface.h b/cpp/include/loot/plugin_interface.h similarity index 100% rename from include/loot/plugin_interface.h rename to cpp/include/loot/plugin_interface.h diff --git a/include/loot/vertex.h b/cpp/include/loot/vertex.h similarity index 100% rename from include/loot/vertex.h rename to cpp/include/loot/vertex.h diff --git a/scripts/OpenCppCoverage.bat b/cpp/scripts/OpenCppCoverage.bat similarity index 100% rename from scripts/OpenCppCoverage.bat rename to cpp/scripts/OpenCppCoverage.bat diff --git a/cpp/src/api/api.cpp b/cpp/src/api/api.cpp new file mode 100644 index 00000000..d1cd4923 --- /dev/null +++ b/cpp/src/api/api.cpp @@ -0,0 +1,107 @@ + + +#include "loot/api.h" + +#include "api/game.h" +#include "libloot-cpp/src/lib.rs.h" +#include "rust/cxx.h" + +extern "C" { +extern const unsigned int LIBLOOT_VERSION_MAJOR; + +extern const unsigned int LIBLOOT_VERSION_MINOR; + +extern const unsigned int LIBLOOT_VERSION_PATCH; + +extern const uint8_t LIBLOOT_LOG_LEVEL_TRACE; + +extern const uint8_t LIBLOOT_LOG_LEVEL_DEBUG; + +extern const uint8_t LIBLOOT_LOG_LEVEL_INFO; + +extern const uint8_t LIBLOOT_LOG_LEVEL_WARNING; + +extern const uint8_t LIBLOOT_LOG_LEVEL_ERROR; + +void libloot_set_logging_callback(void (*callback)(uint8_t, const char*, void*), + void* context); +} + +namespace { +using loot::LogLevel; + +typedef std::function Callback; + +static Callback STORED_CALLBACK; + +LogLevel convert(uint8_t level) { + if (level == LIBLOOT_LOG_LEVEL_TRACE) { + return LogLevel::trace; + } else if (level == LIBLOOT_LOG_LEVEL_DEBUG) { + return LogLevel::debug; + } else if (level == LIBLOOT_LOG_LEVEL_INFO) { + return LogLevel::info; + } else if (level == LIBLOOT_LOG_LEVEL_WARNING) { + return LogLevel::warning; + } else if (level == LIBLOOT_LOG_LEVEL_ERROR) { + return LogLevel::error; + } else { + return LogLevel::error; + } +} + +loot::rust::LogLevel convert(LogLevel level) { + switch (level) { + case LogLevel::trace: + return loot::rust::LogLevel::Trace; + case LogLevel::debug: + return loot::rust::LogLevel::Debug; + case LogLevel::info: + return loot::rust::LogLevel::Info; + case LogLevel::warning: + return loot::rust::LogLevel::Warning; + case LogLevel::error: + return loot::rust::LogLevel::Error; + default: + return loot::rust::LogLevel::Trace; + } +} + +void logging_callback(uint8_t level, const char* message, void* context) { + auto& callback = *static_cast(context); + + callback(convert(level), message); +} +} + +namespace loot { +LOOT_API void SetLoggingCallback(Callback callback) { + STORED_CALLBACK = callback; + libloot_set_logging_callback(logging_callback, &STORED_CALLBACK); +} + +LOOT_API void SetLogLevel(LogLevel level) { + loot::rust::set_log_level(convert(level)); +} + +LOOT_API bool IsCompatible(const unsigned int versionMajor, + const unsigned int versionMinor, + const unsigned int versionPatch) { + return loot::rust::is_compatible(versionMajor, versionMinor, versionPatch); +} + +LOOT_API std::unique_ptr CreateGameHandle( + const GameType game, + const std::filesystem::path& gamePath, + const std::filesystem::path& gameLocalPath) { + return std::make_unique(game, gamePath, gameLocalPath); +} + +LOOT_API std::string GetLiblootVersion() { + return std::string(loot::rust::libloot_version()); +} + +LOOT_API std::string GetLiblootRevision() { + return std::string(loot::rust::libloot_revision()); +} +} diff --git a/cpp/src/api/convert.cpp b/cpp/src/api/convert.cpp new file mode 100644 index 00000000..1d40da1b --- /dev/null +++ b/cpp/src/api/convert.cpp @@ -0,0 +1,273 @@ +#include "api/convert.h" + +#include "api/exception/exception.h" + +namespace { +std::optional convert(loot::rust::EdgeType edgeType) { + switch (edgeType) { + case loot::rust::EdgeType::Hardcoded: + return loot::EdgeType::hardcoded; + case loot::rust::EdgeType::MasterFlag: + return loot::EdgeType::masterFlag; + case loot::rust::EdgeType::Master: + return loot::EdgeType::master; + case loot::rust::EdgeType::MasterlistRequirement: + return loot::EdgeType::masterlistRequirement; + case loot::rust::EdgeType::UserRequirement: + return loot::EdgeType::userRequirement; + case loot::rust::EdgeType::MasterlistLoadAfter: + return loot::EdgeType::masterlistLoadAfter; + case loot::rust::EdgeType::UserLoadAfter: + return loot::EdgeType::userLoadAfter; + case loot::rust::EdgeType::MasterlistGroup: + return loot::EdgeType::masterlistGroup; + case loot::rust::EdgeType::UserGroup: + return loot::EdgeType::userGroup; + case loot::rust::EdgeType::RecordOverlap: + return loot::EdgeType::recordOverlap; + case loot::rust::EdgeType::AssetOverlap: + return loot::EdgeType::assetOverlap; + case loot::rust::EdgeType::TieBreak: + return loot::EdgeType::tieBreak; + case loot::rust::EdgeType::BlueprintMaster: + return loot::EdgeType::blueprintMaster; + default: + return std::nullopt; + } +} +} + +namespace loot { +// To public types +///////////////////// + +std::string convert(const ::rust::String& string) { + return std::string(string); +} + +// Although there's an explicit conversion operator declared, it seems that +// building the CXX wrapper with MSVC doesn't set __cplusplus correctly as using +// the operator causes a linker error, so this just reimpls it as a function. +std::string_view convert(::rust::Str str) { + return std::string_view(str.data(), str.length()); +} + +loot::Group convert(const loot::rust::Group& group) { + return loot::Group(convert(group.name()), + convert(group.after_groups()), + convert(group.description())); +} + +loot::File convert(const loot::rust::File& file) { + return loot::File(convert(file.filename().as_str()), + convert(file.display_name()), + convert(file.condition()), + convert(file.detail()), + convert(file.constraint())); +} + +loot::MessageType convert(loot::rust::MessageType messageType) { + switch (messageType) { + case loot::rust::MessageType::Say: + return loot::MessageType::say; + case loot::rust::MessageType::Warn: + return loot::MessageType::warn; + case loot::rust::MessageType::Error: + return loot::MessageType::error; + default: + throw std::logic_error("Unsupported MessageType value"); + } +} + +loot::MessageContent convert(const loot::rust::MessageContent& content) { + return loot::MessageContent(convert(content.text()), + convert(content.language())); +} + +loot::Message convert(const loot::rust::Message& message) { + return loot::Message(convert(message.message_type()), + convert(message.content()), + convert(message.condition())); +} + +loot::Tag convert(const loot::rust::Tag& tag) { + return loot::Tag( + convert(tag.name()), tag.is_addition(), convert(tag.condition())); +} + +loot::PluginCleaningData convert(const loot::rust::PluginCleaningData& data) { + return loot::PluginCleaningData(data.crc(), + convert(data.cleaning_utility()), + convert(data.detail()), + data.itm_count(), + data.deleted_reference_count(), + data.deleted_navmesh_count()); +} + +loot::Location convert(const loot::rust::Location& location) { + return loot::Location(convert(location.url()), convert(location.name())); +} + +loot::PluginMetadata convert(const loot::rust::PluginMetadata& metadata) { + auto output = loot::PluginMetadata(convert(metadata.name())); + + if (!metadata.group().empty()) { + output.SetGroup(convert(metadata.group())); + } + + output.SetLoadAfterFiles(convert(metadata.load_after_files())); + output.SetRequirements(convert(metadata.requirements())); + output.SetIncompatibilities( + convert(metadata.incompatibilities())); + output.SetMessages(convert(metadata.messages())); + output.SetTags(convert(metadata.tags())); + output.SetDirtyInfo(convert(metadata.dirty_info())); + output.SetCleanInfo(convert(metadata.clean_info())); + output.SetLocations(convert(metadata.locations())); + + return output; +} + +loot::Vertex convert(const loot::rust::Vertex& vertex) { + try { + const auto outEdgeType = ::convert(vertex.out_edge_type()); + if (outEdgeType.has_value()) { + return loot::Vertex(convert(vertex.name()), outEdgeType.value()); + } else { + return loot::Vertex(convert(vertex.name())); + } + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } +} + +// From public types +/////////////////////// + +::rust::Str convert(std::string_view view) { + return ::rust::Str(view.data(), view.length()); +} + +::rust::Box convert(const loot::Group& group) { + return loot::rust::new_group( + group.GetName(), group.GetDescription(), convert(group.GetAfterGroups())); +} + +::rust::Box convert(const loot::File& file) { + try { + return loot::rust::new_file( + std::string(file.GetName()), + file.GetDisplayName(), + file.GetCondition(), + ::rust::Slice(convert(file.GetDetail())), + file.GetConstraint()); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } +} + +loot::rust::MessageType convert(loot::MessageType messageType) { + switch (messageType) { + case loot::MessageType::say: + return loot::rust::MessageType::Say; + case loot::MessageType::warn: + return loot::rust::MessageType::Warn; + case loot::MessageType::error: + return loot::rust::MessageType::Error; + default: + throw std::logic_error("Unsupported MessageType value"); + } +} + +::rust::Box convert( + const loot::MessageContent& content) { + return loot::rust::new_message_content(content.GetText(), + content.GetLanguage()); +} + +::rust::Box convert(const loot::Message& message) { + try { + return loot::rust::multilingual_message( + convert(message.GetType()), + ::rust::Slice( + convert(message.GetContent())), + message.GetCondition()); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } +} + +::rust::Box convert(const loot::Tag& tag) { + try { + const auto suggestion = tag.IsAddition() + ? loot::rust::TagSuggestion::Addition + : loot::rust::TagSuggestion::Removal; + return loot::rust::new_tag(tag.GetName(), suggestion, tag.GetCondition()); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } +} + +::rust::Box convert( + const loot::PluginCleaningData& data) { + try { + return loot::rust::new_plugin_cleaning_data( + data.GetCRC(), + data.GetCleaningUtility(), + ::rust::Slice(convert(data.GetDetail())), + data.GetITMCount(), + data.GetDeletedReferenceCount(), + data.GetDeletedNavmeshCount()); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } +} + +::rust::Box convert(const loot::Location& location) { + return loot::rust::new_location(location.GetURL(), location.GetName()); +} + +::rust::Box convert( + const loot::PluginMetadata& metadata) { + try { + auto output = loot::rust::new_plugin_metadata(metadata.GetName()); + + if (metadata.GetGroup().has_value()) { + output->set_group(metadata.GetGroup().value()); + } + + output->set_load_after_files( + ::rust::Slice(convert(metadata.GetLoadAfterFiles()))); + output->set_requirements( + ::rust::Slice(convert(metadata.GetRequirements()))); + output->set_incompatibilities(::rust::Slice( + convert(metadata.GetIncompatibilities()))); + output->set_messages( + ::rust::Slice(convert(metadata.GetMessages()))); + output->set_tags( + ::rust::Slice(convert(metadata.GetTags()))); + output->set_dirty_info(::rust::Slice( + convert(metadata.GetDirtyInfo()))); + output->set_clean_info(::rust::Slice( + convert(metadata.GetCleanInfo()))); + output->set_locations( + ::rust::Slice(convert(metadata.GetLocations()))); + + return output; + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } +} + +// Between containers +//////////////////////// + +::rust::Vec<::rust::String> convert(const std::vector& vector) { + ::rust::Vec<::rust::String> strings; + for (const auto& str : vector) { + strings.push_back(str); + } + + return strings; +} +} diff --git a/cpp/src/api/convert.h b/cpp/src/api/convert.h new file mode 100644 index 00000000..99cfae5c --- /dev/null +++ b/cpp/src/api/convert.h @@ -0,0 +1,92 @@ +#ifndef LOOT_API_CONVERT +#define LOOT_API_CONVERT + +#include "libloot-cpp/src/lib.rs.h" +#include "loot/metadata/group.h" +#include "loot/metadata/plugin_metadata.h" +#include "loot/vertex.h" + +namespace loot { +// To public types +///////////////////// + +std::string convert(const ::rust::String& string); + +std::string_view convert(::rust::Str string); + +loot::Group convert(const loot::rust::Group& group); + +loot::File convert(const loot::rust::File& file); + +loot::MessageType convert(loot::rust::MessageType messageType); + +loot::MessageContent convert(const loot::rust::MessageContent& content); + +loot::Message convert(const loot::rust::Message& message); + +loot::Tag convert(const loot::rust::Tag& tag); + +loot::PluginCleaningData convert(const loot::rust::PluginCleaningData& data); + +loot::Location convert(const loot::rust::Location& location); + +loot::PluginMetadata convert(const loot::rust::PluginMetadata& metadata); + +std::optional convert(uint8_t edgeType); + +loot::Vertex convert(const loot::rust::Vertex& vertex); + +// From public types +/////////////////////// + +::rust::Str convert(std::string_view view); + +::rust::Box convert(const loot::Group& group); + +::rust::Box convert(const loot::File& file); + +loot::rust::MessageType convert(loot::MessageType messageType); + +::rust::Box convert( + const loot::MessageContent& content); + +::rust::Box convert(const loot::Message& message); + +::rust::Box convert(const loot::Tag& tag); + +::rust::Box convert( + const loot::PluginCleaningData& data); + +::rust::Box convert(const loot::Location& location); + +::rust::Box convert( + const loot::PluginMetadata& metadata); + +// Between containers +//////////////////////// + +::rust::Vec<::rust::String> convert(const std::vector& vector); + +template +std::vector convert(const ::rust::Slice& slice) { + std::vector output; + for (const auto& element : slice) { + output.push_back(convert(element)); + } + + return output; +} + +template +std::vector convert(const ::rust::Vec& vec) { + return convert(::rust::Slice(vec)); +} + +template +const std::vector<::rust::Box> convert(const std::vector& vec) { + return convert<::rust::Box, U>(::rust::Slice(vec)); +} + +} + +#endif diff --git a/cpp/src/api/database.cpp b/cpp/src/api/database.cpp new file mode 100644 index 00000000..d0a174b8 --- /dev/null +++ b/cpp/src/api/database.cpp @@ -0,0 +1,173 @@ + +#include "api/database.h" + +#include "api/convert.h" +#include "api/exception/exception.h" + +namespace loot { +Database::Database(::rust::Box&& database) : + database_(std::move(database)) {} + +void Database::LoadMasterlist(const std::filesystem::path& masterlistPath) { + try { + database_->load_masterlist(masterlistPath.u8string()); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } +} + +void Database::LoadMasterlistWithPrelude( + const std::filesystem::path& masterlistPath, + const std::filesystem::path& masterlistPreludePath) { + try { + database_->load_masterlist_with_prelude(masterlistPath.u8string(), + masterlistPreludePath.u8string()); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } +} + +void Database::LoadUserlist(const std::filesystem::path& userlistPath) { + try { + database_->load_userlist(userlistPath.u8string()); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } +} + +void Database::WriteUserMetadata(const std::filesystem::path& outputFile, + const bool overwrite) const { + try { + database_->write_user_metadata(outputFile.u8string(), overwrite); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } +} + +bool Database::Evaluate(const std::string& condition) const { + try { + return database_->evaluate(condition); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } +} + +std::vector Database::GetKnownBashTags() const { + try { + return convert(database_->known_bash_tags()); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } +} + +std::vector Database::GetGeneralMessages( + bool evaluateConditions) const { + try { + return convert(database_->general_messages(evaluateConditions)); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } +} + +std::vector Database::GetGroups(bool includeUserMetadata) const { + try { + return convert(database_->groups(includeUserMetadata)); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } +} + +std::vector Database::GetUserGroups() const { + try { + return convert(database_->user_groups()); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } +} + +void Database::SetUserGroups(const std::vector& groups) { + try { + database_->set_user_groups( + ::rust::Slice(convert(groups))); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } +} + +std::vector Database::GetGroupsPath( + std::string_view fromGroupName, + std::string_view toGroupName) const { + try { + return convert( + database_->groups_path(convert(fromGroupName), convert(toGroupName))); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } +} + +std::optional Database::GetPluginMetadata( + std::string_view plugin, + bool includeUserMetadata, + bool evaluateConditions) const { + try { + const auto metadata = database_->plugin_metadata( + convert(plugin), includeUserMetadata, evaluateConditions); + if (metadata->is_some()) { + return convert(metadata->as_ref()); + } else { + return std::nullopt; + } + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } +} + +std::optional Database::GetPluginUserMetadata( + std::string_view plugin, + bool evaluateConditions) const { + try { + const auto metadata = + database_->plugin_user_metadata(convert(plugin), evaluateConditions); + if (metadata->is_some()) { + return convert(metadata->as_ref()); + } else { + return std::nullopt; + } + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } +} + +void Database::SetPluginUserMetadata(const PluginMetadata& pluginMetadata) { + try { + database_->set_plugin_user_metadata(convert(pluginMetadata)); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } +} + +void Database::DiscardPluginUserMetadata(std::string_view plugin) { + try { + database_->discard_plugin_user_metadata(convert(plugin)); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } +} + +void Database::DiscardAllUserMetadata() { + try { + database_->discard_all_user_metadata(); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } +} + +void Database::WriteMinimalList(const std::filesystem::path& outputFile, + const bool overwrite) const { + try { + database_->write_minimal_list(outputFile.u8string(), overwrite); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } +} +} diff --git a/cpp/src/api/database.h b/cpp/src/api/database.h new file mode 100644 index 00000000..34a370b0 --- /dev/null +++ b/cpp/src/api/database.h @@ -0,0 +1,61 @@ +#ifndef LOOT_API_DATABASE +#define LOOT_API_DATABASE + +#include "libloot-cpp/src/lib.rs.h" +#include "loot/database_interface.h" +#include "rust/cxx.h" + +namespace loot { +class Database final : public DatabaseInterface { +public: + explicit Database(::rust::Box&& database); + + void LoadMasterlist(const std::filesystem::path& masterlist_path) override; + + void LoadMasterlistWithPrelude( + const std::filesystem::path& masterlist_path, + const std::filesystem::path& masterlist_prelude_path) override; + + void LoadUserlist(const std::filesystem::path& userlist_path) override; + + void WriteUserMetadata(const std::filesystem::path& outputFile, + const bool overwrite) const override; + + void WriteMinimalList(const std::filesystem::path& outputFile, + const bool overwrite) const override; + + bool Evaluate(const std::string& condition) const override; + + std::vector GetKnownBashTags() const override; + + std::vector GetGeneralMessages( + bool evaluateConditions = false) const override; + + std::vector GetGroups(bool includeUserMetadata = true) const override; + std::vector GetUserGroups() const override; + void SetUserGroups(const std::vector& groups) override; + std::vector GetGroupsPath( + std::string_view fromGroupName, + std::string_view toGroupName) const override; + + std::optional GetPluginMetadata( + std::string_view plugin, + bool includeUserMetadata = true, + bool evaluateConditions = false) const override; + + std::optional GetPluginUserMetadata( + std::string_view plugin, + bool evaluateConditions = false) const override; + + void SetPluginUserMetadata(const PluginMetadata& pluginMetadata) override; + + void DiscardPluginUserMetadata(std::string_view plugin) override; + + void DiscardAllUserMetadata() override; + +private: + ::rust::Box database_; +}; +} + +#endif diff --git a/src/api/exception/cyclic_interaction_error.cpp b/cpp/src/api/exception/cyclic_interaction_error.cpp similarity index 63% rename from src/api/exception/cyclic_interaction_error.cpp rename to cpp/src/api/exception/cyclic_interaction_error.cpp index 952b2ad1..d62f5332 100644 --- a/src/api/exception/cyclic_interaction_error.cpp +++ b/cpp/src/api/exception/cyclic_interaction_error.cpp @@ -23,7 +23,40 @@ */ #include "loot/exception/cyclic_interaction_error.h" -#include "api/sorting/plugin_graph.h" +namespace { +using loot::EdgeType; + +std::string describeEdgeType(EdgeType edgeType) { + switch (edgeType) { + case EdgeType::hardcoded: + return "Hardcoded"; + case EdgeType::masterFlag: + return "Master Flag"; + case EdgeType::master: + return "Master"; + case EdgeType::masterlistRequirement: + return "Masterlist Requirement"; + case EdgeType::userRequirement: + return "User Requirement"; + case EdgeType::masterlistLoadAfter: + return "Masterlist Load After"; + case EdgeType::userLoadAfter: + return "User Load After"; + case EdgeType::masterlistGroup: + return "Masterlist Group"; + case EdgeType::userGroup: + return "User Group"; + case EdgeType::recordOverlap: + return "Record Overlap"; + case EdgeType::assetOverlap: + return "Asset Overlap"; + case EdgeType::tieBreak: + return "Tie Break"; + default: + return "Unknown"; + } +} +} namespace loot { // A.esp --[Master Flag]-> B.esp --Group-> diff --git a/cpp/src/api/exception/exception.cpp b/cpp/src/api/exception/exception.cpp new file mode 100644 index 00000000..e932f90c --- /dev/null +++ b/cpp/src/api/exception/exception.cpp @@ -0,0 +1,137 @@ +#include "api/exception/exception.h" + +#include + +#include "loot/exception/cyclic_interaction_error.h" +#include "loot/exception/plugin_not_loaded_error.h" +#include "loot/exception/undefined_group_error.h" +#include "loot/vertex.h" + +namespace { +using std::string_view_literals::operator""sv; +using loot::EdgeType; +using loot::Vertex; + +constexpr std::string_view CYCLIC_ERROR_PREFIX = "CyclicInteractionError: "sv; +constexpr std::string_view UNDEFINED_GROUP_ERROR_PREFIX = + "UndefinedGroupError: "sv; +constexpr std::string_view PLUGIN_NOT_LOADED_ERROR_PREFIX = + "PluginNotLoadedError: "sv; +constexpr std::string_view INVALID_ARGUMENT_PREFIX = "InvalidArgument: "sv; + +bool startsWith(std::string_view str, std::string_view prefix) { + if (str.size() < prefix.size()) { + return false; + } + + return str.substr(0, prefix.size()) == prefix; +} + +std::string replace(std::string_view str, + std::string_view from, + std::string_view to) { + std::string out; + out.reserve(str.size()); + + size_t i = 0; + while (i < str.size()) { + if (i + from.size() <= str.size() && str.substr(i, from.size()) == from) { + out.append(to); + i += from.size(); + } else { + out.push_back(str[i]); + i += 1; + } + } + + return out; +} + +EdgeType toEdgeType(std::string_view edgeTypeDisplay) { + if (edgeTypeDisplay == "Hardcoded") { + return EdgeType::hardcoded; + } else if (edgeTypeDisplay == "Master Flag") { + return EdgeType::masterFlag; + } else if (edgeTypeDisplay == "Master") { + return EdgeType::master; + } else if (edgeTypeDisplay == "Masterlist Requirement") { + return EdgeType::masterlistRequirement; + } else if (edgeTypeDisplay == "User Requirement") { + return EdgeType::userRequirement; + } else if (edgeTypeDisplay == "Masterlist Load After") { + return EdgeType::masterlistLoadAfter; + } else if (edgeTypeDisplay == "User Load After") { + return EdgeType::userLoadAfter; + } else if (edgeTypeDisplay == "Masterlist Group") { + return EdgeType::masterlistGroup; + } else if (edgeTypeDisplay == "User Group") { + return EdgeType::userGroup; + } else if (edgeTypeDisplay == "Record Overlap") { + return EdgeType::recordOverlap; + } else if (edgeTypeDisplay == "Asset Overlap") { + return EdgeType::assetOverlap; + } else if (edgeTypeDisplay == "Tie Break") { + return EdgeType::tieBreak; + } else if (edgeTypeDisplay == "Blueprint Master") { + return EdgeType::blueprintMaster; + } else { + std::string what("Unrecognised edge type: "); + what += edgeTypeDisplay; + throw std::logic_error(what); + } +} + +std::vector parseCyclicError(std::string_view what) { + const auto suffix = what.substr(0, CYCLIC_ERROR_PREFIX.size()); + + std::vector vertices; + size_t pos = 0; + while (pos < suffix.size()) { + const auto sepPos = suffix.find("--", pos); + const auto escapedName = suffix.substr(pos, sepPos); + const auto name = replace(replace(escapedName, "\\-", "-"), "\\\\", "\\"); + + if (sepPos != std::string::npos) { + const auto secondSepPos = suffix.find("--", sepPos + 2); + const auto escapedEdgeName = + suffix.substr(sepPos + 2, secondSepPos - (sepPos + 2)); + + vertices.push_back(Vertex(name, toEdgeType(escapedEdgeName))); + + pos = secondSepPos + 2; + } else { + vertices.push_back(Vertex(name)); + pos = suffix.size(); + } + } + + return vertices; +} + +std::string getErrorSuffix(std::string_view what) { + const auto sepPos = what.find(": "); + + return std::string(what.substr(sepPos + 2)); +} +} + +namespace loot { +std::exception_ptr mapError(const ::rust::Error& error) { + if (startsWith(error.what(), CYCLIC_ERROR_PREFIX)) { + return std::make_exception_ptr( + CyclicInteractionError(parseCyclicError(error.what()))); + } else if (startsWith(error.what(), UNDEFINED_GROUP_ERROR_PREFIX)) { + return std::make_exception_ptr( + UndefinedGroupError(getErrorSuffix(error.what()))); + } else if (startsWith(error.what(), PLUGIN_NOT_LOADED_ERROR_PREFIX)) { + return std::make_exception_ptr( + PluginNotLoadedError("The plugin \"" + getErrorSuffix(error.what()) + + "\" has not been loaded")); + } else if (startsWith(error.what(), INVALID_ARGUMENT_PREFIX)) { + return std::make_exception_ptr( + std::invalid_argument(getErrorSuffix(error.what()))); + } else { + return std::make_exception_ptr(std::runtime_error(error.what())); + } +} +} diff --git a/cpp/src/api/exception/exception.h b/cpp/src/api/exception/exception.h new file mode 100644 index 00000000..2f3fa90e --- /dev/null +++ b/cpp/src/api/exception/exception.h @@ -0,0 +1,10 @@ +#ifndef LOOT_API_EXCEPTION +#define LOOT_API_EXCEPTION + +#include "rust/cxx.h" + +namespace loot { +std::exception_ptr mapError(const ::rust::Error& error); +} + +#endif diff --git a/src/api/exception/undefined_group_error.cpp b/cpp/src/api/exception/undefined_group_error.cpp similarity index 88% rename from src/api/exception/undefined_group_error.cpp rename to cpp/src/api/exception/undefined_group_error.cpp index 4b6ce90d..c62aadac 100644 --- a/src/api/exception/undefined_group_error.cpp +++ b/cpp/src/api/exception/undefined_group_error.cpp @@ -25,8 +25,9 @@ namespace loot { UndefinedGroupError::UndefinedGroupError(std::string_view groupName) : - std::runtime_error("The group \"" + std::string(groupName) + "\" does not exist"), - groupName_(groupName) {} + std::runtime_error("The group \"" + std::string(groupName) + + "\" does not exist"), + groupName_(groupName) {} std::string UndefinedGroupError::GetGroupName() const { return groupName_; } } diff --git a/cpp/src/api/game.cpp b/cpp/src/api/game.cpp new file mode 100644 index 00000000..fafac74d --- /dev/null +++ b/cpp/src/api/game.cpp @@ -0,0 +1,254 @@ + +#include "api/game.h" + +#include "api/convert.h" +#include "api/exception/exception.h" + +namespace { +loot::GameType convert(loot::rust::GameType gameType) { + switch (gameType) { + case loot::rust::GameType::Morrowind: + return loot::GameType::tes3; + case loot::rust::GameType::Oblivion: + return loot::GameType::tes4; + case loot::rust::GameType::Skyrim: + return loot::GameType::tes5; + case loot::rust::GameType::SkyrimSE: + return loot::GameType::tes5se; + case loot::rust::GameType::SkyrimVR: + return loot::GameType::tes5vr; + case loot::rust::GameType::Fallout3: + return loot::GameType::fo3; + case loot::rust::GameType::FalloutNV: + return loot::GameType::fonv; + case loot::rust::GameType::Fallout4: + return loot::GameType::fo4; + case loot::rust::GameType::Fallout4VR: + return loot::GameType::fo4vr; + case loot::rust::GameType::Starfield: + return loot::GameType::starfield; + case loot::rust::GameType::OpenMW: + return loot::GameType::openmw; + case loot::rust::GameType::OblivionRemastered: + return loot::GameType::oblivionRemastered; + default: + throw std::logic_error("Unsupported GameType value"); + } +} + +loot::rust::GameType convert(loot::GameType gameType) { + switch (gameType) { + case loot::GameType::tes3: + return loot::rust::GameType::Morrowind; + case loot::GameType::tes4: + return loot::rust::GameType::Oblivion; + case loot::GameType::tes5: + return loot::rust::GameType::Skyrim; + case loot::GameType::tes5se: + return loot::rust::GameType::SkyrimSE; + case loot::GameType::tes5vr: + return loot::rust::GameType::SkyrimVR; + case loot::GameType::fo3: + return loot::rust::GameType::Fallout3; + case loot::GameType::fonv: + return loot::rust::GameType::FalloutNV; + case loot::GameType::fo4: + return loot::rust::GameType::Fallout4; + case loot::GameType::fo4vr: + return loot::rust::GameType::Fallout4VR; + case loot::GameType::starfield: + return loot::rust::GameType::Starfield; + case loot::GameType::openmw: + return loot::rust::GameType::OpenMW; + case loot::GameType::oblivionRemastered: + return loot::rust::GameType::OblivionRemastered; + default: + throw std::logic_error("Unsupported GameType value"); + } +} + +std::filesystem::path to_path(const rust::String& string) { + return std::filesystem::u8path(string.begin(), string.end()); +} + +rust::Box constructGame( + const loot::GameType gameType, + const std::filesystem::path& gamePath, + const std::filesystem::path& localDataPath) { + try { + if (localDataPath.empty()) { + return loot::rust::new_game(convert(gameType), gamePath.u8string()); + } else { + return loot::rust::new_game_with_local_path( + convert(gameType), gamePath.u8string(), localDataPath.u8string()); + } + } catch (const ::rust::Error& e) { + std::rethrow_exception(loot::mapError(e)); + } +} + +std::vector<::rust::Str> as_str_refs(const std::vector& vector) { + std::vector<::rust::Str> strings; + for (const auto& str : vector) { + strings.push_back(str); + } + + return strings; +} +} + +namespace loot { +Game::Game(const GameType gameType, + const std::filesystem::path& gamePath, + const std::filesystem::path& localDataPath) : + game_(constructGame(gameType, gamePath, localDataPath)), + database_(game_->database()) {} + +GameType Game::GetType() const { + try { + return ::convert(game_->game_type()); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } +} + +const DatabaseInterface& Game::GetDatabase() const { return database_; } + +DatabaseInterface& Game::GetDatabase() { return database_; } + +std::vector Game::GetAdditionalDataPaths() const { + try { + std::vector paths; + for (const auto& path_string : game_->additional_data_paths()) { + paths.push_back(to_path(path_string)); + } + + return paths; + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } +} + +void Game::SetAdditionalDataPaths( + const std::vector& additionalDataPaths) { + std::vector<::rust::String> path_strings; + std::vector<::rust::Str> path_strs; + for (const auto& path : additionalDataPaths) { + path_strings.push_back(path.u8string()); + path_strs.push_back(path_strings.back()); + } + try { + game_->set_additional_data_paths( + ::rust::Slice(path_strs)); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } +} + +bool Game::IsValidPlugin(const std::filesystem::path& pluginPath) const { + return game_->is_valid_plugin(pluginPath.u8string()); +} + +void Game::LoadPlugins(const std::vector& pluginPaths, + bool loadHeadersOnly) { + std::vector<::rust::String> path_strings; + std::vector<::rust::Str> path_strs; + for (const auto& path : pluginPaths) { + path_strings.push_back(path.u8string()); + path_strs.push_back(path_strings.back()); + } + + try { + if (loadHeadersOnly) { + game_->load_plugin_headers(::rust::Slice(path_strs)); + } else { + game_->load_plugins(::rust::Slice(path_strs)); + } + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } +} + +void Game::ClearLoadedPlugins() { game_->clear_loaded_plugins(); } + +std::shared_ptr Game::GetPlugin( + std::string_view pluginName) const { + const auto pluginOpt = game_->plugin(convert(pluginName)); + if (!pluginOpt->is_some()) { + return nullptr; + } + + try { + return std::make_shared( + std::move(pluginOpt->as_ref().boxed_clone())); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } +} + +std::vector> Game::GetLoadedPlugins() + const { + std::vector> plugins; + for (const auto& pluginRef : game_->loaded_plugins()) { + plugins.push_back( + std::make_shared(std::move(pluginRef.boxed_clone()))); + } + + return plugins; +} + +std::vector Game::SortPlugins( + const std::vector& pluginFilenames) { + const auto strs = as_str_refs(pluginFilenames); + + try { + const auto results = game_->sort_plugins(::rust::Slice(strs)); + + return convert(results); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } +} + +void Game::LoadCurrentLoadOrderState() { + try { + game_->load_current_load_order_state(); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } +} + +bool Game::IsLoadOrderAmbiguous() const { + try { + return game_->is_load_order_ambiguous(); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } +} + +std::filesystem::path Game::GetActivePluginsFilePath() const { + try { + return to_path(game_->active_plugins_file_path()); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } +} + +bool Game::IsPluginActive(const std::string& pluginName) const { + return game_->is_plugin_active(pluginName); +} + +std::vector Game::GetLoadOrder() const { + return convert(game_->load_order()); +} + +void Game::SetLoadOrder(const std::vector& loadOrder) { + const auto strs = as_str_refs(loadOrder); + + try { + game_->set_load_order(::rust::Slice(strs)); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } +} +} diff --git a/cpp/src/api/game.h b/cpp/src/api/game.h new file mode 100644 index 00000000..7422282b --- /dev/null +++ b/cpp/src/api/game.h @@ -0,0 +1,67 @@ +#ifndef LOOT_API_GAME +#define LOOT_API_GAME + +#include + +#include "api/database.h" +#include "api/plugin.h" +#include "libloot-cpp/src/lib.rs.h" +#include "loot/game_interface.h" +#include "loot/metadata/filename.h" +#include "rust/cxx.h" + +namespace loot { +class Game final : public GameInterface { +public: + explicit Game(const GameType gameType, + const std::filesystem::path& gamePath, + const std::filesystem::path& gameLocalDataPath = ""); + + // Game Interface Methods // + //////////////////////////// + + GameType GetType() const override; + + std::vector GetAdditionalDataPaths() const override; + + void SetAdditionalDataPaths( + const std::vector& additionalDataPaths) override; + + DatabaseInterface& GetDatabase() override; + const DatabaseInterface& GetDatabase() const override; + + bool IsValidPlugin(const std::filesystem::path& pluginPath) const override; + + void LoadPlugins(const std::vector& pluginPaths, + bool loadHeadersOnly) override; + + void ClearLoadedPlugins() override; + + std::shared_ptr GetPlugin( + std::string_view pluginName) const override; + + std::vector> GetLoadedPlugins() + const override; + + std::vector SortPlugins( + const std::vector& pluginFilenames) override; + + void LoadCurrentLoadOrderState() override; + + bool IsLoadOrderAmbiguous() const override; + + std::filesystem::path GetActivePluginsFilePath() const override; + + bool IsPluginActive(const std::string& pluginName) const override; + + std::vector GetLoadOrder() const override; + + void SetLoadOrder(const std::vector& loadOrder) override; + +private: + ::rust::Box game_; + Database database_; +}; +} + +#endif diff --git a/src/api/metadata/file.cpp b/cpp/src/api/metadata/file.cpp similarity index 100% rename from src/api/metadata/file.cpp rename to cpp/src/api/metadata/file.cpp diff --git a/src/api/metadata/filename.cpp b/cpp/src/api/metadata/filename.cpp similarity index 84% rename from src/api/metadata/filename.cpp rename to cpp/src/api/metadata/filename.cpp index 04c2be6e..0552c7f1 100644 --- a/src/api/metadata/filename.cpp +++ b/cpp/src/api/metadata/filename.cpp @@ -24,7 +24,9 @@ #include "loot/metadata/filename.h" -#include "api/helpers/text.h" +#include + +#include "libloot-cpp/src/lib.rs.h" namespace loot { Filename::Filename(std::string_view filename) : filename_(filename) {} @@ -32,7 +34,8 @@ Filename::Filename(std::string_view filename) : filename_(filename) {} Filename::operator std::string() const { return filename_; } bool operator==(const Filename& lhs, const Filename& rhs) { - return CompareFilenames(std::string(lhs), std::string(rhs)) == 0; + return loot::rust::new_filename(lhs.filename_) + ->eq(*loot::rust::new_filename(rhs.filename_)); } bool operator!=(const Filename& lhs, const Filename& rhs) { @@ -40,7 +43,8 @@ bool operator!=(const Filename& lhs, const Filename& rhs) { } bool operator<(const Filename& lhs, const Filename& rhs) { - return CompareFilenames(std::string(lhs), std::string(rhs)) < 0; + return loot::rust::new_filename(lhs.filename_) + ->lt(*loot::rust::new_filename(rhs.filename_)); } bool operator>(const Filename& lhs, const Filename& rhs) { return rhs < lhs; } diff --git a/src/api/metadata/group.cpp b/cpp/src/api/metadata/group.cpp similarity index 100% rename from src/api/metadata/group.cpp rename to cpp/src/api/metadata/group.cpp diff --git a/src/api/metadata/location.cpp b/cpp/src/api/metadata/location.cpp similarity index 100% rename from src/api/metadata/location.cpp rename to cpp/src/api/metadata/location.cpp diff --git a/src/api/metadata/message.cpp b/cpp/src/api/metadata/message.cpp similarity index 100% rename from src/api/metadata/message.cpp rename to cpp/src/api/metadata/message.cpp diff --git a/src/api/metadata/message_content.cpp b/cpp/src/api/metadata/message_content.cpp similarity index 100% rename from src/api/metadata/message_content.cpp rename to cpp/src/api/metadata/message_content.cpp diff --git a/src/api/metadata/plugin_cleaning_data.cpp b/cpp/src/api/metadata/plugin_cleaning_data.cpp similarity index 100% rename from src/api/metadata/plugin_cleaning_data.cpp rename to cpp/src/api/metadata/plugin_cleaning_data.cpp diff --git a/src/api/metadata/plugin_metadata.cpp b/cpp/src/api/metadata/plugin_metadata.cpp similarity index 78% rename from src/api/metadata/plugin_metadata.cpp rename to cpp/src/api/metadata/plugin_metadata.cpp index 9694f755..9604e951 100644 --- a/src/api/metadata/plugin_metadata.cpp +++ b/cpp/src/api/metadata/plugin_metadata.cpp @@ -24,10 +24,13 @@ #include "loot/metadata/plugin_metadata.h" +#include #include +#include -#include "api/helpers/text.h" -#include "api/metadata/yaml/plugin_metadata.h" +#include "api/convert.h" +#include "api/exception/exception.h" +#include "libloot-cpp/src/lib.rs.h" namespace { // Append second to first, skipping any elements that are already present in @@ -47,16 +50,38 @@ std::vector mergeVectors(std::vector first, return first; } + +std::string TrimDotGhostExtension(std::string&& filename) { + using std::string_view_literals::operator""sv; + // If the name passed ends in '.ghost', that should be trimmed. + constexpr std::string_view GHOST_FILE_EXTENSION = ".ghost"sv; + + if (filename.length() < GHOST_FILE_EXTENSION.length()) { + return filename; + } + + auto view = std::string_view(filename); + view.remove_prefix(filename.length() - GHOST_FILE_EXTENSION.length()); + + bool areEqual = std::equal( + view.begin(), + view.end(), + GHOST_FILE_EXTENSION.begin(), + [](unsigned char a, unsigned char b) { return std::tolower(a) == b; }); + + if (areEqual) { + return filename.substr(0, + filename.length() - GHOST_FILE_EXTENSION.length()); + } + + return filename; +} } namespace loot { // If the name passed ends in '.ghost', that should be trimmed. PluginMetadata::PluginMetadata(std::string_view n) : - name_(TrimDotGhostExtension(std::string(n))) { - if (IsRegexPlugin()) { - nameRegex_ = std::regex(name_, std::regex::ECMAScript | std::regex::icase); - } -} + name_(TrimDotGhostExtension(std::string(n))) {} void PluginMetadata::MergeMetadata(const PluginMetadata& plugin) { if (plugin.HasNameOnly()) @@ -165,23 +190,17 @@ bool PluginMetadata::IsRegexPlugin() const { } bool PluginMetadata::NameMatches(std::string_view pluginName) const { - if (IsRegexPlugin()) { - if (!nameRegex_.has_value()) { - throw std::runtime_error("Regex plugin does not have regex object"); - } + try { + const auto metadata = loot::rust::new_plugin_metadata(name_); - return std::regex_match( - pluginName.begin(), pluginName.end(), nameRegex_.value()); + return metadata->name_matches(convert(pluginName)); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); } - - return CompareFilenames(name_, pluginName) == 0; } -LOOT_API std::string PluginMetadata::AsYaml() const { - YAML::Emitter emitter; - emitter.SetIndent(2); - emitter << *this; - - return std::string(emitter.c_str()); +std::string PluginMetadata::AsYaml() const { + const auto metadata = convert(*this); + return std::string(metadata->as_yaml()); } } diff --git a/src/api/metadata/tag.cpp b/cpp/src/api/metadata/tag.cpp similarity index 100% rename from src/api/metadata/tag.cpp rename to cpp/src/api/metadata/tag.cpp diff --git a/cpp/src/api/plugin.cpp b/cpp/src/api/plugin.cpp new file mode 100644 index 00000000..135c703d --- /dev/null +++ b/cpp/src/api/plugin.cpp @@ -0,0 +1,109 @@ +#include "api/plugin.h" + +#include +#include + +#include "api/convert.h" +#include "api/exception/exception.h" + +namespace loot { +Plugin::Plugin(::rust::Box plugin) : + plugin_(std::move(plugin)) {} + +std::string Plugin::GetName() const { return std::string(plugin_->name()); } + +std::optional Plugin::GetHeaderVersion() const { + const auto value = plugin_->header_version(); + if (std::isnan(value)) { + return std::nullopt; + } else { + return value; + } +} + +std::optional Plugin::GetVersion() const { + const auto value = plugin_->version(); + if (value.empty()) { + return std::nullopt; + } else { + return std::string(value); + } +} + +std::vector Plugin::GetMasters() const { + try { + return convert(plugin_->masters()); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } +} + +std::vector Plugin::GetBashTags() const { + return convert(plugin_->bash_tags()); +} + +std::optional Plugin::GetCRC() const { + try { + auto optional = plugin_->crc(); + if (optional->is_some()) { + return optional->as_ref(); + } + + return std::nullopt; + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } +} + +bool Plugin::IsMaster() const { return plugin_->is_master(); } + +bool Plugin::IsLightPlugin() const { return plugin_->is_light_plugin(); } + +bool Plugin::IsMediumPlugin() const { return plugin_->is_medium_plugin(); } + +bool Plugin::IsUpdatePlugin() const { return plugin_->is_update_plugin(); } + +bool Plugin::IsBlueprintPlugin() const { + return plugin_->is_blueprint_plugin(); +} + +bool Plugin::IsValidAsLightPlugin() const { + try { + return plugin_->is_valid_as_light_plugin(); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } +} + +bool Plugin::IsValidAsMediumPlugin() const { + try { + return plugin_->is_valid_as_medium_plugin(); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } +} + +bool Plugin::IsValidAsUpdatePlugin() const { + try { + return plugin_->is_valid_as_update_plugin(); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } +} + +bool Plugin::IsEmpty() const { return plugin_->is_empty(); } + +bool Plugin::LoadsArchive() const { return plugin_->loads_archive(); } + +bool Plugin::DoRecordsOverlap(const PluginInterface& plugin) const { + try { + auto& otherPlugin = dynamic_cast(plugin); + + return plugin_->do_records_overlap(*otherPlugin.plugin_); + } catch (std::bad_cast&) { + return false; + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } +} +} diff --git a/cpp/src/api/plugin.h b/cpp/src/api/plugin.h new file mode 100644 index 00000000..d77321f3 --- /dev/null +++ b/cpp/src/api/plugin.h @@ -0,0 +1,46 @@ + +#ifndef LOOT_API_PLUGIN +#define LOOT_API_PLUGIN + +#include +#include +#include +#include +#include + +#include "libloot-cpp/src/lib.rs.h" +#include "loot/metadata/tag.h" +#include "loot/plugin_interface.h" + +namespace loot { + +class Plugin final : public PluginInterface { +public: + explicit Plugin(::rust::Box plugin); + + std::string GetName() const override; + std::optional GetHeaderVersion() const override; + std::optional GetVersion() const override; + std::vector GetMasters() const override; + std::vector GetBashTags() const override; + std::optional GetCRC() const override; + + bool IsMaster() const override; + bool IsLightPlugin() const override; + bool IsMediumPlugin() const override; + bool IsUpdatePlugin() const override; + bool IsBlueprintPlugin() const override; + + bool IsValidAsLightPlugin() const override; + bool IsValidAsMediumPlugin() const override; + bool IsValidAsUpdatePlugin() const override; + bool IsEmpty() const override; + bool LoadsArchive() const override; + bool DoRecordsOverlap(const PluginInterface& plugin) const override; + +private: + ::rust::Box plugin_; +}; +} + +#endif diff --git a/src/api/resource.rc b/cpp/src/api/resource.rc similarity index 100% rename from src/api/resource.rc rename to cpp/src/api/resource.rc diff --git a/src/api/vertex.cpp b/cpp/src/api/vertex.cpp similarity index 100% rename from src/api/vertex.cpp rename to cpp/src/api/vertex.cpp diff --git a/cpp/src/database.rs b/cpp/src/database.rs new file mode 100644 index 00000000..8c9188c5 --- /dev/null +++ b/cpp/src/database.rs @@ -0,0 +1,303 @@ +use std::{ + path::Path, + sync::{Arc, RwLock}, +}; + +use delegate::delegate; +use libloot::{WriteMode, error::DatabaseLockPoisonError}; +use libloot_ffi_errors::UnsupportedEnumValueError; + +use crate::{ + OptionalPluginMetadata, VerboseError, + ffi::EdgeType, + metadata::{Group, Message, PluginMetadata, to_vec_of_unwrapped}, +}; + +#[derive(Debug)] +#[repr(transparent)] +pub struct Database(Arc>); + +impl Database { + pub fn new(db: Arc>) -> Self { + Self(db) + } + + pub fn load_masterlist(&self, path: &str) -> Result<(), VerboseError> { + self.0 + .write() + .map_err(DatabaseLockPoisonError::from)? + .load_masterlist(Path::new(path)) + .map_err(Into::into) + } + + pub fn load_masterlist_with_prelude( + &self, + masterlist_path: &str, + prelude_path: &str, + ) -> Result<(), VerboseError> { + self.0 + .write() + .map_err(DatabaseLockPoisonError::from)? + .load_masterlist_with_prelude(Path::new(masterlist_path), Path::new(prelude_path)) + .map_err(Into::into) + } + + pub fn load_userlist(&self, path: &str) -> Result<(), VerboseError> { + self.0 + .write() + .map_err(DatabaseLockPoisonError::from)? + .load_userlist(Path::new(path)) + .map_err(Into::into) + } + + pub fn write_user_metadata( + &self, + output_path: &str, + overwrite: bool, + ) -> Result<(), VerboseError> { + let write_mode = if overwrite { + WriteMode::CreateOrTruncate + } else { + WriteMode::Create + }; + + self.0 + .read() + .map_err(DatabaseLockPoisonError::from)? + .write_user_metadata(Path::new(output_path), write_mode) + .map_err(Into::into) + } + + pub fn write_minimal_list( + &self, + output_path: &str, + overwrite: bool, + ) -> Result<(), VerboseError> { + let write_mode = if overwrite { + WriteMode::CreateOrTruncate + } else { + WriteMode::Create + }; + + self.0 + .read() + .map_err(DatabaseLockPoisonError::from)? + .write_minimal_list(Path::new(output_path), write_mode) + .map_err(Into::into) + } + + pub fn evaluate(&self, condition: &str) -> Result { + self.0 + .read() + .map_err(DatabaseLockPoisonError::from)? + .evaluate(condition) + .map_err(Into::into) + } + + pub fn known_bash_tags(&self) -> Result, VerboseError> { + Ok(self + .0 + .read() + .map_err(DatabaseLockPoisonError::from)? + .known_bash_tags()) + } + + pub fn general_messages( + &self, + evaluate_conditions: bool, + ) -> Result, VerboseError> { + self.0 + .write() + .map_err(DatabaseLockPoisonError::from)? + .general_messages(evaluate_conditions) + .map(|v| v.into_iter().map(Into::into).collect()) + .map_err(Into::into) + } + + pub fn groups(&self, include_user_metadata: bool) -> Result, VerboseError> { + Ok(self + .0 + .read() + .map_err(DatabaseLockPoisonError::from)? + .groups(include_user_metadata) + .into_iter() + .map(Into::into) + .collect()) + } + + // I tried returning a GroupRef<'_> here, but it borrows from the DB read guard, which the borrow checker sees as an owned value within this scope, so won't allow me to return a reference to it. This is the only place that uses a group reference, so it's probably not worth figuring out a workaround, and cloning the groups is fine. + pub fn user_groups(&self) -> Result, VerboseError> { + Ok(self + .0 + .read() + .map_err(DatabaseLockPoisonError::from)? + .user_groups() + .iter() + .cloned() + .map(Into::into) + .collect()) + } + + // This is ugly, but Group can't be held as a value in C++ and CXX doesn't support Vec> as a parameter, so this can't take ownership of the input groups. + pub fn set_user_groups(&self, groups: &[Box]) -> Result<(), VerboseError> { + let groups = to_vec_of_unwrapped(groups); + self.0 + .write() + .map_err(DatabaseLockPoisonError::from)? + .set_user_groups(groups); + Ok(()) + } + + pub fn groups_path( + &self, + from_group_name: &str, + to_group_name: &str, + ) -> Result, VerboseError> { + self.0 + .read() + .map_err(DatabaseLockPoisonError::from)? + .groups_path(from_group_name, to_group_name) + .map(|v| v.into_iter().map(Into::into).collect()) + .map_err(Into::into) + } + + pub fn plugin_metadata( + &self, + plugin_name: &str, + include_user_metadata: bool, + evaluate_conditions: bool, + ) -> Result, VerboseError> { + self.0 + .read() + .map_err(DatabaseLockPoisonError::from)? + .plugin_metadata(plugin_name, include_user_metadata, evaluate_conditions) + .map(|p| Box::new(p.map(Into::into).into())) + .map_err(Into::into) + } + + pub fn plugin_user_metadata( + &self, + plugin_name: &str, + evaluate_conditions: bool, + ) -> Result, VerboseError> { + self.0 + .read() + .map_err(DatabaseLockPoisonError::from)? + .plugin_user_metadata(plugin_name, evaluate_conditions) + .map(|p| Box::new(p.map(Into::into).into())) + .map_err(Into::into) + } + + pub fn set_plugin_user_metadata( + &mut self, + plugin_metadata: Box, + ) -> Result<(), VerboseError> { + self.0 + .write() + .map_err(DatabaseLockPoisonError::from)? + .set_plugin_user_metadata(plugin_metadata.into()); + Ok(()) + } + + pub fn discard_plugin_user_metadata(&self, plugin: &str) -> Result<(), VerboseError> { + self.0 + .write() + .map_err(DatabaseLockPoisonError::from)? + .discard_plugin_user_metadata(plugin); + Ok(()) + } + + pub fn discard_all_user_metadata(&self) -> Result<(), VerboseError> { + self.0 + .write() + .map_err(DatabaseLockPoisonError::from)? + .discard_all_user_metadata(); + Ok(()) + } +} + +#[derive(Clone, Debug)] +#[repr(transparent)] +pub struct Vertex(libloot::Vertex); + +pub fn new_vertex(name: String, out_edge_type: EdgeType) -> Result, VerboseError> { + let mut vertex = libloot::Vertex::new(name); + + if out_edge_type != EdgeType::None { + vertex = vertex.with_out_edge_type(out_edge_type.try_into()?); + } + + Ok(Box::new(Vertex(vertex))) +} + +impl Vertex { + // A value of 255 is used to indicate that there is no out edge. + pub fn out_edge_type(&self) -> Result { + match self.0.out_edge_type() { + Some(e) => EdgeType::try_from(e).map_err(Into::into), + None => Ok(EdgeType::None), + } + } + + pub fn boxed_clone(&self) -> Box { + Box::new(Self(self.0.clone())) + } + + delegate! { + to self.0 { + pub fn name(&self) -> &str; + } + } +} + +impl From for Vertex { + fn from(value: libloot::Vertex) -> Self { + Self(value) + } +} + +impl TryFrom for EdgeType { + type Error = UnsupportedEnumValueError; + + fn try_from(value: libloot::EdgeType) -> Result { + match value { + libloot::EdgeType::Hardcoded => Ok(EdgeType::Hardcoded), + libloot::EdgeType::MasterFlag => Ok(EdgeType::MasterFlag), + libloot::EdgeType::Master => Ok(EdgeType::Master), + libloot::EdgeType::MasterlistRequirement => Ok(EdgeType::MasterlistRequirement), + libloot::EdgeType::UserRequirement => Ok(EdgeType::UserRequirement), + libloot::EdgeType::MasterlistLoadAfter => Ok(EdgeType::MasterlistLoadAfter), + libloot::EdgeType::UserLoadAfter => Ok(EdgeType::UserLoadAfter), + libloot::EdgeType::MasterlistGroup => Ok(EdgeType::MasterlistGroup), + libloot::EdgeType::UserGroup => Ok(EdgeType::UserGroup), + libloot::EdgeType::RecordOverlap => Ok(EdgeType::RecordOverlap), + libloot::EdgeType::AssetOverlap => Ok(EdgeType::AssetOverlap), + libloot::EdgeType::TieBreak => Ok(EdgeType::TieBreak), + libloot::EdgeType::BlueprintMaster => Ok(EdgeType::BlueprintMaster), + _ => Err(UnsupportedEnumValueError), + } + } +} + +impl TryFrom for libloot::EdgeType { + type Error = UnsupportedEnumValueError; + + fn try_from(value: EdgeType) -> Result { + match value { + EdgeType::Hardcoded => Ok(libloot::EdgeType::Hardcoded), + EdgeType::MasterFlag => Ok(libloot::EdgeType::MasterFlag), + EdgeType::Master => Ok(libloot::EdgeType::Master), + EdgeType::MasterlistRequirement => Ok(libloot::EdgeType::MasterlistRequirement), + EdgeType::UserRequirement => Ok(libloot::EdgeType::UserRequirement), + EdgeType::MasterlistLoadAfter => Ok(libloot::EdgeType::MasterlistLoadAfter), + EdgeType::UserLoadAfter => Ok(libloot::EdgeType::UserLoadAfter), + EdgeType::MasterlistGroup => Ok(libloot::EdgeType::MasterlistGroup), + EdgeType::UserGroup => Ok(libloot::EdgeType::UserGroup), + EdgeType::RecordOverlap => Ok(libloot::EdgeType::RecordOverlap), + EdgeType::AssetOverlap => Ok(libloot::EdgeType::AssetOverlap), + EdgeType::TieBreak => Ok(libloot::EdgeType::TieBreak), + EdgeType::BlueprintMaster => Ok(libloot::EdgeType::BlueprintMaster), + _ => Err(UnsupportedEnumValueError), + } + } +} diff --git a/cpp/src/error.rs b/cpp/src/error.rs new file mode 100644 index 00000000..ccc5992b --- /dev/null +++ b/cpp/src/error.rs @@ -0,0 +1,117 @@ +use crate::game::NotValidUtf8; +use libloot_ffi_errors::{UnsupportedEnumValueError, fmt_error_chain, variant_box_from_error}; + +use libloot::{ + error::{ + ConditionEvaluationError, DatabaseLockPoisonError, GameHandleCreationError, + GroupsPathError, LoadOrderError, LoadOrderStateError, LoadPluginsError, + MetadataRetrievalError, PluginDataError, SortPluginsError, + }, + metadata::error::{ + LoadMetadataError, MultilingualMessageContentsError, RegexError, WriteMetadataError, + }, +}; + +#[derive(Debug)] +pub enum VerboseError { + CyclicInteractionError(Vec), + UndefinedGroupError(String), + PluginNotLoadedError(String), + InvalidArgument(String), + Other(Box), +} + +impl std::fmt::Display for VerboseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::CyclicInteractionError(cycle) => { + write!(f, "CyclicInteractionError: ")?; + for vertex in cycle { + let name = vertex.name().replace('\\', "\\\\").replace('-', "\\-"); + match vertex.out_edge_type() { + Some(e) => write!(f, "{name}--{e}--")?, + None => write!(f, "{name}")?, + } + } + Ok(()) + } + Self::UndefinedGroupError(group) => { + write!(f, "UndefinedGroupError: {group}",) + } + Self::PluginNotLoadedError(plugin) => write!(f, "PluginNotLoadedError: {plugin}"), + Self::InvalidArgument(s) => write!(f, "InvalidArgument: {s}"), + Self::Other(e) => fmt_error_chain(e.as_ref(), f), + } + } +} + +variant_box_from_error!(UnsupportedEnumValueError, VerboseError::Other); +variant_box_from_error!(NotValidUtf8, VerboseError::Other); +variant_box_from_error!(DatabaseLockPoisonError, VerboseError::Other); +variant_box_from_error!(MultilingualMessageContentsError, VerboseError::Other); +variant_box_from_error!(RegexError, VerboseError::Other); +variant_box_from_error!(LoadMetadataError, VerboseError::Other); +variant_box_from_error!(WriteMetadataError, VerboseError::Other); +variant_box_from_error!(ConditionEvaluationError, VerboseError::Other); +variant_box_from_error!(MetadataRetrievalError, VerboseError::Other); +variant_box_from_error!(LoadOrderError, VerboseError::Other); +variant_box_from_error!(LoadOrderStateError, VerboseError::Other); +variant_box_from_error!(PluginDataError, VerboseError::Other); + +impl From for VerboseError { + fn from(value: GameHandleCreationError) -> Self { + match value { + GameHandleCreationError::NotADirectory(_) => Self::InvalidArgument(value.to_string()), + GameHandleCreationError::LoadOrderError(_) | _ => Self::Other(Box::new(value)), + } + } +} + +impl From for VerboseError { + fn from(value: LoadPluginsError) -> Self { + match value { + LoadPluginsError::PluginNotLoaded(p) => Self::PluginNotLoadedError(p), + LoadPluginsError::PluginValidationError(_) => Self::InvalidArgument(value.to_string()), + LoadPluginsError::DatabaseLockPoisoned + | LoadPluginsError::IoError(_) + | LoadPluginsError::PluginDataError(_) + | _ => Self::Other(Box::new(value)), + } + } +} + +impl From for VerboseError { + fn from(value: SortPluginsError) -> Self { + match value { + SortPluginsError::UndefinedGroup(g) => Self::UndefinedGroupError(g), + SortPluginsError::CycleFound(cycle) => Self::CyclicInteractionError(cycle), + SortPluginsError::PluginNotLoaded(p) => Self::PluginNotLoadedError(p), + SortPluginsError::DatabaseLockPoisoned + | SortPluginsError::CycleFoundInvolving(_) + | SortPluginsError::PathfindingError(_) + | SortPluginsError::PluginDataError(_) + | _ => Self::Other(Box::new(value)), + } + } +} + +impl From for VerboseError { + fn from(value: GroupsPathError) -> Self { + match value { + GroupsPathError::UndefinedGroup(g) => Self::UndefinedGroupError(g), + GroupsPathError::CycleFound(cycle) => Self::CyclicInteractionError(cycle), + GroupsPathError::PathfindingError(_) | _ => Self::Other(Box::new(value)), + } + } +} + +#[derive(Clone, Copy, Debug)] +pub struct EmptyOptionalError; + +impl std::fmt::Display for EmptyOptionalError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "optional is empty") + } +} + +impl std::error::Error for EmptyOptionalError {} diff --git a/cpp/src/game.rs b/cpp/src/game.rs new file mode 100644 index 00000000..50312d77 --- /dev/null +++ b/cpp/src/game.rs @@ -0,0 +1,200 @@ +use std::path::Path; + +use delegate::delegate; +use libloot_ffi_errors::UnsupportedEnumValueError; + +use crate::{OptionalPlugin, Plugin, VerboseError, database::Database, ffi::GameType}; + +impl TryFrom for GameType { + type Error = UnsupportedEnumValueError; + + fn try_from(value: libloot::GameType) -> Result { + match value { + libloot::GameType::Oblivion => Ok(GameType::Oblivion), + libloot::GameType::Skyrim => Ok(GameType::Skyrim), + libloot::GameType::Fallout3 => Ok(GameType::Fallout3), + libloot::GameType::FalloutNV => Ok(GameType::FalloutNV), + libloot::GameType::Fallout4 => Ok(GameType::Fallout4), + libloot::GameType::SkyrimSE => Ok(GameType::SkyrimSE), + libloot::GameType::Fallout4VR => Ok(GameType::Fallout4VR), + libloot::GameType::SkyrimVR => Ok(GameType::SkyrimVR), + libloot::GameType::Morrowind => Ok(GameType::Morrowind), + libloot::GameType::Starfield => Ok(GameType::Starfield), + libloot::GameType::OpenMW => Ok(GameType::OpenMW), + libloot::GameType::OblivionRemastered => Ok(GameType::OblivionRemastered), + _ => Err(UnsupportedEnumValueError), + } + } +} + +impl TryFrom for libloot::GameType { + type Error = UnsupportedEnumValueError; + + fn try_from(value: GameType) -> Result { + match value { + GameType::Oblivion => Ok(libloot::GameType::Oblivion), + GameType::Skyrim => Ok(libloot::GameType::Skyrim), + GameType::Fallout3 => Ok(libloot::GameType::Fallout3), + GameType::FalloutNV => Ok(libloot::GameType::FalloutNV), + GameType::Fallout4 => Ok(libloot::GameType::Fallout4), + GameType::SkyrimSE => Ok(libloot::GameType::SkyrimSE), + GameType::Fallout4VR => Ok(libloot::GameType::Fallout4VR), + GameType::SkyrimVR => Ok(libloot::GameType::SkyrimVR), + GameType::Morrowind => Ok(libloot::GameType::Morrowind), + GameType::Starfield => Ok(libloot::GameType::Starfield), + GameType::OpenMW => Ok(libloot::GameType::OpenMW), + GameType::OblivionRemastered => Ok(libloot::GameType::OblivionRemastered), + _ => Err(UnsupportedEnumValueError), + } + } +} + +impl From for libloot::Game { + fn from(value: Game) -> Self { + value.0 + } +} + +impl From for Game { + fn from(value: libloot::Game) -> Self { + Game(value) + } +} + +#[derive(Clone, Copy, Debug)] +pub struct NotValidUtf8; + +impl std::fmt::Display for NotValidUtf8 { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Value was not valid UTF-8") + } +} + +impl std::error::Error for NotValidUtf8 {} + +#[derive(Debug)] +#[repr(transparent)] +pub struct Game(libloot::Game); + +// CXX doesn't support &Path so use &str instead. +pub fn new_game(game_type: GameType, game_path: &str) -> Result, VerboseError> { + libloot::Game::new(game_type.try_into()?, Path::new(game_path)) + .map(|g| Box::new(g.into())) + .map_err(Into::into) +} + +pub fn new_game_with_local_path( + game_type: GameType, + game_path: &str, + game_local_path: &str, +) -> Result, VerboseError> { + libloot::Game::with_local_path( + game_type.try_into()?, + Path::new(game_path), + Path::new(game_local_path), + ) + .map(|g| Box::new(g.into())) + .map_err(Into::into) +} + +fn path_to_string(path: &Path) -> Result { + path.to_str() + .map(str::to_owned) + .ok_or(NotValidUtf8) + .map_err(Into::into) +} + +fn strings_to_paths<'a>(paths: &'a [&'a str]) -> Vec<&'a Path> { + paths.iter().map(Path::new).collect() +} + +impl Game { + pub fn game_type(&self) -> Result { + self.0.game_type().try_into().map_err(Into::into) + } + + pub fn additional_data_paths(&self) -> Result, VerboseError> { + self.0 + .additional_data_paths() + .iter() + .map(|p| path_to_string(p)) + .collect() + } + + pub fn set_additional_data_paths( + &mut self, + additional_data_paths: &[&str], + ) -> Result<(), VerboseError> { + self.0 + .set_additional_data_paths(&strings_to_paths(additional_data_paths)) + .map_err(Into::into) + } + + pub fn database(&self) -> Box { + Box::new(Database::new(self.0.database())) + } + + pub fn is_valid_plugin(&self, plugin_path: &str) -> bool { + self.0.is_valid_plugin(Path::new(plugin_path)) + } + + pub fn load_plugins(&mut self, plugin_paths: &[&str]) -> Result<(), VerboseError> { + self.0 + .load_plugins(&strings_to_paths(plugin_paths)) + .map_err(Into::into) + } + + pub fn load_plugin_headers(&mut self, plugin_paths: &[&str]) -> Result<(), VerboseError> { + self.0 + .load_plugin_headers(&strings_to_paths(plugin_paths)) + .map_err(Into::into) + } + + pub fn plugin(&self, plugin_name: &str) -> Box { + Box::new(self.0.plugin(plugin_name).map(Into::into).into()) + } + + pub fn loaded_plugins(&self) -> Vec { + self.0 + .loaded_plugins() + .into_iter() + .map(Into::into) + .collect() + } + + pub fn sort_plugins(&self, plugin_names: &[&str]) -> Result, VerboseError> { + self.0.sort_plugins(plugin_names).map_err(Into::into) + } + + pub fn load_current_load_order_state(&mut self) -> Result<(), VerboseError> { + self.0.load_current_load_order_state().map_err(Into::into) + } + + pub fn is_load_order_ambiguous(&self) -> Result { + self.0.is_load_order_ambiguous().map_err(Into::into) + } + + pub fn active_plugins_file_path(&self) -> Result { + path_to_string(self.0.active_plugins_file_path()) + } + + pub fn load_order(&self) -> Vec { + self.0 + .load_order() + .iter() + .map(ToString::to_string) + .collect() + } + + pub fn set_load_order(&mut self, load_order: &[&str]) -> Result<(), VerboseError> { + self.0.set_load_order(load_order).map_err(Into::into) + } + + delegate! { + to self.0 { + pub fn clear_loaded_plugins(&mut self); + + pub fn is_plugin_active(&self, plugin_name: &str) -> bool; + } + } +} diff --git a/cpp/src/lib.rs b/cpp/src/lib.rs new file mode 100644 index 00000000..d1be5b61 --- /dev/null +++ b/cpp/src/lib.rs @@ -0,0 +1,759 @@ +// Deny some rustc lints that are allow-by-default. +#![deny( + ambiguous_negative_literals, + impl_trait_overcaptures, + let_underscore_drop, + missing_copy_implementations, + missing_debug_implementations, + non_ascii_idents, + redundant_imports, + redundant_lifetimes, + trivial_casts, + trivial_numeric_casts, + unit_bindings +)] +#![deny(clippy::pedantic)] +// Allow a few clippy pedantic lints. +#![allow(clippy::must_use_candidate)] +#![allow(clippy::missing_errors_doc)] +#![allow( + clippy::unnecessary_box_returns, + reason = "CXX requires many returns to be boxed" +)] +// Selectively deny clippy restriction lints. +#![deny( + clippy::as_conversions, + clippy::as_underscore, + clippy::assertions_on_result_states, + clippy::big_endian_bytes, + clippy::cfg_not_test, + clippy::clone_on_ref_ptr, + clippy::create_dir, + clippy::dbg_macro, + clippy::decimal_literal_representation, + clippy::default_numeric_fallback, + clippy::doc_include_without_cfg, + clippy::empty_drop, + clippy::error_impl_error, + clippy::exit, + clippy::exhaustive_enums, + clippy::expect_used, + clippy::filetype_is_file, + clippy::float_cmp_const, + clippy::fn_to_numeric_cast_any, + clippy::get_unwrap, + clippy::host_endian_bytes, + clippy::if_then_some_else_none, + clippy::indexing_slicing, + clippy::infinite_loop, + clippy::integer_division, + clippy::integer_division_remainder_used, + clippy::iter_over_hash_type, + clippy::let_underscore_must_use, + clippy::lossy_float_literal, + clippy::map_err_ignore, + clippy::map_with_unused_argument_over_ranges, + clippy::mem_forget, + clippy::missing_assert_message, + clippy::missing_asserts_for_indexing, + clippy::mixed_read_write_in_expression, + clippy::multiple_inherent_impl, + clippy::multiple_unsafe_ops_per_block, + clippy::mutex_atomic, + clippy::mutex_integer, + clippy::needless_raw_strings, + clippy::non_ascii_literal, + clippy::non_zero_suggestions, + clippy::panic, + clippy::panic_in_result_fn, + clippy::partial_pub_fields, + clippy::pathbuf_init_then_push, + clippy::precedence_bits, + clippy::print_stderr, + clippy::print_stdout, + clippy::rc_buffer, + clippy::rc_mutex, + clippy::redundant_type_annotations, + clippy::ref_patterns, + clippy::rest_pat_in_fully_bound_structs, + clippy::str_to_string, + clippy::string_lit_chars_any, + clippy::string_slice, + clippy::string_to_string, + clippy::suspicious_xor_used_as_pow, + clippy::tests_outside_test_module, + clippy::todo, + clippy::try_err, + clippy::undocumented_unsafe_blocks, + clippy::unimplemented, + clippy::unnecessary_safety_comment, + clippy::unneeded_field_pattern, + clippy::unreachable, + clippy::unused_result_ok, + clippy::unwrap_in_result, + clippy::unwrap_used, + clippy::use_debug, + clippy::verbose_file_reads, + clippy::wildcard_enum_match_arm +)] + +mod database; +mod error; +mod game; +mod metadata; +mod plugin; + +use database::{Database, Vertex, new_vertex}; +use error::{EmptyOptionalError, VerboseError}; +use ffi::OptionalMessageContentRef; +use game::{Game, new_game, new_game_with_local_path}; +use libloot_ffi_errors::UnsupportedEnumValueError; +use metadata::{ + File, Filename, Group, Location, Message, MessageContent, PluginCleaningData, PluginMetadata, + Tag, group_default_name, message_content_default_language, multilingual_message, new_file, + new_filename, new_group, new_location, new_message, new_message_content, + new_plugin_cleaning_data, new_plugin_metadata, new_tag, select_message_content, +}; +use plugin::Plugin; +use std::{ + ffi::{CString, c_char, c_uchar, c_uint, c_void}, + sync::{Mutex, atomic::AtomicPtr}, +}; + +use libloot::set_logging_callback; +pub use libloot::{is_compatible, libloot_revision, libloot_version}; + +impl OptionalMessageContentRef { + pub fn is_some(&self) -> bool { + !self.pointer.is_null() + } + + /// # Safety + /// + /// This is safe as long as the pointer in the `OptionalRef` is still valid. + pub unsafe fn as_ref(&self) -> Result<&MessageContent, EmptyOptionalError> { + if self.pointer.is_null() { + Err(EmptyOptionalError) + } else { + // SAFETY: This is safe as long as self.0 is still valid. + unsafe { Ok(&*self.pointer) } + } + } +} + +impl From> for OptionalMessageContentRef { + fn from(value: Option<&MessageContent>) -> Self { + match value { + Some(p) => OptionalMessageContentRef { pointer: p }, + None => OptionalMessageContentRef { + pointer: std::ptr::null(), + }, + } + } +} + +#[derive(Debug)] +pub struct Optional(Option); + +impl Optional { + pub fn is_some(&self) -> bool { + self.0.is_some() + } + + pub fn as_ref(&self) -> Result<&T, EmptyOptionalError> { + match &self.0 { + Some(t) => Ok(t), + None => Err(EmptyOptionalError), + } + } +} + +impl From> for Optional { + fn from(value: Option) -> Self { + Self(value) + } +} + +pub type OptionalPlugin = Optional; + +pub type OptionalPluginMetadata = Optional; + +pub type OptionalCrc = Optional; + +fn set_log_level(level: ffi::LogLevel) -> Result<(), VerboseError> { + libloot::set_log_level(level.try_into()?); + Ok(()) +} + +impl TryFrom for libloot::LogLevel { + type Error = UnsupportedEnumValueError; + + fn try_from(value: ffi::LogLevel) -> Result { + match value { + ffi::LogLevel::Trace => Ok(libloot::LogLevel::Trace), + ffi::LogLevel::Debug => Ok(libloot::LogLevel::Debug), + ffi::LogLevel::Info => Ok(libloot::LogLevel::Info), + ffi::LogLevel::Warning => Ok(libloot::LogLevel::Warning), + ffi::LogLevel::Error => Ok(libloot::LogLevel::Error), + _ => Err(UnsupportedEnumValueError), + } + } +} + +#[allow( + let_underscore_drop, + missing_debug_implementations, + clippy::multiple_unsafe_ops_per_block, + clippy::needless_lifetimes, + reason = "Required by CXX" +)] +#[cxx::bridge(namespace = "loot::rust")] +mod ffi { + + pub enum GameType { + Oblivion, + Skyrim, + Fallout3, + FalloutNV, + Fallout4, + SkyrimSE, + Fallout4VR, + SkyrimVR, + Morrowind, + Starfield, + OpenMW, + OblivionRemastered, + } + + pub enum MessageType { + Say, + Warn, + Error, + } + + pub enum TagSuggestion { + Addition, + Removal, + } + + pub enum EdgeType { + /// A special value that indicates that there is no edge. + None, + Hardcoded, + MasterFlag, + Master, + MasterlistRequirement, + UserRequirement, + MasterlistLoadAfter, + UserLoadAfter, + MasterlistGroup, + UserGroup, + RecordOverlap, + AssetOverlap, + TieBreak, + BlueprintMaster, + } + + pub enum LogLevel { + Trace, + Debug, + Info, + Warning, + Error, + } + + #[derive(Debug)] + struct OptionalMessageContentRef { + pointer: *const MessageContent, + } + + extern "Rust" { + pub fn is_some(self: &OptionalMessageContentRef) -> bool; + + pub unsafe fn as_ref<'a>(self: &'a OptionalMessageContentRef) + -> Result<&'a MessageContent>; + } + + extern "Rust" { + fn set_log_level(level: LogLevel) -> Result<()>; + + fn is_compatible(major: u32, minor: u32, patch: u32) -> bool; + fn libloot_version() -> String; + fn libloot_revision() -> String; + + fn select_message_content( + contents: &[MessageContent], + language: &str, + ) -> OptionalMessageContentRef; + } + + extern "Rust" { + type Game; + + fn new_game(game_type: GameType, game_path: &str) -> Result>; + + fn new_game_with_local_path( + game_type: GameType, + game_path: &str, + game_local_path: &str, + ) -> Result>; + + pub fn game_type(&self) -> Result; + + pub fn additional_data_paths(&self) -> Result>; + + pub fn set_additional_data_paths(&mut self, additional_data_paths: &[&str]) -> Result<()>; + + pub fn database(&self) -> Box; + + pub fn is_valid_plugin(&self, plugin_path: &str) -> bool; + + pub fn load_plugins(&mut self, plugin_paths: &[&str]) -> Result<()>; + + pub fn load_plugin_headers(&mut self, plugin_paths: &[&str]) -> Result<()>; + + pub fn clear_loaded_plugins(&mut self); + + pub fn plugin(&self, plugin_name: &str) -> Box; + + pub fn loaded_plugins(&self) -> Vec; + + pub fn sort_plugins(&self, plugin_names: &[&str]) -> Result>; + + pub fn load_current_load_order_state(&mut self) -> Result<()>; + + pub fn is_load_order_ambiguous(&self) -> Result; + + pub fn active_plugins_file_path(&self) -> Result; + + pub fn is_plugin_active(&self, plugin_name: &str) -> bool; + + pub fn load_order(&self) -> Vec; + + pub fn set_load_order(&mut self, load_order: &[&str]) -> Result<()>; + } + + extern "Rust" { + type Database; + + pub fn load_masterlist(&self, path: &str) -> Result<()>; + + pub fn load_masterlist_with_prelude( + &self, + masterlist_path: &str, + prelude_path: &str, + ) -> Result<()>; + + pub fn load_userlist(&self, path: &str) -> Result<()>; + + pub fn write_user_metadata(&self, output_path: &str, overwrite: bool) -> Result<()>; + + pub fn write_minimal_list(&self, output_path: &str, overwrite: bool) -> Result<()>; + + pub fn evaluate(&self, condition: &str) -> Result; + + pub fn known_bash_tags(&self) -> Result>; + + pub fn general_messages(&self, evaluate_conditions: bool) -> Result>; + + pub fn groups(&self, include_user_metadata: bool) -> Result>; + + pub fn user_groups(&self) -> Result>; + + pub fn set_user_groups(&self, groups: &[Box]) -> Result<()>; + + pub fn groups_path( + &self, + from_group_name: &str, + to_group_name: &str, + ) -> Result>; + + pub fn plugin_metadata( + &self, + plugin_name: &str, + include_user_metadata: bool, + evaluate_conditions: bool, + ) -> Result>; + + pub fn plugin_user_metadata( + &self, + plugin_name: &str, + evaluate_conditions: bool, + ) -> Result>; + + pub fn set_plugin_user_metadata( + &mut self, + plugin_metadata: Box, + ) -> Result<()>; + + pub fn discard_plugin_user_metadata(&self, plugin: &str) -> Result<()>; + + pub fn discard_all_user_metadata(&self) -> Result<()>; + } + + extern "Rust" { + type Message; + + pub fn new_message( + message_type: MessageType, + content: String, + condition: &str, + ) -> Result>; + + pub fn multilingual_message( + message_type: MessageType, + contents: &[Box], + condition: &str, + ) -> Result>; + + pub fn message_type(&self) -> MessageType; + + pub fn content(&self) -> &[MessageContent]; + + pub fn condition(&self) -> &str; + + pub fn boxed_clone(&self) -> Box; + } + + extern "Rust" { + type MessageContent; + + pub fn message_content_default_language() -> &'static str; + + pub fn new_message_content(text: String, language: &str) -> Box; + + pub fn text(&self) -> &str; + + pub fn language(&self) -> &str; + + pub fn boxed_clone(&self) -> Box; + } + + extern "Rust" { + type Group; + + pub fn new_group(name: String, description: &str, after_groups: Vec) -> Box; + + pub fn group_default_name() -> &'static str; + + pub fn boxed_clone(&self) -> Box; + + pub fn name(&self) -> &str; + + pub fn description(&self) -> &str; + + pub fn after_groups(&self) -> &[String]; + } + + extern "Rust" { + type Plugin; + + pub fn name(&self) -> &str; + + /// NaN is used to indicate that the header version was not found. + pub fn header_version(&self) -> f32; + + /// An empty string is used to indicate that no version was found. + pub fn version(&self) -> &str; + + pub fn masters(&self) -> Result>; + + pub fn bash_tags(&self) -> &[String]; + + pub fn crc(&self) -> Box; + + pub fn is_master(&self) -> bool; + + pub fn is_light_plugin(&self) -> bool; + + pub fn is_medium_plugin(&self) -> bool; + + pub fn is_update_plugin(&self) -> bool; + + pub fn is_blueprint_plugin(&self) -> bool; + + pub fn is_valid_as_light_plugin(&self) -> Result; + + pub fn is_valid_as_medium_plugin(&self) -> Result; + + pub fn is_valid_as_update_plugin(&self) -> Result; + + pub fn is_empty(&self) -> bool; + + pub fn loads_archive(&self) -> bool; + + pub fn do_records_overlap(&self, plugin: &Plugin) -> Result; + + pub fn boxed_clone(&self) -> Box; + } + + extern "Rust" { + type OptionalPlugin; + + pub fn is_some(&self) -> bool; + + pub unsafe fn as_ref<'a>(&'a self) -> Result<&'a Plugin>; + } + + extern "Rust" { + type OptionalCrc; + + pub fn is_some(&self) -> bool; + + pub unsafe fn as_ref<'a>(&'a self) -> Result<&'a u32>; + } + + extern "Rust" { + type Vertex; + + pub fn new_vertex(name: String, out_edge_type: EdgeType) -> Result>; + + pub fn name(&self) -> &str; + + pub fn out_edge_type(&self) -> Result; + + pub fn boxed_clone(&self) -> Box; + } + + extern "Rust" { + type OptionalPluginMetadata; + + pub fn is_some(&self) -> bool; + + pub unsafe fn as_ref<'a>(&'a self) -> Result<&'a PluginMetadata>; + } + + extern "Rust" { + type PluginMetadata; + + pub fn new_plugin_metadata(name: &str) -> Result>; + + pub fn name(&self) -> &str; + + /// An empty string is used to indicate that no group is set. + pub fn group(&self) -> &str; + + pub fn load_after_files(&self) -> &[File]; + + pub fn requirements(&self) -> &[File]; + + pub fn incompatibilities(&self) -> &[File]; + + pub fn messages(&self) -> &[Message]; + + pub fn tags(&self) -> &[Tag]; + + pub fn dirty_info(&self) -> &[PluginCleaningData]; + + pub fn clean_info(&self) -> &[PluginCleaningData]; + + pub fn locations(&self) -> &[Location]; + + pub fn set_group(&mut self, group: String); + + pub fn unset_group(&mut self); + + pub fn set_load_after_files(&mut self, files: &[Box]); + + pub fn set_requirements(&mut self, files: &[Box]); + + pub fn set_incompatibilities(&mut self, files: &[Box]); + + pub fn set_messages(&mut self, messages: &[Box]); + + pub fn set_tags(&mut self, tags: &[Box]); + + pub fn set_dirty_info(&mut self, info: &[Box]); + + pub fn set_clean_info(&mut self, info: &[Box]); + + pub fn set_locations(&mut self, locations: &[Box]); + + pub fn merge_metadata(&mut self, plugin: &PluginMetadata); + + pub fn has_name_only(&self) -> bool; + + pub fn is_regex_plugin(&self) -> bool; + + pub fn name_matches(&self, other_name: &str) -> bool; + + pub fn as_yaml(&self) -> String; + + pub fn boxed_clone(&self) -> Box; + } + + extern "Rust" { + type File; + + pub fn new_file( + name: String, + display_name: &str, + condition: &str, + detail: &[Box], + constraint: &str, + ) -> Result>; + + pub fn filename(&self) -> &Filename; + + pub fn display_name(&self) -> &str; + + pub fn detail(&self) -> &[MessageContent]; + + pub fn condition(&self) -> &str; + + pub fn constraint(&self) -> &str; + + pub fn boxed_clone(&self) -> Box; + } + + extern "Rust" { + type Filename; + + pub fn new_filename(name: String) -> Box; + + pub fn as_str(&self) -> &str; + + pub fn boxed_clone(&self) -> Box; + + pub fn eq(&self, other: &Filename) -> bool; + + pub fn ne(&self, other: &Filename) -> bool; + + pub fn lt(&self, other: &Filename) -> bool; + + pub fn le(&self, other: &Filename) -> bool; + + pub fn gt(&self, other: &Filename) -> bool; + + pub fn ge(&self, other: &Filename) -> bool; + } + + extern "Rust" { + type Tag; + + pub fn new_tag( + name: String, + suggestion: TagSuggestion, + condition: &str, + ) -> Result>; + + pub fn name(&self) -> &str; + + pub fn is_addition(&self) -> bool; + + pub fn condition(&self) -> &str; + + pub fn boxed_clone(&self) -> Box; + } + + extern "Rust" { + type PluginCleaningData; + + pub fn new_plugin_cleaning_data( + crc: u32, + cleaning_utility: String, + detail: &[Box], + itm_count: u32, + deleted_reference_count: u32, + deleted_navmesh_count: u32, + ) -> Result>; + + pub fn crc(&self) -> u32; + + pub fn itm_count(&self) -> u32; + + pub fn deleted_reference_count(&self) -> u32; + + pub fn deleted_navmesh_count(&self) -> u32; + + pub fn cleaning_utility(&self) -> &str; + + pub fn detail(&self) -> &[MessageContent]; + + pub fn boxed_clone(&self) -> Box; + } + + extern "Rust" { + type Location; + + pub fn new_location(url: String, name: &str) -> Box; + + pub fn url(&self) -> &str; + + pub fn name(&self) -> &str; + + pub fn boxed_clone(&self) -> Box; + } +} + +#[unsafe(no_mangle)] +pub static LIBLOOT_VERSION_MAJOR: c_uint = libloot::LIBLOOT_VERSION_MAJOR; + +#[unsafe(no_mangle)] +pub static LIBLOOT_VERSION_MINOR: c_uint = libloot::LIBLOOT_VERSION_MINOR; + +#[unsafe(no_mangle)] +pub static LIBLOOT_VERSION_PATCH: c_uint = libloot::LIBLOOT_VERSION_PATCH; + +#[unsafe(no_mangle)] +pub static LIBLOOT_LOG_LEVEL_TRACE: c_uchar = 0; + +#[unsafe(no_mangle)] +pub static LIBLOOT_LOG_LEVEL_DEBUG: c_uchar = 1; + +#[unsafe(no_mangle)] +pub static LIBLOOT_LOG_LEVEL_INFO: c_uchar = 2; + +#[unsafe(no_mangle)] +pub static LIBLOOT_LOG_LEVEL_WARNING: c_uchar = 3; + +#[unsafe(no_mangle)] +pub static LIBLOOT_LOG_LEVEL_ERROR: c_uchar = 4; + +fn to_u8(value: libloot::LogLevel) -> u8 { + match value { + libloot::LogLevel::Trace => LIBLOOT_LOG_LEVEL_TRACE, + libloot::LogLevel::Debug => LIBLOOT_LOG_LEVEL_DEBUG, + libloot::LogLevel::Info => LIBLOOT_LOG_LEVEL_INFO, + libloot::LogLevel::Warning => LIBLOOT_LOG_LEVEL_WARNING, + libloot::LogLevel::Error => LIBLOOT_LOG_LEVEL_ERROR, + } +} + +#[unsafe(no_mangle)] +unsafe extern "C" fn libloot_set_logging_callback( + callback: unsafe extern "C" fn(u8, *const c_char, *mut c_void), + context: *mut c_void, +) { + let mutex = Mutex::new(AtomicPtr::new(context)); + + set_logging_callback(move |level, message| { + let (level, c_string) = CString::new(message).map_or_else( + |_| { + let c_string = CString::new(format!( + "Attempted to log a message containing a null byte: {}", + message.replace('\0', "\\0") + )) + .unwrap_or_else(|_| { + CString::from(c"Attempted to log a message containing a null byte") + }); + (libloot::LogLevel::Error, c_string) + }, + |c| (level, c), + ); + + let mut context = match mutex.lock() { + Ok(c) => c, + Err(e) => { + // The stored value is an atomic, since it's atomic it can't have been left in an invalid state. + mutex.clear_poison(); + e.into_inner() + } + }; + + // SAFETY: This is safe so long as callback remains a valid function pointer. + unsafe { + callback(to_u8(level), c_string.as_ptr(), *context.get_mut()); + } + }); +} diff --git a/cpp/src/metadata.rs b/cpp/src/metadata.rs new file mode 100644 index 00000000..72c11ad5 --- /dev/null +++ b/cpp/src/metadata.rs @@ -0,0 +1,635 @@ +use delegate::delegate; + +use crate::{ + UnsupportedEnumValueError, VerboseError, + ffi::{MessageType, OptionalMessageContentRef, TagSuggestion}, +}; + +/// # Safety +/// +/// Only implement this on structs that are #[repr(transparent)] and that have a +/// single field. +unsafe trait TransparentWrapper { + type Wrapped; + + fn wrap_ref(value: &Self::Wrapped) -> &Self + where + Self: Sized, + { + let v: *const Self::Wrapped = value; + // SAFETY: Reinterpreting the pointer of a transparent wrapper to the type it wraps is safe. + unsafe { + let v: *const Self = v.cast(); + &*v + } + } + + fn wrap_slice(slice: &[Self::Wrapped]) -> &[Self] + where + Self: Sized, + { + // SAFETY: This is safe because a transparent wrapper is the same in memory as the type it wraps. + unsafe { std::slice::from_raw_parts(slice.as_ptr().cast(), slice.len()) } + } + + fn unwrap_slice(slice: &[Self]) -> &[Self::Wrapped] + where + Self: Sized, + { + // SAFETY: This is safe because a transparent wrapper is the same in memory as the type it wraps. + unsafe { std::slice::from_raw_parts(slice.as_ptr().cast(), slice.len()) } + } +} + +impl From for MessageType { + fn from(value: libloot::metadata::MessageType) -> Self { + match value { + libloot::metadata::MessageType::Say => MessageType::Say, + libloot::metadata::MessageType::Warn => MessageType::Warn, + libloot::metadata::MessageType::Error => MessageType::Error, + } + } +} + +impl TryFrom for libloot::metadata::MessageType { + type Error = UnsupportedEnumValueError; + + fn try_from(value: MessageType) -> Result { + match value { + MessageType::Say => Ok(libloot::metadata::MessageType::Say), + MessageType::Warn => Ok(libloot::metadata::MessageType::Warn), + MessageType::Error => Ok(libloot::metadata::MessageType::Error), + _ => Err(UnsupportedEnumValueError), + } + } +} + +#[derive(Clone, Debug)] +#[repr(transparent)] +pub struct MessageContent(libloot::metadata::MessageContent); + +pub fn new_message_content(text: String, language: &str) -> Box { + let mut content = libloot::metadata::MessageContent::new(text); + + if !language.is_empty() { + content = content.with_language(language.to_owned()); + } + + Box::new(MessageContent(content)) +} + +pub fn message_content_default_language() -> &'static str { + libloot::metadata::MessageContent::DEFAULT_LANGUAGE +} + +impl MessageContent { + pub fn boxed_clone(&self) -> Box { + Box::new(Self(self.0.clone())) + } + + delegate! { + to self.0 { + pub fn text(&self) -> &str; + + pub fn language(&self) -> &str; + } + } +} + +// SAFETY: MessageContent has #[repr(transparent)] +unsafe impl TransparentWrapper for MessageContent { + type Wrapped = libloot::metadata::MessageContent; +} + +impl From for libloot::metadata::MessageContent { + fn from(value: MessageContent) -> Self { + value.0 + } +} + +impl From> for libloot::metadata::MessageContent { + fn from(value: Box) -> Self { + value.0 + } +} + +pub fn select_message_content( + contents: &[MessageContent], + language: &str, +) -> OptionalMessageContentRef { + let option = + libloot::metadata::select_message_content(MessageContent::unwrap_slice(contents), language); + + option.map(MessageContent::wrap_ref).into() +} + +#[derive(Clone, Debug)] +#[repr(transparent)] +pub struct Message(libloot::metadata::Message); + +pub fn new_message( + message_type: MessageType, + content: String, + condition: &str, +) -> Result, VerboseError> { + let mut message = libloot::metadata::Message::new(message_type.try_into()?, content); + + if !condition.is_empty() { + message = message.with_condition(condition.to_owned()); + } + + Ok(Box::new(Message(message))) +} + +pub fn multilingual_message( + message_type: MessageType, + contents: &[Box], + condition: &str, +) -> Result, VerboseError> { + let contents = to_vec_of_unwrapped(contents); + + let mut message = libloot::metadata::Message::multilingual(message_type.try_into()?, contents)?; + + if !condition.is_empty() { + message = message.with_condition(condition.to_owned()); + } + + Ok(Box::new(Message(message))) +} + +impl Message { + pub fn condition(&self) -> &str { + self.0.condition().unwrap_or("") + } + + pub fn content(&self) -> &[MessageContent] { + MessageContent::wrap_slice(self.0.content()) + } + + pub fn boxed_clone(&self) -> Box { + Box::new(Self(self.0.clone())) + } + + delegate! { + to self.0 { + #[into] + pub fn message_type(&self) -> MessageType; + } + } +} + +// SAFETY: Message has #[repr(transparent)] +unsafe impl TransparentWrapper for Message { + type Wrapped = libloot::metadata::Message; +} + +impl From for Message { + fn from(value: libloot::metadata::Message) -> Self { + Self(value) + } +} + +impl From> for libloot::metadata::Message { + fn from(value: Box) -> Self { + value.0 + } +} + +#[derive(Clone, Debug)] +#[repr(transparent)] +pub struct Group(libloot::metadata::Group); + +pub fn new_group(name: String, description: &str, after_groups: Vec) -> Box { + let mut group = libloot::metadata::Group::new(name); + + if !description.is_empty() { + group = group.with_description(description.to_owned()); + } + + if !after_groups.is_empty() { + group = group.with_after_groups(after_groups); + } + + Box::new(Group(group)) +} + +pub fn group_default_name() -> &'static str { + libloot::metadata::Group::DEFAULT_NAME +} + +impl Group { + pub fn description(&self) -> &str { + self.0.description().unwrap_or("") + } + + pub fn boxed_clone(&self) -> Box { + Box::new(Self(self.0.clone())) + } + + delegate! { + to self.0 { + pub fn name(&self) -> &str; + + pub fn after_groups(&self) -> &[String]; + } + } +} + +impl From for Group { + fn from(value: libloot::metadata::Group) -> Self { + Self(value) + } +} + +impl From for libloot::metadata::Group { + fn from(value: Group) -> Self { + value.0 + } +} + +impl From> for libloot::metadata::Group { + fn from(value: Box) -> Self { + value.0 + } +} + +impl From> for Group { + fn from(value: Box) -> Self { + Self((*value).into()) + } +} + +#[derive(Clone, Debug)] +#[repr(transparent)] +pub struct PluginMetadata(libloot::metadata::PluginMetadata); + +pub fn new_plugin_metadata(name: &str) -> Result, VerboseError> { + Ok(Box::new(PluginMetadata( + libloot::metadata::PluginMetadata::new(name)?, + ))) +} + +impl PluginMetadata { + pub fn group(&self) -> &str { + self.0.group().unwrap_or("") + } + + pub fn load_after_files(&self) -> &[File] { + File::wrap_slice(self.0.load_after_files()) + } + + pub fn requirements(&self) -> &[File] { + File::wrap_slice(self.0.requirements()) + } + + pub fn incompatibilities(&self) -> &[File] { + File::wrap_slice(self.0.incompatibilities()) + } + + pub fn messages(&self) -> &[Message] { + Message::wrap_slice(self.0.messages()) + } + + pub fn tags(&self) -> &[Tag] { + Tag::wrap_slice(self.0.tags()) + } + + pub fn dirty_info(&self) -> &[PluginCleaningData] { + PluginCleaningData::wrap_slice(self.0.dirty_info()) + } + + pub fn clean_info(&self) -> &[PluginCleaningData] { + PluginCleaningData::wrap_slice(self.0.clean_info()) + } + + pub fn locations(&self) -> &[Location] { + Location::wrap_slice(self.0.locations()) + } + + pub fn set_load_after_files(&mut self, files: &[Box]) { + self.0.set_load_after_files(to_vec_of_unwrapped(files)); + } + + pub fn set_requirements(&mut self, files: &[Box]) { + self.0.set_requirements(to_vec_of_unwrapped(files)); + } + + pub fn set_incompatibilities(&mut self, files: &[Box]) { + self.0.set_incompatibilities(to_vec_of_unwrapped(files)); + } + + pub fn set_messages(&mut self, messages: &[Box]) { + self.0.set_messages(to_vec_of_unwrapped(messages)); + } + + pub fn set_tags(&mut self, tags: &[Box]) { + self.0.set_tags(to_vec_of_unwrapped(tags)); + } + + pub fn set_dirty_info(&mut self, info: &[Box]) { + self.0.set_dirty_info(to_vec_of_unwrapped(info)); + } + + pub fn set_clean_info(&mut self, info: &[Box]) { + self.0.set_clean_info(to_vec_of_unwrapped(info)); + } + + pub fn set_locations(&mut self, locations: &[Box]) { + self.0.set_locations(to_vec_of_unwrapped(locations)); + } + + pub fn merge_metadata(&mut self, plugin: &PluginMetadata) { + self.0.merge_metadata(&plugin.0); + } + + pub fn boxed_clone(&self) -> Box { + Box::new(Self(self.0.clone())) + } + + delegate! { + to self.0 { + pub fn name(&self) -> &str; + + pub fn set_group(&mut self, group: String); + + pub fn unset_group(&mut self); + + pub fn has_name_only(&self) -> bool; + + pub fn is_regex_plugin(&self) -> bool; + + pub fn name_matches(&self, other_name: &str) -> bool; + + pub fn as_yaml(&self) -> String; + } + } +} + +impl From for PluginMetadata { + fn from(value: libloot::metadata::PluginMetadata) -> Self { + Self(value) + } +} + +impl From> for libloot::metadata::PluginMetadata { + fn from(value: Box) -> Self { + value.0 + } +} + +#[derive(Clone, Debug)] +#[repr(transparent)] +pub struct File(libloot::metadata::File); + +pub fn new_file( + name: String, + display_name: &str, + condition: &str, + detail: &[Box], + constraint: &str, +) -> Result, VerboseError> { + let mut file = libloot::metadata::File::new(name); + + if !display_name.is_empty() { + file = file.with_display_name(display_name.to_owned()); + } + + if !condition.is_empty() { + file = file.with_condition(condition.to_owned()); + } + + if !detail.is_empty() { + file = file.with_detail(to_vec_of_unwrapped(detail))?; + } + + if !constraint.is_empty() { + file = file.with_constraint(constraint.to_owned()); + } + + Ok(Box::new(File(file))) +} + +impl File { + pub fn filename(&self) -> &Filename { + Filename::wrap_ref(self.0.name()) + } + + pub fn display_name(&self) -> &str { + self.0.display_name().unwrap_or("") + } + + pub fn detail(&self) -> &[MessageContent] { + MessageContent::wrap_slice(self.0.detail()) + } + + pub fn condition(&self) -> &str { + self.0.condition().unwrap_or("") + } + + pub fn constraint(&self) -> &str { + self.0.constraint().unwrap_or("") + } + + pub fn boxed_clone(&self) -> Box { + Box::new(Self(self.0.clone())) + } +} + +// SAFETY: File has #[repr(transparent)] +unsafe impl TransparentWrapper for File { + type Wrapped = libloot::metadata::File; +} + +impl From> for libloot::metadata::File { + fn from(value: Box) -> Self { + value.0 + } +} + +#[derive(Clone, Debug, PartialEq, PartialOrd)] +#[repr(transparent)] +pub struct Filename(libloot::metadata::Filename); + +pub fn new_filename(name: String) -> Box { + Box::new(Filename(libloot::metadata::Filename::new(name))) +} + +impl Filename { + pub fn boxed_clone(&self) -> Box { + Box::new(Self(self.0.clone())) + } + + delegate! { + to self.0 { + pub fn as_str(&self) -> &str; + } + } +} + +// SAFETY: Filename has #[repr(transparent)] +unsafe impl TransparentWrapper for Filename { + type Wrapped = libloot::metadata::Filename; +} + +#[derive(Clone, Debug)] +#[repr(transparent)] +pub struct Tag(libloot::metadata::Tag); + +pub fn new_tag( + name: String, + suggestion: TagSuggestion, + condition: &str, +) -> Result, VerboseError> { + let mut tag = libloot::metadata::Tag::new(name, suggestion.try_into()?); + + if !condition.is_empty() { + tag = tag.with_condition(condition.to_owned()); + } + + Ok(Box::new(Tag(tag))) +} + +impl Tag { + pub fn condition(&self) -> &str { + self.0.condition().unwrap_or("") + } + + pub fn boxed_clone(&self) -> Box { + Box::new(Self(self.0.clone())) + } + + delegate! { + to self.0 { + pub fn name(&self) -> &str; + + pub fn is_addition(&self) -> bool; + } + } +} + +// SAFETY: Tag has #[repr(transparent)] +unsafe impl TransparentWrapper for Tag { + type Wrapped = libloot::metadata::Tag; +} + +impl From> for libloot::metadata::Tag { + fn from(value: Box) -> Self { + value.0 + } +} + +impl TryFrom for libloot::metadata::TagSuggestion { + type Error = UnsupportedEnumValueError; + + fn try_from(value: TagSuggestion) -> Result { + match value { + TagSuggestion::Addition => Ok(libloot::metadata::TagSuggestion::Addition), + TagSuggestion::Removal => Ok(libloot::metadata::TagSuggestion::Removal), + _ => Err(UnsupportedEnumValueError), + } + } +} + +#[derive(Clone, Debug)] +#[repr(transparent)] +pub struct PluginCleaningData(libloot::metadata::PluginCleaningData); + +pub fn new_plugin_cleaning_data( + crc: u32, + cleaning_utility: String, + detail: &[Box], + itm_count: u32, + deleted_reference_count: u32, + deleted_navmesh_count: u32, +) -> Result, VerboseError> { + let mut data = libloot::metadata::PluginCleaningData::new(crc, cleaning_utility) + .with_itm_count(itm_count) + .with_deleted_reference_count(deleted_reference_count) + .with_deleted_navmesh_count(deleted_navmesh_count); + + if !detail.is_empty() { + data = data.with_detail(to_vec_of_unwrapped(detail))?; + } + + Ok(Box::new(PluginCleaningData(data))) +} + +impl PluginCleaningData { + pub fn detail(&self) -> &[MessageContent] { + MessageContent::wrap_slice(self.0.detail()) + } + + pub fn boxed_clone(&self) -> Box { + Box::new(Self(self.0.clone())) + } + + delegate! { + to self.0 { + pub fn crc(&self) -> u32; + + pub fn itm_count(&self) -> u32; + + pub fn deleted_reference_count(&self) -> u32; + + pub fn deleted_navmesh_count(&self) -> u32; + + pub fn cleaning_utility(&self) -> &str; + } + } +} + +// SAFETY: PluginCleaningData has #[repr(transparent)] +unsafe impl TransparentWrapper for PluginCleaningData { + type Wrapped = libloot::metadata::PluginCleaningData; +} + +impl From> for libloot::metadata::PluginCleaningData { + fn from(value: Box) -> Self { + value.0 + } +} + +#[derive(Clone, Debug)] +#[repr(transparent)] +pub struct Location(libloot::metadata::Location); + +pub fn new_location(url: String, name: &str) -> Box { + let mut location = libloot::metadata::Location::new(url); + + if !name.is_empty() { + location = location.with_name(name.to_owned()); + } + + Box::new(Location(location)) +} + +impl Location { + pub fn name(&self) -> &str { + self.0.name().unwrap_or("") + } + + pub fn boxed_clone(&self) -> Box { + Box::new(Self(self.0.clone())) + } + + delegate! { + to self.0 { + pub fn url(&self) -> &str; + } + } +} + +// SAFETY: Location has #[repr(transparent)] +unsafe impl TransparentWrapper for Location { + type Wrapped = libloot::metadata::Location; +} + +impl From> for libloot::metadata::Location { + fn from(value: Box) -> Self { + value.0 + } +} + +pub fn to_vec_of_unwrapped>>(slice: &[Box]) -> Vec { + slice.iter().cloned().map(Into::into).collect() +} diff --git a/cpp/src/plugin.rs b/cpp/src/plugin.rs new file mode 100644 index 00000000..e1a351a4 --- /dev/null +++ b/cpp/src/plugin.rs @@ -0,0 +1,79 @@ +use std::sync::Arc; + +use delegate::delegate; + +use crate::{OptionalCrc, VerboseError}; + +#[derive(Debug)] +#[repr(transparent)] +pub struct Plugin(Arc); + +impl Plugin { + pub fn new(plugin: Arc) -> Self { + Self(plugin) + } + + pub fn header_version(&self) -> f32 { + self.0.header_version().unwrap_or(f32::NAN) + } + + pub fn version(&self) -> &str { + self.0.version().unwrap_or("") + } + + pub fn masters(&self) -> Result, VerboseError> { + self.0.masters().map_err(Into::into) + } + + pub fn crc(&self) -> Box { + Box::new(self.0.crc().into()) + } + + pub fn is_valid_as_light_plugin(&self) -> Result { + self.0.is_valid_as_light_plugin().map_err(Into::into) + } + + pub fn is_valid_as_medium_plugin(&self) -> Result { + self.0.is_valid_as_medium_plugin().map_err(Into::into) + } + + pub fn is_valid_as_update_plugin(&self) -> Result { + self.0.is_valid_as_update_plugin().map_err(Into::into) + } + + pub fn do_records_overlap(&self, plugin: &Self) -> Result { + self.0.do_records_overlap(&plugin.0).map_err(Into::into) + } + + pub fn boxed_clone(&self) -> Box { + Box::new(Self(Arc::clone(&self.0))) + } + + delegate! { + to self.0 { + pub fn name(&self) -> &str; + + pub fn bash_tags(&self) -> &[String]; + + pub fn is_master(&self) -> bool; + + pub fn is_light_plugin(&self) -> bool; + + pub fn is_medium_plugin(&self) -> bool; + + pub fn is_update_plugin(&self) -> bool; + + pub fn is_blueprint_plugin(&self) -> bool; + + pub fn is_empty(&self) -> bool; + + pub fn loads_archive(&self) -> bool; + } + } +} + +impl From> for Plugin { + fn from(value: Arc) -> Self { + Plugin(value) + } +} diff --git a/src/tests/api/interface/api_game_operations_test.h b/cpp/src/tests/api/interface/api_game_operations_test.h similarity index 100% rename from src/tests/api/interface/api_game_operations_test.h rename to cpp/src/tests/api/interface/api_game_operations_test.h diff --git a/src/tests/api/interface/create_game_handle_test.h b/cpp/src/tests/api/interface/create_game_handle_test.h similarity index 98% rename from src/tests/api/interface/create_game_handle_test.h rename to cpp/src/tests/api/interface/create_game_handle_test.h index b6466994..c92dd4dc 100644 --- a/src/tests/api/interface/create_game_handle_test.h +++ b/cpp/src/tests/api/interface/create_game_handle_test.h @@ -200,7 +200,7 @@ TEST_P( const auto expectedSuffix = std::filesystem::u8path("Documents") / "My Games" / "Starfield" / "Data"; - EXPECT_TRUE(boost::ends_with(game->GetAdditionalDataPaths()[0].u8string(), + EXPECT_TRUE(endsWith(game->GetAdditionalDataPaths()[0].u8string(), expectedSuffix.u8string())); } else if (GetParam() == GameType::openmw) { EXPECT_EQ(std::vector{localPath / "data"}, diff --git a/src/tests/api/interface/database_interface_test.h b/cpp/src/tests/api/interface/database_interface_test.h similarity index 95% rename from src/tests/api/interface/database_interface_test.h rename to cpp/src/tests/api/interface/database_interface_test.h index 7e7a1421..22a1d950 100644 --- a/src/tests/api/interface/database_interface_test.h +++ b/cpp/src/tests/api/interface/database_interface_test.h @@ -792,13 +792,45 @@ TEST_P(DatabaseInterfaceTest, TEST_P(DatabaseInterfaceTest, writeMinimalListShouldWriteOnlyBashTagsAndDirtyInfo) { + using std::endl; + ASSERT_NO_THROW(GenerateMasterlist()); ASSERT_NO_THROW(handle_->GetDatabase().LoadMasterlist(masterlistPath)); EXPECT_NO_THROW( handle_->GetDatabase().WriteMinimalList(minimalOutputPath_, true)); - EXPECT_EQ(GetExpectedMinimalContent(), GetFileContent(minimalOutputPath_)); + const auto content = GetFileContent(minimalOutputPath_); + + // Plugin entries are unordered. + std::stringstream expectedContent; + if (content.find(blankDifferentEsm) < content.find(blankEsm)) { + expectedContent << "plugins:" << endl + << " - name: '" << blankDifferentEsm << "'" << endl + << " dirty:" << endl + << " - crc: 0x7D22F9DF" << endl + << " util: 'TES4Edit'" << endl + << " udr: 4" << endl + << " - name: '" << blankEsm << "'" << endl + << " tag:" << endl + << " - Actors.ACBS" << endl + << " - Actors.AIData" << endl + << " - -C.Water"; + } else { + expectedContent << "plugins:" << endl + << " - name: '" << blankEsm << "'" << endl + << " tag:" << endl + << " - Actors.ACBS" << endl + << " - Actors.AIData" << endl + << " - -C.Water" << endl + << " - name: '" << blankDifferentEsm << "'" << endl + << " dirty:" << endl + << " - crc: 0x7D22F9DF" << endl + << " util: 'TES4Edit'" << endl + << " udr: 4"; + } + + EXPECT_EQ(expectedContent.str(), content); } } } diff --git a/src/tests/api/interface/game_interface_test.h b/cpp/src/tests/api/interface/game_interface_test.h similarity index 99% rename from src/tests/api/interface/game_interface_test.h rename to cpp/src/tests/api/interface/game_interface_test.h index a077fe37..9682deda 100644 --- a/src/tests/api/interface/game_interface_test.h +++ b/cpp/src/tests/api/interface/game_interface_test.h @@ -309,12 +309,11 @@ TEST_P(GameInterfaceTest, TEST_P(GameInterfaceTest, loadPluginsShouldNotClearThePluginsCache) { handle_->LoadPlugins({std::filesystem::u8path(blankEsm)}, true); - const auto pointer = handle_->GetPlugin(blankEsm); - ASSERT_NE(nullptr, pointer); + ASSERT_EQ(1, handle_->GetLoadedPlugins().size()); handle_->LoadPlugins({std::filesystem::u8path(blankEsp)}, true); - EXPECT_EQ(pointer, handle_->GetPlugin(blankEsm)); + EXPECT_EQ(2, handle_->GetLoadedPlugins().size()); } TEST_P(GameInterfaceTest, diff --git a/src/tests/api/interface/is_compatible_test.h b/cpp/src/tests/api/interface/is_compatible_test.h similarity index 100% rename from src/tests/api/interface/is_compatible_test.h rename to cpp/src/tests/api/interface/is_compatible_test.h diff --git a/src/tests/api/interface/main.cpp b/cpp/src/tests/api/interface/main.cpp similarity index 93% rename from src/tests/api/interface/main.cpp rename to cpp/src/tests/api/interface/main.cpp index 700c5af2..f278e612 100644 --- a/src/tests/api/interface/main.cpp +++ b/cpp/src/tests/api/interface/main.cpp @@ -83,7 +83,7 @@ TEST(SetLoggingCallback, shouldAcceptAMemberFunction) { } catch (...) { EXPECT_EQ( "Attempting to create a game handle for game type \"The Elder Scrolls " - "IV: Oblivion\" with game path \"dummy\" and game local path \"\"", + "IV: Oblivion\" with game path \"dummy\"", testLogger.loggedMessages); SetLoggingCallback([](LogLevel, std::string_view) {}); @@ -103,7 +103,7 @@ TEST(SetLoggingCallback, shouldAcceptALambdaFunction) { } catch (...) { EXPECT_EQ( "Attempting to create a game handle for game type \"The Elder Scrolls " - "IV: Oblivion\" with game path \"dummy\" and game local path \"\"", + "IV: Oblivion\" with game path \"dummy\"", loggedMessages); SetLoggingCallback([](LogLevel, std::string_view) {}); @@ -125,7 +125,7 @@ TEST(SetLoggingCallback, } catch (...) { EXPECT_EQ( "Attempting to create a game handle for game type \"The Elder Scrolls " - "IV: Oblivion\" with game path \"dummy\" and game local path \"\"", + "IV: Oblivion\" with game path \"dummy\"", loggedMessages); SetLoggingCallback([](LogLevel, std::string_view) {}); @@ -157,7 +157,7 @@ TEST(SetLogLevel, shouldOnlyRunTheCallbackForMessagesAtOrAboveTheGivenLevel) { EXPECT_EQ(LogLevel::info, loggedMessages[0].first); EXPECT_EQ( "Attempting to create a game handle for game type \"The Elder Scrolls " - "IV: Oblivion\" with game path \"dummy\" and game local path \"\"", + "IV: Oblivion\" with game path \"dummy\"", loggedMessages[0].second); SetLoggingCallback([](LogLevel, std::string_view) {}); diff --git a/src/tests/api/interface/metadata/file_test.h b/cpp/src/tests/api/interface/metadata/file_test.h similarity index 100% rename from src/tests/api/interface/metadata/file_test.h rename to cpp/src/tests/api/interface/metadata/file_test.h diff --git a/src/tests/api/interface/metadata/group_test.h b/cpp/src/tests/api/interface/metadata/group_test.h similarity index 100% rename from src/tests/api/interface/metadata/group_test.h rename to cpp/src/tests/api/interface/metadata/group_test.h diff --git a/src/tests/api/interface/metadata/location_test.h b/cpp/src/tests/api/interface/metadata/location_test.h similarity index 100% rename from src/tests/api/interface/metadata/location_test.h rename to cpp/src/tests/api/interface/metadata/location_test.h diff --git a/src/tests/api/interface/metadata/message_content_test.h b/cpp/src/tests/api/interface/metadata/message_content_test.h similarity index 100% rename from src/tests/api/interface/metadata/message_content_test.h rename to cpp/src/tests/api/interface/metadata/message_content_test.h diff --git a/src/tests/api/interface/metadata/message_test.h b/cpp/src/tests/api/interface/metadata/message_test.h similarity index 100% rename from src/tests/api/interface/metadata/message_test.h rename to cpp/src/tests/api/interface/metadata/message_test.h diff --git a/src/tests/api/interface/metadata/plugin_cleaning_data_test.h b/cpp/src/tests/api/interface/metadata/plugin_cleaning_data_test.h similarity index 100% rename from src/tests/api/interface/metadata/plugin_cleaning_data_test.h rename to cpp/src/tests/api/interface/metadata/plugin_cleaning_data_test.h diff --git a/src/tests/api/interface/metadata/plugin_metadata_test.h b/cpp/src/tests/api/interface/metadata/plugin_metadata_test.h similarity index 98% rename from src/tests/api/interface/metadata/plugin_metadata_test.h rename to cpp/src/tests/api/interface/metadata/plugin_metadata_test.h index 54f7dfd7..fd4a97fe 100644 --- a/src/tests/api/interface/metadata/plugin_metadata_test.h +++ b/cpp/src/tests/api/interface/metadata/plugin_metadata_test.h @@ -62,7 +62,7 @@ TEST_F(PluginMetadataTest, nameMatchesShouldUseCaseInsensitiveNameComparisonForNonRegexNames) { PluginMetadata plugin(blankEsm); - EXPECT_TRUE(plugin.NameMatches(boost::to_lower_copy(blankEsm))); + EXPECT_TRUE(plugin.NameMatches("blank.esm")); EXPECT_FALSE(plugin.NameMatches(blankDifferentEsm)); } @@ -78,7 +78,7 @@ TEST_F(PluginMetadataTest, nameMatchesShouldUseCaseInsensitiveRegexMatchingForARegexName) { PluginMetadata plugin("Blan.\\.esm"); - EXPECT_TRUE(plugin.NameMatches(boost::to_lower_copy(blankEsm))); + EXPECT_TRUE(plugin.NameMatches("blank.esm")); EXPECT_FALSE(plugin.NameMatches(blankDifferentEsm)); } diff --git a/src/tests/api/interface/metadata/tag_test.h b/cpp/src/tests/api/interface/metadata/tag_test.h similarity index 100% rename from src/tests/api/interface/metadata/tag_test.h rename to cpp/src/tests/api/interface/metadata/tag_test.h diff --git a/src/tests/api/interface/plugin_interface_test.h b/cpp/src/tests/api/interface/plugin_interface_test.h similarity index 100% rename from src/tests/api/interface/plugin_interface_test.h rename to cpp/src/tests/api/interface/plugin_interface_test.h diff --git a/cpp/src/tests/api/internals/main.cpp b/cpp/src/tests/api/internals/main.cpp new file mode 100644 index 00000000..ed295dc9 --- /dev/null +++ b/cpp/src/tests/api/internals/main.cpp @@ -0,0 +1,99 @@ +/* LOOT + + A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and + Fallout: New Vegas. + + Copyright (C) 2014-2016 WrinklyNinja + + This file is part of LOOT. + + LOOT is free software: you can redistribute + it and/or modify it under the terms of the GNU General Public License + as published by the Free Software Foundation, either version 3 of + the License, or (at your option) any later version. + + LOOT is distributed in the hope that it will + be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with LOOT. If not, see + . + */ + +#include + +#include + +#ifdef _WIN32 +TEST(Filesystem, + pathStringConstructorDoesNotConvertCharacterEncodingFromUtf8ToNative) { + std::string utf8 = u8"Andr\u00E9_settings.toml"; + std::u16string utf16 = u"Andr\u00E9_settings.toml"; + + ASSERT_EQ('\xc3', utf8[4]); + ASSERT_EQ('\xa9', utf8[5]); + + std::filesystem::path path(utf8); + + EXPECT_EQ(utf8, path.string()); + EXPECT_NE(utf8, path.u8string()); + EXPECT_NE(utf16, path.u16string()); +} + +TEST( + Filesystem, + pathStringAndLocaleConstructorDoesNotConvertCharacterEncodingFromUtf8WithClassicLocale) { + std::string utf8 = u8"Andr\u00E9_settings.toml"; + std::u16string utf16 = u"Andr\u00E9_settings.toml"; + + ASSERT_EQ('\xc3', utf8[4]); + ASSERT_EQ('\xa9', utf8[5]); + + std::filesystem::path path(utf8, std::locale::classic()); + + EXPECT_EQ(utf8, path.string()); + + EXPECT_NE(utf8, path.u8string()); + EXPECT_NE(utf16, path.u16string()); +} +#else +TEST(Filesystem, pathStringConstructorUsesNativeEncodingOfUtf8) { + std::string utf8 = u8"Andr\u00E9_settings.toml"; + std::u16string utf16 = u"Andr\u00E9_settings.toml"; + + ASSERT_EQ('\xc3', utf8[4]); + ASSERT_EQ('\xa9', utf8[5]); + + std::filesystem::path path(utf8); + + EXPECT_EQ(utf8, path.string()); + EXPECT_EQ(utf8, path.u8string()); + EXPECT_EQ(utf16, path.u16string()); +} +#endif + +TEST(Filesystem, u8pathConvertsCharacterEncodingFromUtf8ToNative) { + std::string utf8 = u8"Andr\u00E9_settings.toml"; + std::u16string utf16 = u"Andr\u00E9_settings.toml"; + + ASSERT_EQ('\xc3', utf8[4]); + ASSERT_EQ('\xa9', utf8[5]); + + std::filesystem::path path = std::filesystem::u8path(utf8); + +#ifdef _WIN32 + EXPECT_NE(utf8, path.string()); +#else + EXPECT_EQ(utf8, path.string()); +#endif + + EXPECT_EQ(utf8, path.u8string()); + EXPECT_EQ(utf16, path.u16string()); +} + +int main(int argc, char **argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/src/tests/common_game_test_fixture.h b/cpp/src/tests/common_game_test_fixture.h similarity index 97% rename from src/tests/common_game_test_fixture.h rename to cpp/src/tests/common_game_test_fixture.h index 5dbdc35e..dc47b44f 100644 --- a/src/tests/common_game_test_fixture.h +++ b/cpp/src/tests/common_game_test_fixture.h @@ -28,7 +28,6 @@ along with LOOT. If not, see #include #include -#include #include #include #include @@ -230,10 +229,9 @@ protected: std::string filename = it->path().filename().u8string(); if (filename == nonPluginFile) continue; - if (boost::ends_with(filename, ".ghost")) + if (endsWith(filename, ".ghost")) filename = it->path().stem().u8string(); - if (boost::ends_with(filename, ".esp") || - boost::ends_with(filename, ".esm")) + if (endsWith(filename, ".esp") || endsWith(filename, ".esm")) loadOrder.emplace(std::filesystem::last_write_time(it->path()), filename); } @@ -386,6 +384,17 @@ protected: WriteFile(path, bytes); } + static bool endsWith(const std::string& str, const std::string& suffix) { + if (str.length() < suffix.length()) { + return false; + } + + auto view = std::string_view(str); + view.remove_prefix(str.length() - suffix.length()); + + return view == suffix; + } + private: GameType gameType_; const std::filesystem::path rootTestPath; diff --git a/src/tests/printers.h b/cpp/src/tests/printers.h similarity index 100% rename from src/tests/printers.h rename to cpp/src/tests/printers.h diff --git a/src/tests/test_helpers.h b/cpp/src/tests/test_helpers.h similarity index 100% rename from src/tests/test_helpers.h rename to cpp/src/tests/test_helpers.h diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..18e927a8 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,14 @@ +# libloot Documentation + +This directory contains documentation for libloot and LOOT's metadata syntax. It does not include Rust API reference documentation: that is generated using `cargo doc`. + +To build the documentation, install Python and [uv](https://docs.astral.sh/uv/getting-started/installation/) and make sure they're accessible from your `PATH`, then run: + +``` +cd ../docs +uv run -- sphinx-build -b html . build/html +``` + +If [Doxygen](https://www.doxygen.nl/) is also installed and accessible from your `PATH`, the C++ API's reference documentation will also be included. + +The documentation files for dependency licenses and copyright notices are auto-generated using [cargo-attribution](https://github.com/ameknite/cargo-attribution): to regenerate them run `py scripts/licenses.py`. The script is hardcoded to generate files for the C++ wrapper's dependencies, but in practice that doesn't add any content over doing the same for just the core libloot Rust library. diff --git a/docs/api/changelog.rst b/docs/api/changelog.rst index 1b878f8b..1b7496bc 100644 --- a/docs/api/changelog.rst +++ b/docs/api/changelog.rst @@ -2,6 +2,27 @@ Version History *************** +0.28.0 - Unreleased +=================== + +Changed +------- + +- :cpp:any:`loot::DatabaseInterface::LoadMasterlist()`, + :cpp:any:`loot::DatabaseInterface::LoadMasterlistWithPrelude()` and + :cpp:any:`loot::DatabaseInterface::LoadUserlist()` now throw + ``std::runtime_error`` exceptions instead of ``YAML::RepresentationException`` + (which was a grandchild of ``std::runtime_error``) when there is a error + parsing metadata YAML. +- When :cpp:any:`loot::DatabaseInterface::WriteUserMetadata()` or + :cpp:any:`loot::DatabaseInterface::WriteMinimalList()` are called with an + output path that has a parent path that does not exist, they now throw a + ``std::runtime_error`` instead of a ``std::invalid_argument``. +- When :cpp:any:`loot::DatabaseInterface::GetGroupsPath()` is given a group name + that is undefined, it now throws a :cpp:any:`loot::UndefinedGroupError` + instead of a `std::invalid_argument`. +- Many exception messages have changed. + 0.27.0 - 2025-06-08 =================== diff --git a/docs/api/reference.rst b/docs/api/cpp_api_reference.rst similarity index 96% rename from docs/api/reference.rst rename to docs/api/cpp_api_reference.rst index 145714a0..de3585b3 100644 --- a/docs/api/reference.rst +++ b/docs/api/cpp_api_reference.rst @@ -1,8 +1,9 @@ -************* -API Reference -************* +***************** +C++ API Reference +***************** .. contents:: + :local: String Encoding =============== diff --git a/docs/api/licenses/Apache-2.0 b/docs/api/licenses/Apache-2.0 new file mode 100644 index 00000000..137069b8 --- /dev/null +++ b/docs/api/licenses/Apache-2.0 @@ -0,0 +1,73 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/docs/api/licenses/BSD-3-Clause b/docs/api/licenses/BSD-3-Clause new file mode 100644 index 00000000..ea890afb --- /dev/null +++ b/docs/api/licenses/BSD-3-Clause @@ -0,0 +1,11 @@ +Copyright (c) . + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/api/licenses/Boost Software License v1.0.txt b/docs/api/licenses/Boost Software License v1.0.txt deleted file mode 100644 index 36b7cd93..00000000 --- a/docs/api/licenses/Boost Software License v1.0.txt +++ /dev/null @@ -1,23 +0,0 @@ -Boost Software License - Version 1.0 - August 17th, 2003 - -Permission is hereby granted, free of charge, to any person or organization -obtaining a copy of the software and accompanying documentation covered by -this license (the "Software") to use, reproduce, display, distribute, -execute, and transmit the Software, and to prepare derivative works of the -Software, and to permit third-parties to whom the Software is furnished to -do so, all subject to the following: - -The copyright notices in the Software and this entire statement, including -the above license grant, this restriction and the following disclaimer, -must be included in all copies of the Software, in whole or in part, and -all derivative works of the Software, unless such copies or derivative -works are solely in the form of machine-executable object code generated by -a source language processor. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT -SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE -FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE, -ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. diff --git a/docs/api/licenses/GNU FDL v1.3.txt b/docs/api/licenses/GFDL-1.3-no-invariants-or-later similarity index 100% rename from docs/api/licenses/GNU FDL v1.3.txt rename to docs/api/licenses/GFDL-1.3-no-invariants-or-later diff --git a/docs/api/licenses/GNU GPL v3.txt b/docs/api/licenses/GPL-3.0-or-later similarity index 100% rename from docs/api/licenses/GNU GPL v3.txt rename to docs/api/licenses/GPL-3.0-or-later diff --git a/docs/api/licenses/MIT b/docs/api/licenses/MIT new file mode 100644 index 00000000..d817195d --- /dev/null +++ b/docs/api/licenses/MIT @@ -0,0 +1,18 @@ +MIT License + +Copyright (c) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/docs/api/licenses/MIT License (spdlog).txt b/docs/api/licenses/MIT License (spdlog).txt deleted file mode 100644 index 5f83436d..00000000 --- a/docs/api/licenses/MIT License (spdlog).txt +++ /dev/null @@ -1,21 +0,0 @@ -The MIT License (MIT) - -Copyright (c) 2016 Gabi Melman. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/docs/api/licenses/MIT License (yaml-cpp).txt b/docs/api/licenses/MIT License (yaml-cpp).txt deleted file mode 100644 index 5bd9e1a1..00000000 --- a/docs/api/licenses/MIT License (yaml-cpp).txt +++ /dev/null @@ -1,19 +0,0 @@ -Copyright (c) 2008 Jesse Beder. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. diff --git a/docs/api/licenses/Unicode-3.0 b/docs/api/licenses/Unicode-3.0 new file mode 100644 index 00000000..11f2842a --- /dev/null +++ b/docs/api/licenses/Unicode-3.0 @@ -0,0 +1,39 @@ +UNICODE LICENSE V3 + +COPYRIGHT AND PERMISSION NOTICE + +Copyright © 1991-2023 Unicode, Inc. + +NOTICE TO USER: Carefully read the following legal agreement. BY +DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR +SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE +TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT +DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of data files and any associated documentation (the "Data Files") or +software and any associated documentation (the "Software") to deal in the +Data Files or Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, and/or sell +copies of the Data Files or Software, and to permit persons to whom the +Data Files or Software are furnished to do so, provided that either (a) +this copyright and permission notice appear with all copies of the Data +Files or Software, or (b) this copyright and permission notice appear in +associated Documentation. + +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +THIRD PARTY RIGHTS. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE +BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, +OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA +FILES OR SOFTWARE. + +Except as contained in this notice, the name of a copyright holder shall +not be used in advertising or otherwise to promote the sale, use or other +dealings in these Data Files or Software without prior written +authorization of the copyright holder. diff --git a/docs/api/licenses/dependency-notices.rst b/docs/api/licenses/dependency-notices.rst new file mode 100644 index 00000000..0c640f42 --- /dev/null +++ b/docs/api/licenses/dependency-notices.rst @@ -0,0 +1,529 @@ +.. This file was generated by scripts/licenses.py at 2025-05-08T22:38:22.221410. + +Dependency Copyright Notices +============================ + +`arraydeque `_ +--------------------------------------------------------- + +:: + + Copyright (c) 2018 Andy Lok + +`autocfg `_ +----------------------------------------------- + +:: + + Copyright (c) 2018 Josh Stone + +`bit-set `_ +-------------------------------------------------- + +:: + + Copyright (c) 2023 The Rust Project Developers + +`bit-vec `_ +-------------------------------------------------- + +:: + + Copyright (c) 2023 The Rust Project Developers + +`block-buffer `_ +----------------------------------------------------- + +:: + + Copyright (c) 2018-2019 The RustCrypto Project Developers + +`cc `_ +------------------------------------------ + +:: + + Copyright (c) 2014 Alex Crichton + +`cfg-if `_ +-------------------------------------------------- + +:: + + Copyright (c) 2014 Alex Crichton + +`const-random `_ +----------------------------------------------------------- + +:: + + Copyright (c) 2016 Amanieu d'Antras + +`const-random-macro `_ +----------------------------------------------------------------- + +:: + + Copyright (c) 2016 Amanieu d'Antras + +`cpufeatures `_ +---------------------------------------------------- + +:: + + Copyright (c) 2020-2025 The RustCrypto Project Developers + +`crc32fast `_ +------------------------------------------------------ + +:: + + Copyright (c) 2018 Sam Rijs, Alex Crichton and contributors + +`crossbeam-deque `_ +-------------------------------------------------------------- + +:: + + Copyright (c) 2019 The Crossbeam Project Developers + +`crossbeam-epoch `_ +-------------------------------------------------------------- + +:: + + Copyright (c) 2019 The Crossbeam Project Developers + +`crossbeam-utils `_ +-------------------------------------------------------------- + +:: + + Copyright (c) 2019 The Crossbeam Project Developers + +`crunchy `_ +----------------------------------------------------- + +:: + + Copyright 2017-2023 Eira Fransham. + +`crypto-common `_ +------------------------------------------------------- + +:: + + Copyright (c) 2021 RustCrypto Developers + +`dataview `_ +------------------------------------------------- + +:: + + Copyright (c) 2020 Casper + +`derive_pod `_ +--------------------------------------------------- + +:: + + Copyright (c) 2020 Casper + +`digest `_ +------------------------------------------------ + +:: + + Copyright (c) 2017 Artyom Pavlov + +`dirs `_ +---------------------------------------- + +:: + + Copyright (c) 2018-2019 dirs-rs contributors + +`dirs-sys `_ +----------------------------------------------------- + +:: + + Copyright (c) 2018-2019 dirs-rs contributors + +`dlv-list `_ +------------------------------------------------------ + +:: + + Copyright (c) 2022 Scott Godwin + +`either `_ +---------------------------------------------- + +:: + + Copyright (c) 2015 + +`encoding_rs `_ +-------------------------------------------------------- + +:: + + Copyright © WHATWG (Apple, Google, Mozilla, Microsoft). + +`equivalent `_ +--------------------------------------------------------- + +:: + + Copyright (c) 2016--2023 + +`fancy-regex `_ +----------------------------------------------------------- + +:: + + Copyright 2015 The Fancy Regex Authors. + +`fixedbitset `_ +-------------------------------------------------------- + +:: + + Copyright (c) 2015-2017 + +`generic-array `_ +--------------------------------------------------------------- + +:: + + Copyright (c) 2015 Bartłomiej Kamiński + +`getrandom `_ +------------------------------------------------------- + +:: + + Copyright (c) 2014 The Rust Project Developers + Copyright (c) 2018-2024 The rust-random Project Developers + +`getrandom `_ +------------------------------------------------------- + +:: + + Copyright (c) 2014 The Rust Project Developers + Copyright (c) 2018-2025 The rust-random Project Developers + +`hashbrown `_ +----------------------------------------------------- + +:: + + Copyright (c) 2016 Amanieu d'Antras + +`hashbrown `_ +----------------------------------------------------- + +:: + + Copyright (c) 2016 Amanieu d'Antras + +`hashlink `_ +----------------------------------------------- + +:: + + Copyright (c) 2015 The Rust Project Developers + +`indexmap `_ +----------------------------------------------------- + +:: + + Copyright (c) 2016--2017 + +`libc `_ +------------------------------------------- + +:: + + Copyright (c) 2014-2020 The Rust Project Developers + +`log `_ +----------------------------------------- + +:: + + Copyright (c) 2014 The Rust Project Developers + +`loot-condition-interpreter `_ +-------------------------------------------------------------------------- + +:: + + Copyright (c) 2018 Oliver Hamlet + +`no-std-compat `_ +------------------------------------------------------------ + +:: + + Copyright (c) 2019 jD91mZM2 + +`nom `_ +------------------------------------------- + +:: + + Copyright (c) 2014-2019 Geoffroy Couprie + +`num-traits `_ +------------------------------------------------------ + +:: + + Copyright (c) 2014 The Rust Project Developers + +`ordered-float `_ +------------------------------------------------------------- + +:: + + Copyright (c) 2015 Jonathan Reem + +`ordered-multimap `_ +---------------------------------------------------------------------- + +:: + + Copyright (c) 2018 sgodwincs + +`pelite `_ +--------------------------------------------- + +:: + + Copyright (c) 2016-2018 Casper + +`petgraph `_ +-------------------------------------------------- + +:: + + Copyright (c) 2015 + +`rayon `_ +-------------------------------------------- + +:: + + Copyright (c) 2010 The Rust Project Developers + +`rayon-core `_ +------------------------------------------------- + +:: + + Copyright (c) 2010 The Rust Project Developers + +`regex `_ +--------------------------------------------- + +:: + + Copyright (c) 2014 The Rust Project Developers + +`regex-automata `_ +--------------------------------------------------------------------------------- + +:: + + Copyright (c) 2014 The Rust Project Developers + +`regex-syntax `_ +----------------------------------------------------------------------------- + +:: + + Copyright (c) 2014 The Rust Project Developers + +`rust-ini `_ +-------------------------------------------------- + +:: + + Copyright (c) 2014 Y. T. CHUNG + +`sha2 `_ +---------------------------------------------- + +:: + + Copyright (c) 2006-2009 Graydon Hoare + Copyright (c) 2009-2013 Mozilla Foundation + Copyright (c) 2016 Artyom Pavlov + +`shlex `_ +---------------------------------------------- + +:: + + Copyright (c) 2015 Nicholas Allegra (comex). + Copyright 2015 Nicholas Allegra (comex). + +`tempfile `_ +--------------------------------------------------- + +:: + + Copyright (c) 2015 Steven Allen + +`trim-in-place `_ +------------------------------------------------------------ + +:: + + Copyright (c) 2020 magiclen.org (Ron Li) + +`typenum `_ +---------------------------------------------- + +:: + + Copyright (c) 2014 Paho Lurie-Gregg + Copyright 2014 Paho Lurie-Gregg + +`ucd-trie `_ +-------------------------------------------------------- + +:: + + Copyright (c) 2015 Andrew Gallant + +`unicase `_ +--------------------------------------------------- + +:: + + Copyright (c) 2014-2017 Sean McArthur + +`unicode-ident `_ +----------------------------------------------------------- + +:: + + Copyright © 1991-2023 Unicode, Inc. + +`unicode-width `_ +-------------------------------------------------------------- + +:: + + Copyright (c) 2015 The Rust Project Developers + +`version_check `_ +----------------------------------------------------------------- + +:: + + Copyright (c) 2017-2018 Sergio Benitez + +`winapi `_ +------------------------------------------------- + +:: + + Copyright (c) 2015-2018 The winapi-rs Developers + +`windows `_ +---------------------------------------------------- + +:: + + Copyright (c) Microsoft Corporation. + +`windows-collections `_ +---------------------------------------------------------------- + +:: + + Copyright (c) Microsoft Corporation. + +`windows-core `_ +--------------------------------------------------------- + +:: + + Copyright (c) Microsoft Corporation. + +`windows-future `_ +----------------------------------------------------------- + +:: + + Copyright (c) Microsoft Corporation. + +`windows-implement `_ +-------------------------------------------------------------- + +:: + + Copyright (c) Microsoft Corporation. + +`windows-interface `_ +-------------------------------------------------------------- + +:: + + Copyright (c) Microsoft Corporation. + +`windows-link `_ +--------------------------------------------------------- + +:: + + Copyright (c) Microsoft Corporation. + +`windows-numerics `_ +------------------------------------------------------------- + +:: + + Copyright (c) Microsoft Corporation. + +`windows-result `_ +----------------------------------------------------------- + +:: + + Copyright (c) Microsoft Corporation. + +`windows-strings `_ +------------------------------------------------------------ + +:: + + Copyright (c) Microsoft Corporation. + +`windows-sys `_ +-------------------------------------------------------- + +:: + + Copyright (c) Microsoft Corporation. + +`windows-targets `_ +------------------------------------------------------------ + +:: + + Copyright (c) Microsoft Corporation. + +`windows_x86_64_msvc `_ +---------------------------------------------------------------- + +:: + + Copyright (c) Microsoft Corporation. + diff --git a/docs/api/licenses/index.rst b/docs/api/licenses/index.rst index b785590d..2e1636ac 100644 --- a/docs/api/licenses/index.rst +++ b/docs/api/licenses/index.rst @@ -6,4 +6,5 @@ Copyright Notices :maxdepth: 1 libloot-notices + dependency-notices texts diff --git a/docs/api/licenses/texts.rst b/docs/api/licenses/texts.rst index a94a7f11..5c1943c9 100644 --- a/docs/api/licenses/texts.rst +++ b/docs/api/licenses/texts.rst @@ -1,44 +1,41 @@ -*********************** Copyright License Texts -*********************** +======================= .. contents:: + :local: -`Boost`_ -======== +GNU General Public License 3.0 +------------------------------ -.. _Boost: http://www.boost.org/ - -.. include:: Boost Software License v1.0.txt +.. include:: GPL-3.0-or-later :literal: -libloot, `esplugin`_ & `Libloadorder`_ -====================================== +GNU Free Documentation License 1.3 +---------------------------------- -.. _esplugin: https://github.com/Ortham/esplugin -.. _libloadorder: https://github.com/Ortham/libloadorder - -.. include:: GNU GPL v3.txt +.. include:: GFDL-1.3-no-invariants-or-later :literal: -libloot Documentation -====================== +Apache License 2.0 +------------------ -.. include:: GNU FDL v1.3.txt +.. include:: Apache-2.0 :literal: -`spdlog`_ -============ +BSD 3-Clause "New" or "Revised" License +--------------------------------------- -.. _spdlog: https://github.com/gabime/spdlog - -.. include:: MIT License (spdlog).txt +.. include:: Apache-2.0 :literal: -`yaml-cpp`_ -=========== +MIT License +----------- -.. _yaml-cpp: https://github.com/loot/yaml-cpp +.. include:: MIT + :literal: -.. include:: MIT License (yaml-cpp).txt +Unicode License v3 +------------------ + +.. include:: Unicode-3.0 :literal: diff --git a/docs/api/rust_api_reference.rst b/docs/api/rust_api_reference.rst new file mode 100644 index 00000000..45412b8f --- /dev/null +++ b/docs/api/rust_api_reference.rst @@ -0,0 +1,6 @@ +****************** +Rust API Reference +****************** + +To view the Rust API reference, run ``cargo doc --open`` from the root directory +of a copy of the libloot source code repository. diff --git a/docs/conf.py b/docs/conf.py index 00c0e554..e8df0dfe 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -20,13 +20,7 @@ # import sys # sys.path.insert(0, os.path.abspath('.')) -import subprocess, os - -output_directory = os.path.join('..', 'build', 'docs') -if not os.path.exists(output_directory): - os.makedirs(output_directory) - -subprocess.call(['doxygen', 'docs/api/Doxyfile'], cwd='..') +import shutil, subprocess, os # -- General configuration ------------------------------------------------ @@ -348,9 +342,23 @@ texinfo_documents = [ # # texinfo_no_detailmenu = False +found_doxygen = shutil.which('doxygen') -breathe_projects = { -"loot":"../build/docs/xml/", -} +if found_doxygen: + doxygen_output_directory = os.path.join('..', 'cpp', 'build', 'docs') + if not os.path.exists(doxygen_output_directory): + os.makedirs(doxygen_output_directory) -breathe_default_project = 'loot' + subprocess.call(['doxygen', 'Doxyfile'], cwd='../cpp') + + extensions.append('breathe') + + breathe_projects = { + 'loot':'../cpp/build/docs/xml/', + } + + breathe_default_project = 'loot' +else: + # This causes Sphinx to log a warning, but it's unavoidable without + # modifying index.rst at runtime. + exclude_patterns.append('api/cpp_api_reference.rst') diff --git a/docs/index.rst b/docs/index.rst index b997994a..a7829316 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -8,7 +8,8 @@ libloot api/introduction api/sorting - api/reference + api/rust_api_reference + api/cpp_api_reference api/licenses/index api/changelog diff --git a/docs/pyproject.toml b/docs/pyproject.toml index af4acfa1..4699056d 100644 --- a/docs/pyproject.toml +++ b/docs/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "libloot-docs" -version = "0.26.1" +version = "0.27.0" requires-python = ">=3.11" dependencies = [ "breathe==4.36.0", diff --git a/docs/scripts/licenses.py b/docs/scripts/licenses.py new file mode 100644 index 00000000..35c247d4 --- /dev/null +++ b/docs/scripts/licenses.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# This script expects cargo-attribution +# to be installed. + +import argparse +import datetime +import json +import os +import re +import shutil +import subprocess +import tomllib + +def process_licenses(attribution_dir, destination_dir): + # CC0, Unlicense and Zlib don't need licenses to be included. + # MPL doesn't need the license included with binary distributions, and + # allows distribution under a compatible license. + # cargo attribution's GPL-3.0 download doesn't contain the license text. + # The ISC license is skipped because its dependency gets filtered out, same + # with the exceptions. + skip_entries = [ + 'exceptions', + 'CC0-1.0', + 'GPL-3.0', + 'ISC', + 'MPL-2.0', + 'Unlicense', + 'Zlib' + ] + + for entry in os.scandir(os.path.join(attribution_dir, 'licenses')): + if entry.name not in skip_entries: + destination_path = os.path.join(destination_dir, entry.name) + if entry.is_dir(): + shutil.copytree(entry.path, destination_path, dirs_exist_ok=True) + else: + shutil.copy2(entry.path, destination_path) + +def recurse_dependencies(packages, package, dependency_names): + for dependency in package['dependencies']: + name = dependency['name'] + if name in dependency_names: + continue + + dependency_names.add(name) + for package in packages: + if package['name'] == name: + recurse_dependencies(packages, package, dependency_names) + +def get_target_dependency_names(target_package_name): + result = subprocess.run( + [ + 'cargo', + 'metadata', + '--manifest-path', + cargo_toml_path, + '--filter-platform', + 'x86_64-pc-windows-msvc', + '--format-version', + '1' + ], + capture_output=True, + text=True, + check=True + ) + + json_output = json.loads(result.stdout[:-1]) + packages = json_output['packages'] + + target_dependency_names = set() + for package in packages: + if package['name'] == 'libloot-cpp': + recurse_dependencies(packages, package, target_dependency_names) + + return target_dependency_names + +if __name__ == "__main__": + target_package_name = 'libloot-cpp' + cargo_toml_path = '../cpp/Cargo.toml' + attribution_dir = 'build/attribution' + output_dir = 'api/licenses' + + subprocess.run( + [ + 'cargo', + 'attribution', + '--manifest-path', + cargo_toml_path, + '--output-dir', + attribution_dir, + '--filter-platform', + 'x86_64-pc-windows-msvc', + '--only-normal-dependencies' + ], + check=True + ) + + process_licenses(attribution_dir, output_dir) + + target_dependency_names = get_target_dependency_names(target_package_name) + + dependencies_toml_path = os.path.join(attribution_dir, 'dependencies.toml') + with open(dependencies_toml_path, 'rb') as f: + data = tomllib.load(f) + dependencies = data['dependencies'] + + # esplugin's and libloadorder's notices are nonsense, but they're my + # libraries so I give myself permission to skip providing the notices. + skip_dependencies = [ + 'libloot', + 'libloot-ffi-errors', + 'libloot-cpp', + 'libloot-nodejs', + 'libloot-python', + 'parameterized-test', + 'esplugin', + 'libloadorder' + ] + + notices_rst = f'.. This file was generated by scripts/licenses.py at {datetime.datetime.now().isoformat()}.\n\n' + + heading = 'Dependency Copyright Notices' + notices_rst += heading + notices_rst += '\n' + notices_rst += '=' * len(heading) + notices_rst += '\n\n' + + for dependency in dependencies: + if dependency['name'] not in target_dependency_names: + continue + + if dependency['name'] in skip_dependencies: + continue + + if 'license' not in dependency: + print(f'Found dependency with no license: {dependency['name']}') + exit(1) + + if 'ISC' in dependency['license']: + print(f'Found dependency that may require the ISC license: {dependency['name']}') + exit(1) + + if 'exception' in dependency['license']: + print(f'Found dependency that may require an exception: {dependency['name']}') + exit(1) + + if 'CC0-1.0' in dependency['license']: + print(f'Skipping {dependency['name']} because it uses the CC0-1.0 license: {dependency['license']}') + continue + + if 'Unlicense' in dependency['license']: + print(f'Skipping {dependency['name']} because it uses the Unlicense license: {dependency['license']}') + continue + + if 'Zlib' in dependency['license']: + print(f'Skipping {dependency['name']} because it uses the Zlib license: {dependency['license']}') + continue + + if 'notices' not in dependency: + print(f'Skipping dependency with no notices: {dependency['name']}') + continue + + if 'repository' not in dependency: + print(f'Found dependency with no repository: {dependency['name']}') + exit(1) + + if dependency['name'] == 'hashlink': + # The notice for hashlink is mangled so correcting it here. + dependency['notices'] = ['Copyright (c) 2015 The Rust Project Developers'] + + link_rst = f'`{dependency['name']} <{dependency['repository']}>`_' + underline_rst = '-' * len(link_rst) + dep_notices_rst = '\n '.join(dependency['notices']) + + section_rst = f'{link_rst}\n{underline_rst}\n\n::\n\n {dep_notices_rst}\n\n' + + notices_rst += section_rst + + notices_rst_path = os.path.join(output_dir, 'dependency-notices.rst') + with open(notices_rst_path, 'w', encoding="utf-8") as outfile: + outfile.write(notices_rst) diff --git a/docs/uv.lock b/docs/uv.lock index db1d5270..b03f26e7 100644 --- a/docs/uv.lock +++ b/docs/uv.lock @@ -139,7 +139,7 @@ wheels = [ [[package]] name = "libloot-docs" -version = "0.26.1" +version = "0.27.0" source = { virtual = "." } dependencies = [ { name = "breathe" }, diff --git a/ffi-errors/Cargo.toml b/ffi-errors/Cargo.toml new file mode 100644 index 00000000..171e3bd2 --- /dev/null +++ b/ffi-errors/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "libloot-ffi-errors" +version = "0.27.0" +edition = "2024" +license = "GPL-3.0-or-later" + +[dependencies] +libloot = { path = ".." } diff --git a/ffi-errors/src/lib.rs b/ffi-errors/src/lib.rs new file mode 100644 index 00000000..5372e070 --- /dev/null +++ b/ffi-errors/src/lib.rs @@ -0,0 +1,128 @@ +// Deny some rustc lints that are allow-by-default. +#![deny( + ambiguous_negative_literals, + impl_trait_overcaptures, + let_underscore_drop, + missing_copy_implementations, + missing_debug_implementations, + non_ascii_idents, + redundant_imports, + redundant_lifetimes, + trivial_casts, + trivial_numeric_casts, + unit_bindings, + unreachable_pub +)] +#![deny(clippy::pedantic)] +#![allow(clippy::missing_errors_doc)] +// Selectively deny clippy restriction lints. +#![deny( + clippy::allow_attributes, + clippy::as_conversions, + clippy::as_underscore, + clippy::assertions_on_result_states, + clippy::big_endian_bytes, + clippy::cfg_not_test, + clippy::clone_on_ref_ptr, + clippy::create_dir, + clippy::dbg_macro, + clippy::decimal_literal_representation, + clippy::default_numeric_fallback, + clippy::doc_include_without_cfg, + clippy::empty_drop, + clippy::error_impl_error, + clippy::exit, + clippy::exhaustive_enums, + clippy::expect_used, + clippy::filetype_is_file, + clippy::float_cmp_const, + clippy::fn_to_numeric_cast_any, + clippy::get_unwrap, + clippy::host_endian_bytes, + clippy::if_then_some_else_none, + clippy::indexing_slicing, + clippy::infinite_loop, + clippy::integer_division, + clippy::integer_division_remainder_used, + clippy::iter_over_hash_type, + clippy::let_underscore_must_use, + clippy::lossy_float_literal, + clippy::map_err_ignore, + clippy::map_with_unused_argument_over_ranges, + clippy::mem_forget, + clippy::missing_assert_message, + clippy::missing_asserts_for_indexing, + clippy::mixed_read_write_in_expression, + clippy::multiple_inherent_impl, + clippy::multiple_unsafe_ops_per_block, + clippy::mutex_atomic, + clippy::mutex_integer, + clippy::needless_raw_strings, + clippy::non_ascii_literal, + clippy::non_zero_suggestions, + clippy::panic, + clippy::panic_in_result_fn, + clippy::partial_pub_fields, + clippy::pathbuf_init_then_push, + clippy::precedence_bits, + clippy::print_stderr, + clippy::print_stdout, + clippy::rc_buffer, + clippy::rc_mutex, + clippy::redundant_type_annotations, + clippy::ref_patterns, + clippy::rest_pat_in_fully_bound_structs, + clippy::str_to_string, + clippy::string_lit_chars_any, + clippy::string_slice, + clippy::string_to_string, + clippy::suspicious_xor_used_as_pow, + clippy::tests_outside_test_module, + clippy::todo, + clippy::try_err, + clippy::undocumented_unsafe_blocks, + clippy::unimplemented, + clippy::unnecessary_safety_comment, + clippy::unneeded_field_pattern, + clippy::unreachable, + clippy::unused_result_ok, + clippy::unwrap_in_result, + clippy::unwrap_used, + clippy::use_debug, + clippy::verbose_file_reads, + clippy::wildcard_enum_match_arm +)] + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct UnsupportedEnumValueError; + +impl std::fmt::Display for UnsupportedEnumValueError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Enum value is unsupported") + } +} + +impl std::error::Error for UnsupportedEnumValueError {} + +pub fn fmt_error_chain( + mut error: &dyn std::error::Error, + f: &mut std::fmt::Formatter<'_>, +) -> std::fmt::Result { + write!(f, "{error}")?; + while let Some(source) = error.source() { + write!(f, ": {source}")?; + error = source; + } + Ok(()) +} + +#[macro_export] +macro_rules! variant_box_from_error { + ( $from_type:ident, $to_type:ident::$to_variant:ident ) => { + impl From<$from_type> for $to_type { + fn from(value: $from_type) -> Self { + Self::$to_variant(Box::new(value)) + } + } + }; +} diff --git a/nodejs/.cargo/config.toml b/nodejs/.cargo/config.toml new file mode 100644 index 00000000..0c17df09 --- /dev/null +++ b/nodejs/.cargo/config.toml @@ -0,0 +1,2 @@ +[target.x86_64-pc-windows-msvc] +rustflags = ["-C", "target-feature=+crt-static"] \ No newline at end of file diff --git a/nodejs/.github/workflows/CI.yml b/nodejs/.github/workflows/CI.yml new file mode 100644 index 00000000..c4112c8b --- /dev/null +++ b/nodejs/.github/workflows/CI.yml @@ -0,0 +1,203 @@ +name: CI +env: + DEBUG: napi:* + APP_NAME: libloot-nodejs + MACOSX_DEPLOYMENT_TARGET: '10.13' +permissions: + contents: write + id-token: write +'on': + push: + branches: + - main + tags-ignore: + - '**' + paths-ignore: + - '**/*.md' + - LICENSE + - '**/*.gitignore' + - .editorconfig + - docs/** + pull_request: null +jobs: + build: + strategy: + fail-fast: false + matrix: + settings: + - host: windows-latest + build: yarn build --target x86_64-pc-windows-msvc + target: x86_64-pc-windows-msvc + - host: ubuntu-latest + target: x86_64-unknown-linux-gnu + docker: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian + build: yarn build --target x86_64-unknown-linux-gnu + name: stable - ${{ matrix.settings.target }} - node@20 + runs-on: ${{ matrix.settings.host }} + steps: + - uses: actions/checkout@v4 + - name: Setup node + uses: actions/setup-node@v4 + if: ${{ !matrix.settings.docker }} + with: + node-version: 20 + cache: yarn + - name: Install + uses: dtolnay/rust-toolchain@stable + if: ${{ !matrix.settings.docker }} + with: + toolchain: stable + targets: ${{ matrix.settings.target }} + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + .cargo-cache + target/ + key: ${{ matrix.settings.target }}-cargo-${{ matrix.settings.host }} + - uses: goto-bus-stop/setup-zig@v2 + if: ${{ matrix.settings.target == 'armv7-unknown-linux-gnueabihf' || matrix.settings.target == 'armv7-unknown-linux-musleabihf' }} + with: + version: 0.13.0 + - name: Setup toolchain + run: ${{ matrix.settings.setup }} + if: ${{ matrix.settings.setup }} + shell: bash + - name: Setup node x86 + if: matrix.settings.target == 'i686-pc-windows-msvc' + run: yarn config set supportedArchitectures.cpu "ia32" + shell: bash + - name: Install dependencies + run: yarn install + - name: Setup node x86 + uses: actions/setup-node@v4 + if: matrix.settings.target == 'i686-pc-windows-msvc' + with: + node-version: 20 + cache: yarn + architecture: x86 + - name: Build in docker + uses: addnab/docker-run-action@v3 + if: ${{ matrix.settings.docker }} + with: + image: ${{ matrix.settings.docker }} + options: '--user 0:0 -v ${{ github.workspace }}/.cargo-cache/git/db:/usr/local/cargo/git/db -v ${{ github.workspace }}/.cargo/registry/cache:/usr/local/cargo/registry/cache -v ${{ github.workspace }}/.cargo/registry/index:/usr/local/cargo/registry/index -v ${{ github.workspace }}:/build -w /build' + run: ${{ matrix.settings.build }} + - name: Build + run: ${{ matrix.settings.build }} + if: ${{ !matrix.settings.docker }} + shell: bash + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: bindings-${{ matrix.settings.target }} + path: ${{ env.APP_NAME }}.*.node + if-no-files-found: error + test-macOS-windows-binding: + name: Test bindings on ${{ matrix.settings.target }} - node@${{ matrix.node }} + needs: + - build + strategy: + fail-fast: false + matrix: + settings: + - host: windows-latest + target: x86_64-pc-windows-msvc + node: + - '18' + - '20' + runs-on: ${{ matrix.settings.host }} + steps: + - uses: actions/checkout@v4 + - name: Setup node + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: yarn + architecture: x64 + - name: Install dependencies + run: yarn install + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + name: bindings-${{ matrix.settings.target }} + path: . + - name: List packages + run: ls -R . + shell: bash + - name: Test bindings + run: yarn test + test-linux-x64-gnu-binding: + name: Test bindings on Linux-x64-gnu - node@${{ matrix.node }} + needs: + - build + strategy: + fail-fast: false + matrix: + node: + - '18' + - '20' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup node + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: yarn + - name: Install dependencies + run: yarn install + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + name: bindings-x86_64-unknown-linux-gnu + path: . + - name: List packages + run: ls -R . + shell: bash + - name: Test bindings + run: docker run --rm -v $(pwd):/build -w /build node:${{ matrix.node }}-slim yarn test + publish: + name: Publish + runs-on: ubuntu-latest + needs: + - test-macOS-windows-binding + - test-linux-x64-gnu-binding + steps: + - uses: actions/checkout@v4 + - name: Setup node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: yarn + - name: Install dependencies + run: yarn install + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + - name: Move artifacts + run: yarn artifacts + - name: List packages + run: ls -R ./npm + shell: bash + - name: Publish + run: | + npm config set provenance true + if git log -1 --pretty=%B | grep "^[0-9]\+\.[0-9]\+\.[0-9]\+$"; + then + echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> ~/.npmrc + npm publish --access public + elif git log -1 --pretty=%B | grep "^[0-9]\+\.[0-9]\+\.[0-9]\+"; + then + echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> ~/.npmrc + npm publish --tag next --access public + else + echo "Not a release, skipping publish" + fi + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/nodejs/.gitignore b/nodejs/.gitignore new file mode 100644 index 00000000..f301f5ab --- /dev/null +++ b/nodejs/.gitignore @@ -0,0 +1,200 @@ +# Created by https://www.toptal.com/developers/gitignore/api/node +# Edit at https://www.toptal.com/developers/gitignore?templates=node + +### Node ### +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript v1 declaration files +typings/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test + +# parcel-bundler cache (https://parceljs.org/) +.cache + +# Next.js build output +.next + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# End of https://www.toptal.com/developers/gitignore/api/node + +# Created by https://www.toptal.com/developers/gitignore/api/macos +# Edit at https://www.toptal.com/developers/gitignore?templates=macos + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +# End of https://www.toptal.com/developers/gitignore/api/macos + +# Created by https://www.toptal.com/developers/gitignore/api/windows +# Edit at https://www.toptal.com/developers/gitignore?templates=windows + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# End of https://www.toptal.com/developers/gitignore/api/windows + +#Added by cargo + +/target +Cargo.lock + +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + +*.node + +/index.d.ts +/index.js diff --git a/nodejs/.npmignore b/nodejs/.npmignore new file mode 100644 index 00000000..ec144db2 --- /dev/null +++ b/nodejs/.npmignore @@ -0,0 +1,13 @@ +target +Cargo.lock +.cargo +.github +npm +.eslintrc +.prettierignore +rustfmt.toml +yarn.lock +*.node +.yarn +__test__ +renovate.json diff --git a/nodejs/.yarnrc.yml b/nodejs/.yarnrc.yml new file mode 100644 index 00000000..3186f3f0 --- /dev/null +++ b/nodejs/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/nodejs/Cargo.toml b/nodejs/Cargo.toml new file mode 100644 index 00000000..2ea2ef48 --- /dev/null +++ b/nodejs/Cargo.toml @@ -0,0 +1,19 @@ +[package] +edition = "2021" +name = "libloot-nodejs" +version = "0.27.0" +license = "GPL-3.0-or-later" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +libloot = { path = ".." } +libloot-ffi-errors = { path = "../ffi-errors" } + +# Default enable napi4 feature, see https://nodejs.org/api/n-api.html#node-api-version-matrix +napi = { version = "2.12.2", default-features = false, features = ["napi4"] } +napi-derive = "2.12.2" + +[build-dependencies] +napi-build = "2.0.1" diff --git a/nodejs/README.md b/nodejs/README.md new file mode 100644 index 00000000..f9359e0c --- /dev/null +++ b/nodejs/README.md @@ -0,0 +1,22 @@ +# libloot-nodejs + +An **experimental** Node.js wrapper around the libloot Rust implementation, built using [NAPI-RS](https://napi.rs/docs/concepts/class). + +## Build + +To build, first install Rust and Node.js, then run: + +``` +npm install +npm run build +``` + +The tests can be run using: + +``` +npm test +``` + +## Usage notes + +- All errors are thrown as JavaScript `Error` values. The error message is the concatenated display text of all the recursive Rust errors from the libloot crate, with the exception of `PluginDataError` errors, which are turned into messages of the form `esplugin error, code : `, where `` is one of the esplugin error codes currently exposed by the C++ implementation of libloot. diff --git a/nodejs/__test__/index.spec.mjs b/nodejs/__test__/index.spec.mjs new file mode 100644 index 00000000..b5cb6752 --- /dev/null +++ b/nodejs/__test__/index.spec.mjs @@ -0,0 +1,25 @@ +import test from 'ava' + +import { liblootVersion, isCompatible, Group } from '../index.js' + +test('liblootVersion', t => { + t.is(liblootVersion(), process.env.npm_package_version) +}); + +test('isCompatible', t => { + t.is(isCompatible(0, 27, 0), true) +}); + +test('Group equality', t => { + const group1 = new Group("A"); + const group2 = new Group("A"); + const group3 = new Group("B"); + + t.deepEqual(group1.name, group2.name); + t.notDeepEqual(group1.name, group3.name); + + t.deepEqual(group1, group2); + + // The objects have no public fields so appear to be equal. + t.deepEqual(group1, group3); +}) diff --git a/nodejs/build.rs b/nodejs/build.rs new file mode 100644 index 00000000..9fc23678 --- /dev/null +++ b/nodejs/build.rs @@ -0,0 +1,5 @@ +extern crate napi_build; + +fn main() { + napi_build::setup(); +} diff --git a/nodejs/npm/linux-x64-gnu/README.md b/nodejs/npm/linux-x64-gnu/README.md new file mode 100644 index 00000000..8da13516 --- /dev/null +++ b/nodejs/npm/linux-x64-gnu/README.md @@ -0,0 +1,3 @@ +# `libloot-nodejs-linux-x64-gnu` + +This is the **x86_64-unknown-linux-gnu** binary for `libloot-nodejs` diff --git a/nodejs/npm/linux-x64-gnu/package.json b/nodejs/npm/linux-x64-gnu/package.json new file mode 100644 index 00000000..ade45a77 --- /dev/null +++ b/nodejs/npm/linux-x64-gnu/package.json @@ -0,0 +1,21 @@ +{ + "name": "libloot-nodejs-linux-x64-gnu", + "version": "0.27.0", + "os": [ + "linux" + ], + "cpu": [ + "x64" + ], + "main": "libloot-nodejs.linux-x64-gnu.node", + "files": [ + "libloot-nodejs.linux-x64-gnu.node" + ], + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "libc": [ + "glibc" + ] +} diff --git a/nodejs/npm/win32-x64-msvc/README.md b/nodejs/npm/win32-x64-msvc/README.md new file mode 100644 index 00000000..8a99f69d --- /dev/null +++ b/nodejs/npm/win32-x64-msvc/README.md @@ -0,0 +1,3 @@ +# `libloot-nodejs-win32-x64-msvc` + +This is the **x86_64-pc-windows-msvc** binary for `libloot-nodejs` diff --git a/nodejs/npm/win32-x64-msvc/package.json b/nodejs/npm/win32-x64-msvc/package.json new file mode 100644 index 00000000..bd122b09 --- /dev/null +++ b/nodejs/npm/win32-x64-msvc/package.json @@ -0,0 +1,18 @@ +{ + "name": "libloot-nodejs-win32-x64-msvc", + "version": "0.27.0", + "os": [ + "win32" + ], + "cpu": [ + "x64" + ], + "main": "libloot-nodejs.win32-x64-msvc.node", + "files": [ + "libloot-nodejs.win32-x64-msvc.node" + ], + "license": "MIT", + "engines": { + "node": ">= 10" + } +} diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json new file mode 100644 index 00000000..f350c79b --- /dev/null +++ b/nodejs/package-lock.json @@ -0,0 +1,2087 @@ +{ + "name": "libloot-nodejs", + "version": "0.26.2", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "libloot-nodejs", + "version": "0.26.2", + "license": "MIT", + "devDependencies": { + "@napi-rs/cli": "^2.18.4", + "ava": "^6.0.1" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@mapbox/node-pre-gyp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-2.0.0.tgz", + "integrity": "sha512-llMXd39jtP0HpQLVI37Bf1m2ADlEb35GYSh1SDSLsBhR+5iCxiNGlT31yqbNtVHygHAtMy6dWFERpU2JgufhPg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "consola": "^3.2.3", + "detect-libc": "^2.0.0", + "https-proxy-agent": "^7.0.5", + "node-fetch": "^2.6.7", + "nopt": "^8.0.0", + "semver": "^7.5.3", + "tar": "^7.4.0" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@napi-rs/cli": { + "version": "2.18.4", + "resolved": "https://registry.npmjs.org/@napi-rs/cli/-/cli-2.18.4.tgz", + "integrity": "sha512-SgJeA4df9DE2iAEpr3M2H0OKl/yjtg1BnRI5/JyowS71tUWhrfSu2LT0V3vlHET+g1hBVlrO60PmEXwUEKp8Mg==", + "dev": true, + "license": "MIT", + "bin": { + "napi": "scripts/index.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz", + "integrity": "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", + "integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@types/estree": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", + "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vercel/nft": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@vercel/nft/-/nft-0.27.10.tgz", + "integrity": "sha512-zbaF9Wp/NsZtKLE4uVmL3FyfFwlpDyuymQM1kPbeT0mVOHKDQQNjnnfslB3REg3oZprmNFJuh3pkHBk2qAaizg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@mapbox/node-pre-gyp": "^2.0.0-rc.0", + "@rollup/pluginutils": "^5.1.3", + "acorn": "^8.6.0", + "acorn-import-attributes": "^1.9.5", + "async-sema": "^3.1.1", + "bindings": "^1.4.0", + "estree-walker": "2.0.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "node-gyp-build": "^4.2.2", + "picomatch": "^4.0.2", + "resolve-from": "^5.0.0" + }, + "bin": { + "nft": "out/cli.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/abbrev": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", + "integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/acorn": { + "version": "8.14.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", + "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/array-find-index": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.2.tgz", + "integrity": "sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/arrgv": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/arrgv/-/arrgv-1.0.2.tgz", + "integrity": "sha512-a4eg4yhp7mmruZDQFqVMlxNRFGi/i1r87pt8SDHy0/I8PqSXoUTlWZRdAZo0VXgvEARcujbtTk8kiZRi1uDGRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/arrify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-3.0.0.tgz", + "integrity": "sha512-tLkvA81vQG/XqE2mjDkGQHoOINtMHtysSnemrmoGe6PydDPMRbVugqyk4A6V/WDWEfm3l+0d8anA9r8cv/5Jaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/async-sema": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/async-sema/-/async-sema-3.1.1.tgz", + "integrity": "sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ava": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/ava/-/ava-6.2.0.tgz", + "integrity": "sha512-+GZk5PbyepjiO/68hzCZCUepQOQauKfNnI7sA4JukBTg97jD7E+tDKEA7OhGOGr6EorNNMM9+jqvgHVOTOzG4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vercel/nft": "^0.27.5", + "acorn": "^8.13.0", + "acorn-walk": "^8.3.4", + "ansi-styles": "^6.2.1", + "arrgv": "^1.0.2", + "arrify": "^3.0.0", + "callsites": "^4.2.0", + "cbor": "^9.0.2", + "chalk": "^5.3.0", + "chunkd": "^2.0.1", + "ci-info": "^4.0.0", + "ci-parallel-vars": "^1.0.1", + "cli-truncate": "^4.0.0", + "code-excerpt": "^4.0.0", + "common-path-prefix": "^3.0.0", + "concordance": "^5.0.4", + "currently-unhandled": "^0.4.1", + "debug": "^4.3.7", + "emittery": "^1.0.3", + "figures": "^6.1.0", + "globby": "^14.0.2", + "ignore-by-default": "^2.1.0", + "indent-string": "^5.0.0", + "is-plain-object": "^5.0.0", + "is-promise": "^4.0.0", + "matcher": "^5.0.0", + "memoize": "^10.0.0", + "ms": "^2.1.3", + "p-map": "^7.0.2", + "package-config": "^5.0.0", + "picomatch": "^4.0.2", + "plur": "^5.1.0", + "pretty-ms": "^9.1.0", + "resolve-cwd": "^3.0.0", + "stack-utils": "^2.0.6", + "strip-ansi": "^7.1.0", + "supertap": "^3.0.1", + "temp-dir": "^3.0.0", + "write-file-atomic": "^6.0.0", + "yargs": "^17.7.2" + }, + "bin": { + "ava": "entrypoints/cli.mjs" + }, + "engines": { + "node": "^18.18 || ^20.8 || ^22 || >=23" + }, + "peerDependencies": { + "@ava/typescript": "*" + }, + "peerDependenciesMeta": { + "@ava/typescript": { + "optional": true + } + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/blueimp-md5": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/blueimp-md5/-/blueimp-md5-2.19.0.tgz", + "integrity": "sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/callsites": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-4.2.0.tgz", + "integrity": "sha512-kfzR4zzQtAE9PC7CzZsjl3aBNbXWuXiSeOCdLcPpBfGW8YuCqQHcRPFDbr/BPVmd3EEPVpuFzLyuT/cUhPr4OQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cbor": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/cbor/-/cbor-9.0.2.tgz", + "integrity": "sha512-JPypkxsB10s9QOWwa6zwPzqE1Md3vqpPc+cai4sAecuCsRyAtAl/pMyhPlMbT/xtPnm2dznJZYRLui57qiRhaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "nofilter": "^3.1.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/chalk": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", + "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/chunkd": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/chunkd/-/chunkd-2.0.1.tgz", + "integrity": "sha512-7d58XsFmOq0j6el67Ug9mHf9ELUXsQXYJBkyxhH/k+6Ke0qXRnv0kbemx+Twc6fRJ07C49lcbdgm9FL1Ei/6SQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/ci-info": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.2.0.tgz", + "integrity": "sha512-cYY9mypksY8NRqgDB1XD1RiJL338v/551niynFTGkZOO2LHuB2OmOYxDIe/ttN9AHwrqdum1360G3ald0W9kCg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ci-parallel-vars": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ci-parallel-vars/-/ci-parallel-vars-1.0.1.tgz", + "integrity": "sha512-uvzpYrpmidaoxvIQHM+rKSrigjOe9feHYbw4uOI2gdfe1C3xIlxO+kVXq83WQWNniTf8bAxVpy+cQeFQsMERKg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-truncate": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", + "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^5.0.0", + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/code-excerpt": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", + "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "convert-to-spaces": "^2.0.1" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/common-path-prefix": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz", + "integrity": "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==", + "dev": true, + "license": "ISC" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/concordance": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/concordance/-/concordance-5.0.4.tgz", + "integrity": "sha512-OAcsnTEYu1ARJqWVGwf4zh4JDfHZEaSNlNccFmt8YjB2l/n19/PF2viLINHc57vO4FKIAFl2FWASIGZZWZ2Kxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "date-time": "^3.1.0", + "esutils": "^2.0.3", + "fast-diff": "^1.2.0", + "js-string-escape": "^1.0.1", + "lodash": "^4.17.15", + "md5-hex": "^3.0.1", + "semver": "^7.3.2", + "well-known-symbols": "^2.0.0" + }, + "engines": { + "node": ">=10.18.0 <11 || >=12.14.0 <13 || >=14" + } + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/convert-to-spaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", + "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/currently-unhandled": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/currently-unhandled/-/currently-unhandled-0.4.1.tgz", + "integrity": "sha512-/fITjgjGU50vjQ4FH6eUoYu+iUoUKIXws2hL15JJpIR+BbTxaXQsMuuyjtNh2WqsSBS5nsaZHFsFecyw5CCAng==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-find-index": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/date-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/date-time/-/date-time-3.1.0.tgz", + "integrity": "sha512-uqCUKXE5q1PNBXjPqvwhwJf9SwMoAHBgWJ6DcrnS5o+W2JOiIILl0JEdVD8SGujrNS02GGxgwAg2PN2zONgtjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "time-zone": "^1.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/detect-libc": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", + "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/emittery": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-1.1.0.tgz", + "integrity": "sha512-rsX7ktqARv/6UQDgMaLfIqUWAEzzbCQiVh7V9rhDXp6c37yoJcks12NVD+XPkgl4AEavmNhVfrhGoqYwIsMYYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "dev": true, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up-simple": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.1.tgz", + "integrity": "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz", + "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/globby": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz", + "integrity": "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^2.1.0", + "fast-glob": "^3.3.3", + "ignore": "^7.0.3", + "path-type": "^6.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ignore": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.3.tgz", + "integrity": "sha512-bAH5jbK/F3T3Jls4I0SO1hmPR0dKU0a7+SY6n1yzRtG54FLO8d6w/nxLFX2Nb7dBu6cCWXPaAME6cYqFUMmuCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/ignore-by-default": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-2.1.0.tgz", + "integrity": "sha512-yiWd4GVmJp0Q6ghmM2B/V3oZGRmjrKLXvHR3TE1nfoXsmoggllfZUQe74EN0fJdPFZu2NIvNdrMMLm3OsV7Ohw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10 <11 || >=12 <13 || >=14" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/irregular-plurals": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/irregular-plurals/-/irregular-plurals-3.5.0.tgz", + "integrity": "sha512-1ANGLZ+Nkv1ptFb2pa8oG8Lem4krflKuX/gINiHJHjJUKaJHk/SXk5x6K3J+39/p0h1RQ2saROclJJ+QLvETCQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/js-string-escape": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/js-string-escape/-/js-string-escape-1.0.1.tgz", + "integrity": "sha512-Smw4xcfIQ5LVjAOuJCvN/zIodzA/BBSsluuoSykP+lUvScIi4U6RJLfwHet5cxFnCswUjISV8oAXaqaJDY3chg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/load-json-file": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-7.0.1.tgz", + "integrity": "sha512-Gnxj3ev3mB5TkVBGad0JM6dmLiQL+o0t23JPBZ9sd+yvSLk05mFoqKBw5N8gbbkU4TNXyqCgIrl/VM17OgUIgQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true, + "license": "MIT" + }, + "node_modules/matcher": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-5.0.0.tgz", + "integrity": "sha512-s2EMBOWtXFc8dgqvoAzKJXxNHibcdJMV0gwqKUaw9E2JBJuGUK7DrNKrA6g/i+v72TT16+6sVm5mS3thaMLQUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^5.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/md5-hex": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/md5-hex/-/md5-hex-3.0.1.tgz", + "integrity": "sha512-BUiRtTtV39LIJwinWBjqVsU9xhdnz7/i889V859IBFpuqGAj6LuOvHv5XLbgZ2R7ptJoJaEcxkv88/h25T7Ciw==", + "dev": true, + "license": "MIT", + "dependencies": { + "blueimp-md5": "^2.10.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/memoize": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/memoize/-/memoize-10.1.0.tgz", + "integrity": "sha512-MMbFhJzh4Jlg/poq1si90XRlTZRDHVqdlz2mPyGJ6kqMpyHUyVpDd5gpFAvVehW64+RA1eKE9Yt8aSLY7w2Kgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/memoize?sponsor=1" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", + "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "dev": true, + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/nofilter": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/nofilter/-/nofilter-3.1.0.tgz", + "integrity": "sha512-l2NNj07e9afPnhAhvgVrCD/oy2Ai1yfLpuo3EpiO1jFTsB4sFz6oIfAfSZyQzVpkZQ9xS8ZS5g1jCBgq4Hwo0g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.19" + } + }, + "node_modules/nopt": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", + "integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^3.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-map": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.3.tgz", + "integrity": "sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-config": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/package-config/-/package-config-5.0.0.tgz", + "integrity": "sha512-GYTTew2slBcYdvRHqjhwaaydVMvn/qrGC323+nKclYioNSLTDUM/lGgtGTgyHVtYcozb+XkE8CNhwcraOmZ9Mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up-simple": "^1.0.0", + "load-json-file": "^7.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-type": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz", + "integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/plur": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/plur/-/plur-5.1.0.tgz", + "integrity": "sha512-VP/72JeXqak2KiOzjgKtQen5y3IZHn+9GOuLDafPv0eXa47xq0At93XahYBs26MsifCQ4enGKwbjBTKgb9QJXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "irregular-plurals": "^3.3.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pretty-ms": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.2.0.tgz", + "integrity": "sha512-4yf0QO/sllf/1zbZWYnvWw3NxCQwLXKzIj0G849LSufP15BXKM0rbD2Z3wVnkMfjdn/CB0Dpp444gYAACdsplg==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/slice-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/supertap": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/supertap/-/supertap-3.0.1.tgz", + "integrity": "sha512-u1ZpIBCawJnO+0QePsEiOknOfCRq0yERxiAchT0i4li0WHNUJbf0evXXSXOcCAR4M8iMDoajXYmstm/qO81Isw==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^5.0.0", + "js-yaml": "^3.14.1", + "serialize-error": "^7.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/tar": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", + "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.0.1", + "mkdirp": "^3.0.1", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/temp-dir": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-3.0.0.tgz", + "integrity": "sha512-nHc6S/bwIilKHNRgK/3jlhDoIHcp45YgyiwcAk46Tr0LfEqGBVpmiAyuiuxeVE44m3mXnEeVhaipLOEWmH+Njw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + } + }, + "node_modules/time-zone": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/time-zone/-/time-zone-1.0.0.tgz", + "integrity": "sha512-TIsDdtKo6+XrPtiTm1ssmMngN1sAhyKnTO2kunQWqNPWIVvCm15Wmw4SWInwTVgJ5u/Tr04+8Ei9TNcw4x4ONA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/well-known-symbols": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/well-known-symbols/-/well-known-symbols-2.0.0.tgz", + "integrity": "sha512-ZMjC3ho+KXo0BfJb7JgtQ5IBuvnShdlACNkKkdsqBmYw3bPAaJfPeYUo6tLUaT5tG/Gkh7xkpBhKRQ9e7pyg9Q==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=6" + } + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-6.0.0.tgz", + "integrity": "sha512-GmqrO8WJ1NuzJ2DrziEI2o57jKAVIQNf8a18W3nCYU3H7PNWqCCVTeH6/NQE93CIllIgQS98rrmVkYgTX9fFJQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/nodejs/package.json b/nodejs/package.json new file mode 100644 index 00000000..94fcc7ea --- /dev/null +++ b/nodejs/package.json @@ -0,0 +1,37 @@ +{ + "name": "libloot-nodejs", + "version": "0.27.0", + "main": "index.js", + "types": "index.d.ts", + "napi": { + "name": "libloot-nodejs", + "triples": { + "defaults": false, + "additional": [ + "x86_64-pc-windows-msvc", + "x86_64-unknown-linux-gnu" + ] + } + }, + "license": "MIT", + "devDependencies": { + "@napi-rs/cli": "^2.18.4", + "ava": "^6.0.1" + }, + "ava": { + "timeout": "3m" + }, + "engines": { + "node": ">= 10" + }, + "scripts": { + "artifacts": "napi artifacts", + "build": "napi build --platform --release", + "build:debug": "napi build --platform", + "prepublishOnly": "napi prepublish -t npm", + "test": "ava", + "universal": "napi universal", + "version": "napi version" + }, + "packageManager": "yarn@4.9.1" +} diff --git a/nodejs/src/database.rs b/nodejs/src/database.rs new file mode 100644 index 00000000..71c8975d --- /dev/null +++ b/nodejs/src/database.rs @@ -0,0 +1,339 @@ +use std::{ + path::Path, + sync::{Arc, RwLock}, +}; + +use libloot::{error::DatabaseLockPoisonError, WriteMode}; +use libloot_ffi_errors::UnsupportedEnumValueError; +use napi_derive::napi; + +use crate::{ + error::VerboseError, + metadata::{Group, Message, PluginMetadata}, +}; + +#[napi] +#[derive(Clone, Debug)] +pub struct Database(Arc>); + +#[napi] +impl Database { + #[napi] + pub fn load_masterlist(&self, path: String) -> Result<(), VerboseError> { + self.0 + .write() + .map_err(DatabaseLockPoisonError::from)? + .load_masterlist(Path::new(&path)) + .map_err(Into::into) + } + + #[napi] + pub fn load_masterlist_with_prelude( + &self, + masterlist_path: String, + prelude_path: String, + ) -> Result<(), VerboseError> { + self.0 + .write() + .map_err(DatabaseLockPoisonError::from)? + .load_masterlist_with_prelude(Path::new(&masterlist_path), Path::new(&prelude_path)) + .map_err(Into::into) + } + + #[napi] + pub fn load_userlist(&self, path: String) -> Result<(), VerboseError> { + self.0 + .write() + .map_err(DatabaseLockPoisonError::from)? + .load_userlist(Path::new(&path)) + .map_err(Into::into) + } + + #[napi] + pub fn write_user_metadata( + &self, + output_path: String, + overwrite: bool, + ) -> Result<(), VerboseError> { + let write_mode = if overwrite { + WriteMode::CreateOrTruncate + } else { + WriteMode::Create + }; + + self.0 + .read() + .map_err(DatabaseLockPoisonError::from)? + .write_user_metadata(Path::new(&output_path), write_mode) + .map_err(Into::into) + } + + #[napi] + pub fn write_minimal_list( + &self, + output_path: String, + overwrite: bool, + ) -> Result<(), VerboseError> { + let write_mode = if overwrite { + WriteMode::CreateOrTruncate + } else { + WriteMode::Create + }; + + self.0 + .read() + .map_err(DatabaseLockPoisonError::from)? + .write_minimal_list(Path::new(&output_path), write_mode) + .map_err(Into::into) + } + + #[napi] + pub fn evaluate(&self, condition: String) -> Result { + self.0 + .read() + .map_err(DatabaseLockPoisonError::from)? + .evaluate(&condition) + .map_err(Into::into) + } + + #[napi] + pub fn known_bash_tags(&self) -> Result, VerboseError> { + Ok(self + .0 + .read() + .map_err(DatabaseLockPoisonError::from)? + .known_bash_tags()) + } + + #[napi] + pub fn general_messages( + &self, + evaluate_conditions: bool, + ) -> Result, VerboseError> { + self.0 + .write() + .map_err(DatabaseLockPoisonError::from)? + .general_messages(evaluate_conditions) + .map(|v| v.into_iter().map(Into::into).collect()) + .map_err(Into::into) + } + + #[napi] + pub fn groups(&self, include_user_metadata: bool) -> Result, VerboseError> { + Ok(self + .0 + .read() + .map_err(DatabaseLockPoisonError::from)? + .groups(include_user_metadata) + .into_iter() + .map(Into::into) + .collect()) + } + + #[napi] + pub fn user_groups(&self) -> Result, VerboseError> { + Ok(self + .0 + .read() + .map_err(DatabaseLockPoisonError::from)? + .user_groups() + .iter() + .cloned() + .map(Into::into) + .collect()) + } + + #[napi] + pub fn set_user_groups(&self, groups: Vec<&Group>) -> Result<(), VerboseError> { + let groups = groups.into_iter().cloned().map(Into::into).collect(); + self.0 + .write() + .map_err(DatabaseLockPoisonError::from)? + .set_user_groups(groups); + Ok(()) + } + + #[napi] + pub fn groups_path( + &self, + from_group_name: String, + to_group_name: String, + ) -> Result, VerboseError> { + self.0 + .read() + .map_err(DatabaseLockPoisonError::from)? + .groups_path(&from_group_name, &to_group_name) + .map(|v| v.into_iter().map(Into::into).collect()) + .map_err(Into::into) + } + + #[napi] + pub fn plugin_metadata( + &self, + plugin_name: String, + include_user_metadata: bool, + evaluate_conditions: bool, + ) -> Result, VerboseError> { + self.0 + .read() + .map_err(DatabaseLockPoisonError::from)? + .plugin_metadata(&plugin_name, include_user_metadata, evaluate_conditions) + .map(|p| p.map(Into::into)) + .map_err(Into::into) + } + + #[napi] + pub fn plugin_user_metadata( + &self, + plugin_name: String, + evaluate_conditions: bool, + ) -> Result, VerboseError> { + self.0 + .read() + .map_err(DatabaseLockPoisonError::from)? + .plugin_user_metadata(&plugin_name, evaluate_conditions) + .map(|p| p.map(Into::into)) + .map_err(Into::into) + } + + #[napi] + pub fn set_plugin_user_metadata( + &mut self, + plugin_metadata: &PluginMetadata, + ) -> Result<(), VerboseError> { + self.0 + .write() + .map_err(DatabaseLockPoisonError::from)? + .set_plugin_user_metadata(plugin_metadata.clone().into()); + Ok(()) + } + + #[napi] + pub fn discard_plugin_user_metadata(&self, plugin: String) -> Result<(), VerboseError> { + self.0 + .write() + .map_err(DatabaseLockPoisonError::from)? + .discard_plugin_user_metadata(&plugin); + Ok(()) + } + + #[napi] + pub fn discard_all_user_metadata(&self) -> Result<(), VerboseError> { + self.0 + .write() + .map_err(DatabaseLockPoisonError::from)? + .discard_all_user_metadata(); + Ok(()) + } +} + +impl From>> for Database { + fn from(value: Arc>) -> Self { + Self(value) + } +} + +#[napi] +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[repr(transparent)] +pub struct Vertex(libloot::Vertex); + +#[napi] +impl Vertex { + #[napi(constructor)] + pub fn new(name: String, out_edge_type: Option) -> Self { + let mut vertex = libloot::Vertex::new(name); + + if let Some(out_edge_type) = out_edge_type { + vertex = vertex.with_out_edge_type(out_edge_type.into()); + } + + Self(vertex) + } + + #[napi(getter)] + pub fn name(&self) -> &str { + self.0.name() + } + + #[napi(getter)] + pub fn out_edge_type(&self) -> Result, VerboseError> { + self.0 + .out_edge_type() + .map(|e| e.try_into().map_err(Into::into)) + .transpose() + } +} + +impl From for Vertex { + fn from(value: libloot::Vertex) -> Self { + Self(value) + } +} + +impl From for libloot::Vertex { + fn from(value: Vertex) -> Self { + value.0 + } +} + +#[napi] +#[derive(Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub enum EdgeType { + Hardcoded, + MasterFlag, + Master, + MasterlistRequirement, + UserRequirement, + MasterlistLoadAfter, + UserLoadAfter, + MasterlistGroup, + UserGroup, + RecordOverlap, + AssetOverlap, + TieBreak, + BlueprintMaster, +} + +impl TryFrom for EdgeType { + type Error = UnsupportedEnumValueError; + + fn try_from(value: libloot::EdgeType) -> Result { + match value { + libloot::EdgeType::Hardcoded => Ok(EdgeType::Hardcoded), + libloot::EdgeType::MasterFlag => Ok(EdgeType::MasterFlag), + libloot::EdgeType::Master => Ok(EdgeType::Master), + libloot::EdgeType::MasterlistRequirement => Ok(EdgeType::MasterlistRequirement), + libloot::EdgeType::UserRequirement => Ok(EdgeType::UserRequirement), + libloot::EdgeType::MasterlistLoadAfter => Ok(EdgeType::MasterlistLoadAfter), + libloot::EdgeType::UserLoadAfter => Ok(EdgeType::UserLoadAfter), + libloot::EdgeType::MasterlistGroup => Ok(EdgeType::MasterlistGroup), + libloot::EdgeType::UserGroup => Ok(EdgeType::UserGroup), + libloot::EdgeType::RecordOverlap => Ok(EdgeType::RecordOverlap), + libloot::EdgeType::AssetOverlap => Ok(EdgeType::AssetOverlap), + libloot::EdgeType::TieBreak => Ok(EdgeType::TieBreak), + libloot::EdgeType::BlueprintMaster => Ok(EdgeType::BlueprintMaster), + _ => Err(UnsupportedEnumValueError), + } + } +} + +impl From for libloot::EdgeType { + fn from(value: EdgeType) -> Self { + match value { + EdgeType::Hardcoded => libloot::EdgeType::Hardcoded, + EdgeType::MasterFlag => libloot::EdgeType::MasterFlag, + EdgeType::Master => libloot::EdgeType::Master, + EdgeType::MasterlistRequirement => libloot::EdgeType::MasterlistRequirement, + EdgeType::UserRequirement => libloot::EdgeType::UserRequirement, + EdgeType::MasterlistLoadAfter => libloot::EdgeType::MasterlistLoadAfter, + EdgeType::UserLoadAfter => libloot::EdgeType::UserLoadAfter, + EdgeType::MasterlistGroup => libloot::EdgeType::MasterlistGroup, + EdgeType::UserGroup => libloot::EdgeType::UserGroup, + EdgeType::RecordOverlap => libloot::EdgeType::RecordOverlap, + EdgeType::AssetOverlap => libloot::EdgeType::AssetOverlap, + EdgeType::TieBreak => libloot::EdgeType::TieBreak, + EdgeType::BlueprintMaster => libloot::EdgeType::BlueprintMaster, + } + } +} diff --git a/nodejs/src/error.rs b/nodejs/src/error.rs new file mode 100644 index 00000000..cc0ae3b3 --- /dev/null +++ b/nodejs/src/error.rs @@ -0,0 +1,58 @@ +use libloot::{ + error::{ + ConditionEvaluationError, DatabaseLockPoisonError, GameHandleCreationError, + GroupsPathError, LoadOrderError, LoadOrderStateError, LoadPluginsError, + MetadataRetrievalError, PluginDataError, SortPluginsError, + }, + metadata::error::{ + LoadMetadataError, MultilingualMessageContentsError, RegexError, WriteMetadataError, + }, +}; +use libloot_ffi_errors::{fmt_error_chain, UnsupportedEnumValueError}; + +#[derive(Debug)] +pub struct VerboseError(Box); + +impl std::fmt::Display for VerboseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + fmt_error_chain(self.0.as_ref(), f) + } +} + +macro_rules! box_from_error { + ( $from_type:ident, $to_type:ident ) => { + impl From<$from_type> for $to_type { + fn from(value: $from_type) -> Self { + Self(Box::new(value)) + } + } + }; +} + +box_from_error!(GameHandleCreationError, VerboseError); +box_from_error!(UnsupportedEnumValueError, VerboseError); +box_from_error!(DatabaseLockPoisonError, VerboseError); +box_from_error!(LoadPluginsError, VerboseError); +box_from_error!(SortPluginsError, VerboseError); +box_from_error!(LoadOrderStateError, VerboseError); +box_from_error!(LoadOrderError, VerboseError); +box_from_error!(LoadMetadataError, VerboseError); +box_from_error!(WriteMetadataError, VerboseError); +box_from_error!(ConditionEvaluationError, VerboseError); +box_from_error!(GroupsPathError, VerboseError); +box_from_error!(MetadataRetrievalError, VerboseError); +box_from_error!(MultilingualMessageContentsError, VerboseError); +box_from_error!(RegexError, VerboseError); +box_from_error!(PluginDataError, VerboseError); + +impl From for napi::Error { + fn from(value: VerboseError) -> Self { + napi::Error::from_reason(value.to_string()) + } +} + +impl From for napi::JsError { + fn from(value: VerboseError) -> Self { + napi::JsError::from(napi::Error::from(value)) + } +} diff --git a/nodejs/src/game.rs b/nodejs/src/game.rs new file mode 100644 index 00000000..8fb45d3a --- /dev/null +++ b/nodejs/src/game.rs @@ -0,0 +1,199 @@ +use std::path::Path; + +use libloot_ffi_errors::UnsupportedEnumValueError; +use napi_derive::napi; + +use crate::{database::Database, error::VerboseError, plugin::Plugin}; + +#[napi] +#[derive(Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub enum GameType { + Oblivion, + Skyrim, + Fallout3, + FalloutNV, + Fallout4, + SkyrimSE, + Fallout4VR, + SkyrimVR, + Morrowind, + Starfield, + OpenMW, + OblivionRemastered, +} + +impl TryFrom for GameType { + type Error = UnsupportedEnumValueError; + + fn try_from(value: libloot::GameType) -> Result { + match value { + libloot::GameType::Oblivion => Ok(GameType::Oblivion), + libloot::GameType::Skyrim => Ok(GameType::Skyrim), + libloot::GameType::Fallout3 => Ok(GameType::Fallout3), + libloot::GameType::FalloutNV => Ok(GameType::FalloutNV), + libloot::GameType::Fallout4 => Ok(GameType::Fallout4), + libloot::GameType::SkyrimSE => Ok(GameType::SkyrimSE), + libloot::GameType::Fallout4VR => Ok(GameType::Fallout4VR), + libloot::GameType::SkyrimVR => Ok(GameType::SkyrimVR), + libloot::GameType::Morrowind => Ok(GameType::Morrowind), + libloot::GameType::Starfield => Ok(GameType::Starfield), + libloot::GameType::OpenMW => Ok(GameType::OpenMW), + libloot::GameType::OblivionRemastered => Ok(GameType::OblivionRemastered), + _ => Err(UnsupportedEnumValueError), + } + } +} + +impl From for libloot::GameType { + fn from(value: GameType) -> Self { + match value { + GameType::Oblivion => libloot::GameType::Oblivion, + GameType::Skyrim => libloot::GameType::Skyrim, + GameType::Fallout3 => libloot::GameType::Fallout3, + GameType::FalloutNV => libloot::GameType::FalloutNV, + GameType::Fallout4 => libloot::GameType::Fallout4, + GameType::SkyrimSE => libloot::GameType::SkyrimSE, + GameType::Fallout4VR => libloot::GameType::Fallout4VR, + GameType::SkyrimVR => libloot::GameType::SkyrimVR, + GameType::Morrowind => libloot::GameType::Morrowind, + GameType::Starfield => libloot::GameType::Starfield, + GameType::OpenMW => libloot::GameType::OpenMW, + GameType::OblivionRemastered => libloot::GameType::OblivionRemastered, + } + } +} + +#[napi] +#[derive(Debug)] +pub struct Game(libloot::Game); + +#[napi] +impl Game { + #[napi(constructor)] + pub fn new( + game_type: GameType, + game_path: String, + local_path: Option, + ) -> Result { + match local_path { + Some(local_path) => Ok(Game(libloot::Game::with_local_path( + game_type.into(), + Path::new(&game_path), + Path::new(&local_path), + )?)), + None => Ok(Game(libloot::Game::new( + game_type.into(), + Path::new(&game_path), + )?)), + } + } + + #[napi] + pub fn game_type(&self) -> Result { + self.0.game_type().try_into().map_err(Into::into) + } + + #[napi] + pub fn additional_data_paths(&self) -> Vec { + self.0 + .additional_data_paths() + .iter() + .map(|p| p.to_string_lossy().to_string()) + .collect() + } + + #[napi] + pub fn set_additional_data_paths(&mut self, paths: Vec) -> Result<(), VerboseError> { + self.0.set_additional_data_paths(&as_paths(&paths))?; + Ok(()) + } + + #[napi] + pub fn database(&self) -> Database { + self.0.database().into() + } + + #[napi] + pub fn is_valid_plugin(&self, plugin_path: String) -> bool { + self.0.is_valid_plugin(Path::new(&plugin_path)) + } + + #[napi] + pub fn load_plugins(&mut self, plugin_paths: Vec) -> Result<(), VerboseError> { + self.0.load_plugins(&as_paths(&plugin_paths))?; + Ok(()) + } + + #[napi] + pub fn load_plugin_headers(&mut self, plugin_paths: Vec) -> Result<(), VerboseError> { + self.0.load_plugin_headers(&as_paths(&plugin_paths))?; + Ok(()) + } + + #[napi] + pub fn clear_loaded_plugins(&mut self) { + self.0.clear_loaded_plugins(); + } + + #[napi] + pub fn plugin(&self, plugin_name: String) -> Option { + self.0.plugin(&plugin_name).map(Into::into) + } + + #[napi] + pub fn loaded_plugins(&self) -> Vec { + self.0 + .loaded_plugins() + .into_iter() + .map(Into::into) + .collect() + } + + #[napi] + pub fn sort_plugins(&self, plugin_names: Vec) -> Result, VerboseError> { + Ok(self.0.sort_plugins(&as_strs(&plugin_names))?) + } + + #[napi] + pub fn load_current_load_order_state(&mut self) -> Result<(), VerboseError> { + self.0.load_current_load_order_state()?; + Ok(()) + } + + #[napi] + pub fn is_load_order_ambiguous(&self) -> Result { + Ok(self.0.is_load_order_ambiguous()?) + } + + #[napi] + pub fn active_plugins_file_path(&self) -> String { + self.0 + .active_plugins_file_path() + .to_string_lossy() + .to_string() + } + + #[napi] + pub fn is_plugin_active(&self, plugin_name: String) -> bool { + self.0.is_plugin_active(&plugin_name) + } + + #[napi] + pub fn load_order(&self) -> Vec<&str> { + self.0.load_order() + } + + #[napi] + pub fn set_load_order(&mut self, load_order: Vec) -> Result<(), VerboseError> { + self.0.set_load_order(&as_strs(&load_order))?; + Ok(()) + } +} + +fn as_paths(pathbufs: &[String]) -> Vec<&Path> { + pathbufs.iter().map(Path::new).collect() +} + +fn as_strs(strings: &[String]) -> Vec<&str> { + strings.iter().map(String::as_ref).collect() +} diff --git a/nodejs/src/lib.rs b/nodejs/src/lib.rs new file mode 100644 index 00000000..c5e616ff --- /dev/null +++ b/nodejs/src/lib.rs @@ -0,0 +1,198 @@ +// Deny some rustc lints that are allow-by-default. +#![deny( + ambiguous_negative_literals, + impl_trait_overcaptures, + let_underscore_drop, + missing_copy_implementations, + missing_debug_implementations, + non_ascii_idents, + redundant_imports, + redundant_lifetimes, + trivial_casts, + trivial_numeric_casts, + unit_bindings, + unreachable_pub, + unsafe_code +)] +#![deny(clippy::pedantic)] +#![allow(clippy::missing_errors_doc)] +#![allow(clippy::must_use_candidate)] +#![allow(clippy::needless_pass_by_value)] +// Selectively deny clippy restriction lints. +#![deny( + clippy::allow_attributes, + clippy::as_conversions, + clippy::as_underscore, + clippy::assertions_on_result_states, + clippy::big_endian_bytes, + clippy::clone_on_ref_ptr, + clippy::create_dir, + clippy::dbg_macro, + clippy::decimal_literal_representation, + clippy::default_numeric_fallback, + clippy::doc_include_without_cfg, + clippy::empty_drop, + clippy::error_impl_error, + clippy::exit, + // clippy::exhaustive_enums, + clippy::expect_used, + clippy::filetype_is_file, + clippy::float_cmp_const, + clippy::fn_to_numeric_cast_any, + clippy::get_unwrap, + clippy::host_endian_bytes, + clippy::if_then_some_else_none, + clippy::indexing_slicing, + clippy::infinite_loop, + clippy::integer_division, + clippy::integer_division_remainder_used, + clippy::iter_over_hash_type, + clippy::let_underscore_must_use, + clippy::lossy_float_literal, + clippy::map_err_ignore, + clippy::map_with_unused_argument_over_ranges, + clippy::mem_forget, + clippy::missing_assert_message, + clippy::missing_asserts_for_indexing, + clippy::mixed_read_write_in_expression, + clippy::multiple_inherent_impl, + clippy::multiple_unsafe_ops_per_block, + clippy::mutex_atomic, + clippy::mutex_integer, + clippy::needless_raw_strings, + clippy::non_ascii_literal, + clippy::non_zero_suggestions, + clippy::panic, + clippy::panic_in_result_fn, + clippy::partial_pub_fields, + clippy::pathbuf_init_then_push, + clippy::precedence_bits, + clippy::print_stderr, + clippy::print_stdout, + clippy::rc_buffer, + clippy::rc_mutex, + clippy::redundant_type_annotations, + clippy::ref_patterns, + clippy::rest_pat_in_fully_bound_structs, + clippy::str_to_string, + clippy::string_lit_chars_any, + clippy::string_slice, + clippy::string_to_string, + clippy::suspicious_xor_used_as_pow, + clippy::tests_outside_test_module, + clippy::todo, + clippy::try_err, + clippy::undocumented_unsafe_blocks, + clippy::unimplemented, + clippy::unnecessary_safety_comment, + clippy::unneeded_field_pattern, + clippy::unreachable, + clippy::unused_result_ok, + clippy::unwrap_in_result, + clippy::unwrap_used, + clippy::use_debug, + clippy::verbose_file_reads, + clippy::wildcard_enum_match_arm +)] + +mod database; +mod error; +mod game; +mod metadata; +mod plugin; + +use napi::{ + threadsafe_function::{ErrorStrategy, ThreadsafeFunction, ThreadsafeFunctionCallMode}, + Either, +}; +use napi_derive::napi; + +pub use metadata::select_message_content; + +#[napi] +pub fn is_compatible(major: u32, minor: u32, patch: u32) -> bool { + libloot::is_compatible(major, minor, patch) +} + +#[napi] +pub fn libloot_revision() -> String { + libloot::libloot_revision() +} + +#[napi] +pub fn libloot_version() -> String { + libloot::libloot_version() +} + +#[napi] +pub const LIBLOOT_VERSION_MAJOR: u32 = libloot::LIBLOOT_VERSION_MAJOR; + +#[napi] +pub const LIBLOOT_VERSION_MINOR: u32 = libloot::LIBLOOT_VERSION_MINOR; + +#[napi] +pub const LIBLOOT_VERSION_PATCH: u32 = libloot::LIBLOOT_VERSION_PATCH; + +#[napi] +#[derive(Debug)] +pub enum LogLevel { + Trace, + Debug, + Info, + Warning, + Error, +} + +impl From for libloot::LogLevel { + fn from(value: LogLevel) -> Self { + match value { + LogLevel::Trace => libloot::LogLevel::Trace, + LogLevel::Debug => libloot::LogLevel::Debug, + LogLevel::Info => libloot::LogLevel::Info, + LogLevel::Warning => libloot::LogLevel::Warning, + LogLevel::Error => libloot::LogLevel::Error, + } + } +} + +impl From for LogLevel { + fn from(value: libloot::LogLevel) -> Self { + match value { + libloot::LogLevel::Trace => LogLevel::Trace, + libloot::LogLevel::Debug => LogLevel::Debug, + libloot::LogLevel::Info => LogLevel::Info, + libloot::LogLevel::Warning => LogLevel::Warning, + libloot::LogLevel::Error => LogLevel::Error, + } + } +} + +#[napi] +pub fn set_log_level(level: LogLevel) { + libloot::set_log_level(level.into()); +} + +#[napi(ts_args_type = "callback: (logLevel: LogLevel, message: string) => void")] +pub fn set_logging_callback(callback: napi::JsFunction) -> napi::Result<()> { + let thread_safe_callback: ThreadsafeFunction< + (libloot::LogLevel, String), + ErrorStrategy::Fatal, + > = callback.create_threadsafe_function(0, |ctx| { + let (level, message): (libloot::LogLevel, String) = ctx.value; + + Ok(vec![ + Either::A::(level.into()), + Either::B(message), + ]) + })?; + + let rust_callback = move |level: libloot::LogLevel, message: &str| { + thread_safe_callback.call( + (level, message.to_owned()), + ThreadsafeFunctionCallMode::Blocking, + ); + }; + + libloot::set_logging_callback(rust_callback); + Ok(()) +} diff --git a/nodejs/src/metadata.rs b/nodejs/src/metadata.rs new file mode 100644 index 00000000..2b113be4 --- /dev/null +++ b/nodejs/src/metadata.rs @@ -0,0 +1,668 @@ +use napi::Either; +use napi_derive::napi; + +use crate::error::VerboseError; + +#[napi] +#[derive(Clone, Debug)] +#[repr(transparent)] +pub struct Group(libloot::metadata::Group); + +#[napi] +impl Group { + #[napi] + pub fn default_name() -> &'static str { + libloot::metadata::Group::DEFAULT_NAME + } + + #[napi(constructor)] + pub fn new( + name: String, + description: Option, + after_groups: Option>, + ) -> Self { + let mut group = libloot::metadata::Group::new(name); + + if let Some(description) = description { + group = group.with_description(description); + } + + if let Some(after_groups) = after_groups { + group = group.with_after_groups(after_groups); + } + + Self(group) + } + + #[napi(getter)] + pub fn name(&self) -> &str { + self.0.name() + } + + #[napi(getter)] + pub fn description(&self) -> Option<&str> { + self.0.description() + } + + #[napi(getter)] + pub fn after_groups(&self) -> Vec { + self.0.after_groups().to_vec() + } +} + +impl From for Group { + fn from(value: libloot::metadata::Group) -> Self { + Self(value) + } +} + +impl From for libloot::metadata::Group { + fn from(value: Group) -> Self { + value.0 + } +} + +#[napi] +#[derive(Clone, Debug)] +#[repr(transparent)] +pub struct MessageContent(libloot::metadata::MessageContent); + +#[napi] +impl MessageContent { + #[napi] + pub fn default_language() -> &'static str { + libloot::metadata::MessageContent::DEFAULT_LANGUAGE + } + + #[napi(constructor)] + pub fn new(text: String, language: Option) -> Self { + let mut content = libloot::metadata::MessageContent::new(text); + + if let Some(language) = language { + content = content.with_language(language); + } + + Self(content) + } + + #[napi(getter)] + pub fn text(&self) -> &str { + self.0.text() + } + + #[napi(getter)] + pub fn language(&self) -> &str { + self.0.language() + } +} + +impl From for MessageContent { + fn from(value: libloot::metadata::MessageContent) -> Self { + Self(value) + } +} + +impl From for libloot::metadata::MessageContent { + fn from(value: MessageContent) -> Self { + value.0 + } +} + +#[napi] +pub fn select_message_content( + content: Vec<&MessageContent>, + language: String, +) -> Option { + let content: Vec<_> = content.into_iter().cloned().map(Into::into).collect(); + libloot::metadata::select_message_content(&content, &language) + .cloned() + .map(Into::into) +} + +#[napi] +#[derive(Debug)] +pub enum MessageType { + Say, + Warn, + Error, +} + +impl From for MessageType { + fn from(value: libloot::metadata::MessageType) -> Self { + match value { + libloot::metadata::MessageType::Say => MessageType::Say, + libloot::metadata::MessageType::Warn => MessageType::Warn, + libloot::metadata::MessageType::Error => MessageType::Error, + } + } +} + +impl From for libloot::metadata::MessageType { + fn from(value: MessageType) -> Self { + match value { + MessageType::Say => libloot::metadata::MessageType::Say, + MessageType::Warn => libloot::metadata::MessageType::Warn, + MessageType::Error => libloot::metadata::MessageType::Error, + } + } +} + +#[napi] +#[derive(Clone, Debug)] +#[repr(transparent)] +pub struct Message(libloot::metadata::Message); + +#[napi] +impl Message { + #[napi(constructor)] + pub fn new( + message_type: MessageType, + contents: Either>, + condition: Option, + ) -> Result { + let mut message = match contents { + Either::A(c) => libloot::metadata::Message::new(message_type.into(), c), + Either::B(c) => { + let c = c.into_iter().cloned().map(Into::into).collect(); + libloot::metadata::Message::multilingual(message_type.into(), c)? + } + }; + + if let Some(condition) = condition { + message = message.with_condition(condition); + } + + Ok(Self(message)) + } + + #[napi(getter)] + pub fn message_type(&self) -> MessageType { + self.0.message_type().into() + } + + #[napi(getter)] + pub fn content(&self) -> Vec { + self.0.content().iter().cloned().map(Into::into).collect() + } + + #[napi(getter)] + pub fn condition(&self) -> Option<&str> { + self.0.condition() + } +} + +impl From for Message { + fn from(value: libloot::metadata::Message) -> Self { + Self(value) + } +} + +impl From for libloot::metadata::Message { + fn from(value: Message) -> Self { + value.0 + } +} + +#[napi] +#[derive(Clone, Debug)] +#[repr(transparent)] +pub struct File(libloot::metadata::File); + +#[napi] +impl File { + #[napi(constructor)] + pub fn new( + name: String, + display_name: Option, + detail: Option>, + condition: Option, + constraint: Option, + ) -> Result { + let mut file = libloot::metadata::File::new(name); + + if let Some(display_name) = display_name { + file = file.with_display_name(display_name); + } + + if let Some(detail) = detail { + let detail = detail.into_iter().cloned().map(Into::into).collect(); + file = file.with_detail(detail)?; + } + + if let Some(condition) = condition { + file = file.with_condition(condition); + } + + if let Some(constraint) = constraint { + file = file.with_constraint(constraint); + } + + Ok(Self(file)) + } + + #[napi(getter)] + pub fn name(&self) -> Filename { + self.0.name().clone().into() + } + + #[napi(getter)] + pub fn display_name(&self) -> Option<&str> { + self.0.display_name() + } + + #[napi(getter)] + pub fn detail(&self) -> Vec { + self.0.detail().iter().cloned().map(Into::into).collect() + } + + #[napi(getter)] + pub fn condition(&self) -> Option<&str> { + self.0.condition() + } + + #[napi(getter)] + pub fn constraint(&self) -> Option<&str> { + self.0.constraint() + } +} + +impl From for File { + fn from(value: libloot::metadata::File) -> Self { + Self(value) + } +} + +impl From for libloot::metadata::File { + fn from(value: File) -> Self { + value.0 + } +} + +#[napi] +#[derive(Debug)] +#[repr(transparent)] +pub struct Filename(libloot::metadata::Filename); + +#[napi] +impl Filename { + #[napi(constructor)] + pub fn new(name: String) -> Self { + Self(libloot::metadata::Filename::new(name)) + } + + #[napi] + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +impl From for Filename { + fn from(value: libloot::metadata::Filename) -> Self { + Self(value) + } +} + +#[napi] +#[derive(Clone, Debug)] +#[repr(transparent)] +pub struct PluginCleaningData(libloot::metadata::PluginCleaningData); + +#[napi] +impl PluginCleaningData { + #[napi(constructor)] + pub fn new( + crc: u32, + cleaning_utility: String, + itm_count: Option, + deleted_reference_count: Option, + deleted_navmesh_count: Option, + detail: Option>, + ) -> Result { + let mut data = libloot::metadata::PluginCleaningData::new(crc, cleaning_utility); + + if let Some(count) = itm_count { + data = data.with_itm_count(count); + } + + if let Some(count) = deleted_reference_count { + data = data.with_deleted_reference_count(count); + } + + if let Some(count) = deleted_navmesh_count { + data = data.with_deleted_navmesh_count(count); + } + + if let Some(detail) = detail { + let detail = detail.into_iter().cloned().map(Into::into).collect(); + data = data.with_detail(detail)?; + } + + Ok(Self(data)) + } + + #[napi(getter)] + pub fn crc(&self) -> u32 { + self.0.crc() + } + + #[napi(getter)] + pub fn itm_count(&self) -> u32 { + self.0.itm_count() + } + + #[napi(getter)] + pub fn deleted_reference_count(&self) -> u32 { + self.0.deleted_reference_count() + } + + #[napi(getter)] + pub fn deleted_navmesh_count(&self) -> u32 { + self.0.deleted_navmesh_count() + } + + #[napi(getter)] + pub fn cleaning_utility(&self) -> &str { + self.0.cleaning_utility() + } + + #[napi(getter)] + pub fn detail(&self) -> Vec { + self.0.detail().iter().cloned().map(Into::into).collect() + } +} + +impl From for PluginCleaningData { + fn from(value: libloot::metadata::PluginCleaningData) -> Self { + Self(value) + } +} + +impl From for libloot::metadata::PluginCleaningData { + fn from(value: PluginCleaningData) -> Self { + value.0 + } +} + +#[napi] +#[derive(Debug)] +pub enum TagSuggestion { + Addition, + Removal, +} + +impl From for libloot::metadata::TagSuggestion { + fn from(value: TagSuggestion) -> Self { + match value { + TagSuggestion::Addition => libloot::metadata::TagSuggestion::Addition, + TagSuggestion::Removal => libloot::metadata::TagSuggestion::Removal, + } + } +} + +#[napi] +#[derive(Clone, Debug)] +#[repr(transparent)] +pub struct Tag(libloot::metadata::Tag); + +#[napi] +impl Tag { + #[napi(constructor)] + pub fn new(name: String, suggestion: TagSuggestion, condition: Option) -> Self { + let mut tag = libloot::metadata::Tag::new(name, suggestion.into()); + + if let Some(condition) = condition { + tag = tag.with_condition(condition); + } + + Self(tag) + } + + #[napi(getter)] + pub fn name(&self) -> &str { + self.0.name() + } + + #[napi(getter)] + pub fn is_addition(&self) -> bool { + self.0.is_addition() + } + + #[napi(getter)] + pub fn condition(&self) -> Option<&str> { + self.0.condition() + } +} + +impl From for Tag { + fn from(value: libloot::metadata::Tag) -> Self { + Self(value) + } +} + +impl From for libloot::metadata::Tag { + fn from(value: Tag) -> Self { + value.0 + } +} + +#[napi] +#[derive(Clone, Debug)] +#[repr(transparent)] +pub struct Location(libloot::metadata::Location); + +#[napi] +impl Location { + #[napi(constructor)] + pub fn new(url: String, name: Option) -> Self { + let mut location = libloot::metadata::Location::new(url); + + if let Some(name) = name { + location = location.with_name(name); + } + + Self(location) + } + + #[napi(getter)] + pub fn url(&self) -> &str { + self.0.url() + } + + #[napi(getter)] + pub fn name(&self) -> Option<&str> { + self.0.name() + } +} + +impl From for Location { + fn from(value: libloot::metadata::Location) -> Self { + Self(value) + } +} + +impl From for libloot::metadata::Location { + fn from(value: Location) -> Self { + value.0 + } +} + +#[napi] +#[derive(Clone, Debug)] +#[repr(transparent)] +pub struct PluginMetadata(libloot::metadata::PluginMetadata); + +#[napi] +impl PluginMetadata { + #[napi(constructor)] + pub fn new(name: String) -> Result { + Ok(Self(libloot::metadata::PluginMetadata::new(&name)?)) + } + + #[napi(getter)] + pub fn name(&self) -> &str { + self.0.name() + } + + #[napi(getter)] + pub fn group(&self) -> Option<&str> { + self.0.group() + } + + #[napi(getter)] + pub fn load_after_files(&self) -> Vec { + self.0 + .load_after_files() + .iter() + .cloned() + .map(Into::into) + .collect() + } + + #[napi(getter)] + pub fn requirements(&self) -> Vec { + self.0 + .requirements() + .iter() + .cloned() + .map(Into::into) + .collect() + } + + #[napi(getter)] + pub fn incompatibilities(&self) -> Vec { + self.0 + .incompatibilities() + .iter() + .cloned() + .map(Into::into) + .collect() + } + + #[napi(getter)] + pub fn messages(&self) -> Vec { + self.0.messages().iter().cloned().map(Into::into).collect() + } + + #[napi(getter)] + pub fn tags(&self) -> Vec { + self.0.tags().iter().cloned().map(Into::into).collect() + } + + #[napi(getter)] + pub fn dirty_info(&self) -> Vec { + self.0 + .dirty_info() + .iter() + .cloned() + .map(Into::into) + .collect() + } + + #[napi(getter)] + pub fn clean_info(&self) -> Vec { + self.0 + .clean_info() + .iter() + .cloned() + .map(Into::into) + .collect() + } + + #[napi(getter)] + pub fn locations(&self) -> Vec { + self.0.locations().iter().cloned().map(Into::into).collect() + } + + #[napi(setter)] + pub fn set_group(&mut self, group: Option) { + match group { + Some(g) => self.0.set_group(g), + None => self.0.unset_group(), + } + } + + #[napi(setter)] + pub fn set_load_after_files(&mut self, value: Vec<&File>) { + let value = value.into_iter().cloned().map(Into::into).collect(); + self.0.set_load_after_files(value); + } + + #[napi(setter)] + pub fn set_requirements(&mut self, value: Vec<&File>) { + let value = value.into_iter().cloned().map(Into::into).collect(); + self.0.set_requirements(value); + } + + #[napi(setter)] + pub fn set_incompatibilities(&mut self, value: Vec<&File>) { + let value = value.into_iter().cloned().map(Into::into).collect(); + self.0.set_incompatibilities(value); + } + + #[napi(setter)] + pub fn set_messages(&mut self, value: Vec<&Message>) { + let value = value.into_iter().cloned().map(Into::into).collect(); + self.0.set_messages(value); + } + + #[napi(setter)] + pub fn set_tags(&mut self, value: Vec<&Tag>) { + let value = value.into_iter().cloned().map(Into::into).collect(); + self.0.set_tags(value); + } + + #[napi(setter)] + pub fn set_dirty_info(&mut self, value: Vec<&PluginCleaningData>) { + let value = value.into_iter().cloned().map(Into::into).collect(); + self.0.set_dirty_info(value); + } + + #[napi(setter)] + pub fn set_clean_info(&mut self, value: Vec<&PluginCleaningData>) { + let value = value.into_iter().cloned().map(Into::into).collect(); + self.0.set_clean_info(value); + } + + #[napi(setter)] + pub fn set_locations(&mut self, value: Vec<&Location>) { + let value = value.into_iter().cloned().map(Into::into).collect(); + self.0.set_locations(value); + } + + #[napi] + pub fn merge_metadata(&mut self, other: &PluginMetadata) { + self.0.merge_metadata(&other.0); + } + + #[napi] + pub fn has_name_only(&self) -> bool { + self.0.has_name_only() + } + + #[napi] + pub fn is_regex_plugin(&self) -> bool { + self.0.is_regex_plugin() + } + + #[napi] + pub fn name_matches(&self, other_name: String) -> bool { + self.0.name_matches(&other_name) + } + + #[napi] + pub fn as_yaml(&self) -> String { + self.0.as_yaml() + } +} + +impl From for PluginMetadata { + fn from(value: libloot::metadata::PluginMetadata) -> Self { + Self(value) + } +} + +impl From for libloot::metadata::PluginMetadata { + fn from(value: PluginMetadata) -> Self { + value.0 + } +} diff --git a/nodejs/src/plugin.rs b/nodejs/src/plugin.rs new file mode 100644 index 00000000..876e0b68 --- /dev/null +++ b/nodejs/src/plugin.rs @@ -0,0 +1,104 @@ +use std::sync::Arc; + +use napi_derive::napi; + +use crate::error::VerboseError; + +#[napi] +#[derive(Clone, Debug, Eq, PartialEq)] +#[repr(transparent)] +pub struct Plugin(Arc); + +#[napi] +impl Plugin { + #[napi] + pub fn name(&self) -> &str { + self.0.name() + } + + #[napi] + pub fn header_version(&self) -> Option { + self.0.header_version() + } + + #[napi] + pub fn version(&self) -> Option<&str> { + self.0.version() + } + + #[napi] + pub fn masters(&self) -> Result, VerboseError> { + Ok(self.0.masters()?) + } + + #[napi] + pub fn bash_tags(&self) -> Vec { + self.0.bash_tags().to_vec() + } + + #[napi] + pub fn crc(&self) -> Option { + self.0.crc() + } + + #[napi] + pub fn is_master(&self) -> bool { + self.0.is_master() + } + + #[napi] + pub fn is_light_plugin(&self) -> bool { + self.0.is_light_plugin() + } + + #[napi] + pub fn is_medium_plugin(&self) -> bool { + self.0.is_medium_plugin() + } + + #[napi] + pub fn is_update_plugin(&self) -> bool { + self.0.is_update_plugin() + } + + #[napi] + pub fn is_blueprint_plugin(&self) -> bool { + self.0.is_blueprint_plugin() + } + + #[napi] + pub fn is_valid_as_light_plugin(&self) -> Result { + Ok(self.0.is_valid_as_light_plugin()?) + } + + #[napi] + pub fn is_valid_as_medium_plugin(&self) -> Result { + Ok(self.0.is_valid_as_medium_plugin()?) + } + + #[napi] + pub fn is_valid_as_update_plugin(&self) -> Result { + Ok(self.0.is_valid_as_update_plugin()?) + } + + #[napi] + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + #[napi] + pub fn loads_archive(&self) -> bool { + self.0.loads_archive() + } + + #[napi] + pub fn do_records_overlap(&self, plugin: &Plugin) -> Result { + Ok(self.0.do_records_overlap(&plugin.0)?) + } +} + +impl From> for Plugin { + fn from(value: Arc) -> Self { + Self(value) + } +} diff --git a/nodejs/yarn.lock b/nodejs/yarn.lock new file mode 100644 index 00000000..74679bca --- /dev/null +++ b/nodejs/yarn.lock @@ -0,0 +1,1026 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@isaacs/fs-minipass@^4.0.0": + version "4.0.1" + resolved "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz" + integrity sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w== + dependencies: + minipass "^7.0.4" + +"@mapbox/node-pre-gyp@^2.0.0-rc.0": + version "2.0.0" + resolved "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-2.0.0.tgz" + integrity sha512-llMXd39jtP0HpQLVI37Bf1m2ADlEb35GYSh1SDSLsBhR+5iCxiNGlT31yqbNtVHygHAtMy6dWFERpU2JgufhPg== + dependencies: + consola "^3.2.3" + detect-libc "^2.0.0" + https-proxy-agent "^7.0.5" + node-fetch "^2.6.7" + nopt "^8.0.0" + semver "^7.5.3" + tar "^7.4.0" + +"@napi-rs/cli@^2.18.4": + version "2.18.4" + resolved "https://registry.npmjs.org/@napi-rs/cli/-/cli-2.18.4.tgz" + integrity sha512-SgJeA4df9DE2iAEpr3M2H0OKl/yjtg1BnRI5/JyowS71tUWhrfSu2LT0V3vlHET+g1hBVlrO60PmEXwUEKp8Mg== + +"@nodelib/fs.scandir@2.1.5": + version "2.1.5" + resolved "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz" + integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g== + dependencies: + "@nodelib/fs.stat" "2.0.5" + run-parallel "^1.1.9" + +"@nodelib/fs.stat@^2.0.2", "@nodelib/fs.stat@2.0.5": + version "2.0.5" + resolved "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz" + integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== + +"@nodelib/fs.walk@^1.2.3": + version "1.2.8" + resolved "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz" + integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg== + dependencies: + "@nodelib/fs.scandir" "2.1.5" + fastq "^1.6.0" + +"@rollup/pluginutils@^5.1.3": + version "5.1.4" + resolved "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz" + integrity sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ== + dependencies: + "@types/estree" "^1.0.0" + estree-walker "^2.0.2" + picomatch "^4.0.2" + +"@sindresorhus/merge-streams@^2.1.0": + version "2.3.0" + resolved "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz" + integrity sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg== + +"@types/estree@^1.0.0": + version "1.0.7" + resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz" + integrity sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ== + +"@vercel/nft@^0.27.5": + version "0.27.10" + resolved "https://registry.npmjs.org/@vercel/nft/-/nft-0.27.10.tgz" + integrity sha512-zbaF9Wp/NsZtKLE4uVmL3FyfFwlpDyuymQM1kPbeT0mVOHKDQQNjnnfslB3REg3oZprmNFJuh3pkHBk2qAaizg== + dependencies: + "@mapbox/node-pre-gyp" "^2.0.0-rc.0" + "@rollup/pluginutils" "^5.1.3" + acorn "^8.6.0" + acorn-import-attributes "^1.9.5" + async-sema "^3.1.1" + bindings "^1.4.0" + estree-walker "2.0.2" + glob "^7.1.3" + graceful-fs "^4.2.9" + node-gyp-build "^4.2.2" + picomatch "^4.0.2" + resolve-from "^5.0.0" + +abbrev@^3.0.0: + version "3.0.1" + resolved "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz" + integrity sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg== + +acorn-import-attributes@^1.9.5: + version "1.9.5" + resolved "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz" + integrity sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ== + +acorn-walk@^8.3.4: + version "8.3.4" + resolved "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz" + integrity sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g== + dependencies: + acorn "^8.11.0" + +acorn@^8, acorn@^8.11.0, acorn@^8.13.0, acorn@^8.6.0: + version "8.14.1" + resolved "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz" + integrity sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg== + +agent-base@^7.1.2: + version "7.1.3" + resolved "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz" + integrity sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw== + +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + +ansi-regex@^6.0.1: + version "6.1.0" + resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz" + integrity sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA== + +ansi-styles@^4.0.0: + version "4.3.0" + resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" + +ansi-styles@^6.0.0, ansi-styles@^6.2.1: + version "6.2.1" + resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz" + integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug== + +argparse@^1.0.7: + version "1.0.10" + resolved "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz" + integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== + dependencies: + sprintf-js "~1.0.2" + +array-find-index@^1.0.1: + version "1.0.2" + resolved "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.2.tgz" + integrity sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw== + +arrgv@^1.0.2: + version "1.0.2" + resolved "https://registry.npmjs.org/arrgv/-/arrgv-1.0.2.tgz" + integrity sha512-a4eg4yhp7mmruZDQFqVMlxNRFGi/i1r87pt8SDHy0/I8PqSXoUTlWZRdAZo0VXgvEARcujbtTk8kiZRi1uDGRw== + +arrify@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/arrify/-/arrify-3.0.0.tgz" + integrity sha512-tLkvA81vQG/XqE2mjDkGQHoOINtMHtysSnemrmoGe6PydDPMRbVugqyk4A6V/WDWEfm3l+0d8anA9r8cv/5Jaw== + +async-sema@^3.1.1: + version "3.1.1" + resolved "https://registry.npmjs.org/async-sema/-/async-sema-3.1.1.tgz" + integrity sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg== + +ava@^6.0.1: + version "6.2.0" + resolved "https://registry.npmjs.org/ava/-/ava-6.2.0.tgz" + integrity sha512-+GZk5PbyepjiO/68hzCZCUepQOQauKfNnI7sA4JukBTg97jD7E+tDKEA7OhGOGr6EorNNMM9+jqvgHVOTOzG4w== + dependencies: + "@vercel/nft" "^0.27.5" + acorn "^8.13.0" + acorn-walk "^8.3.4" + ansi-styles "^6.2.1" + arrgv "^1.0.2" + arrify "^3.0.0" + callsites "^4.2.0" + cbor "^9.0.2" + chalk "^5.3.0" + chunkd "^2.0.1" + ci-info "^4.0.0" + ci-parallel-vars "^1.0.1" + cli-truncate "^4.0.0" + code-excerpt "^4.0.0" + common-path-prefix "^3.0.0" + concordance "^5.0.4" + currently-unhandled "^0.4.1" + debug "^4.3.7" + emittery "^1.0.3" + figures "^6.1.0" + globby "^14.0.2" + ignore-by-default "^2.1.0" + indent-string "^5.0.0" + is-plain-object "^5.0.0" + is-promise "^4.0.0" + matcher "^5.0.0" + memoize "^10.0.0" + ms "^2.1.3" + p-map "^7.0.2" + package-config "^5.0.0" + picomatch "^4.0.2" + plur "^5.1.0" + pretty-ms "^9.1.0" + resolve-cwd "^3.0.0" + stack-utils "^2.0.6" + strip-ansi "^7.1.0" + supertap "^3.0.1" + temp-dir "^3.0.0" + write-file-atomic "^6.0.0" + yargs "^17.7.2" + +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + +bindings@^1.4.0: + version "1.5.0" + resolved "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz" + integrity sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ== + dependencies: + file-uri-to-path "1.0.0" + +blueimp-md5@^2.10.0: + version "2.19.0" + resolved "https://registry.npmjs.org/blueimp-md5/-/blueimp-md5-2.19.0.tgz" + integrity sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w== + +brace-expansion@^1.1.7: + version "1.1.11" + resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz" + integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +braces@^3.0.3: + version "3.0.3" + resolved "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz" + integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== + dependencies: + fill-range "^7.1.1" + +callsites@^4.2.0: + version "4.2.0" + resolved "https://registry.npmjs.org/callsites/-/callsites-4.2.0.tgz" + integrity sha512-kfzR4zzQtAE9PC7CzZsjl3aBNbXWuXiSeOCdLcPpBfGW8YuCqQHcRPFDbr/BPVmd3EEPVpuFzLyuT/cUhPr4OQ== + +cbor@^9.0.2: + version "9.0.2" + resolved "https://registry.npmjs.org/cbor/-/cbor-9.0.2.tgz" + integrity sha512-JPypkxsB10s9QOWwa6zwPzqE1Md3vqpPc+cai4sAecuCsRyAtAl/pMyhPlMbT/xtPnm2dznJZYRLui57qiRhaQ== + dependencies: + nofilter "^3.1.0" + +chalk@^5.3.0: + version "5.4.1" + resolved "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz" + integrity sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w== + +chownr@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz" + integrity sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g== + +chunkd@^2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/chunkd/-/chunkd-2.0.1.tgz" + integrity sha512-7d58XsFmOq0j6el67Ug9mHf9ELUXsQXYJBkyxhH/k+6Ke0qXRnv0kbemx+Twc6fRJ07C49lcbdgm9FL1Ei/6SQ== + +ci-info@^4.0.0: + version "4.2.0" + resolved "https://registry.npmjs.org/ci-info/-/ci-info-4.2.0.tgz" + integrity sha512-cYY9mypksY8NRqgDB1XD1RiJL338v/551niynFTGkZOO2LHuB2OmOYxDIe/ttN9AHwrqdum1360G3ald0W9kCg== + +ci-parallel-vars@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/ci-parallel-vars/-/ci-parallel-vars-1.0.1.tgz" + integrity sha512-uvzpYrpmidaoxvIQHM+rKSrigjOe9feHYbw4uOI2gdfe1C3xIlxO+kVXq83WQWNniTf8bAxVpy+cQeFQsMERKg== + +cli-truncate@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz" + integrity sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA== + dependencies: + slice-ansi "^5.0.0" + string-width "^7.0.0" + +cliui@^8.0.1: + version "8.0.1" + resolved "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz" + integrity sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.1" + wrap-ansi "^7.0.0" + +code-excerpt@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz" + integrity sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA== + dependencies: + convert-to-spaces "^2.0.1" + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +common-path-prefix@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz" + integrity sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w== + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz" + integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== + +concordance@^5.0.4: + version "5.0.4" + resolved "https://registry.npmjs.org/concordance/-/concordance-5.0.4.tgz" + integrity sha512-OAcsnTEYu1ARJqWVGwf4zh4JDfHZEaSNlNccFmt8YjB2l/n19/PF2viLINHc57vO4FKIAFl2FWASIGZZWZ2Kxw== + dependencies: + date-time "^3.1.0" + esutils "^2.0.3" + fast-diff "^1.2.0" + js-string-escape "^1.0.1" + lodash "^4.17.15" + md5-hex "^3.0.1" + semver "^7.3.2" + well-known-symbols "^2.0.0" + +consola@^3.2.3: + version "3.4.2" + resolved "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz" + integrity sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA== + +convert-to-spaces@^2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz" + integrity sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ== + +currently-unhandled@^0.4.1: + version "0.4.1" + resolved "https://registry.npmjs.org/currently-unhandled/-/currently-unhandled-0.4.1.tgz" + integrity sha512-/fITjgjGU50vjQ4FH6eUoYu+iUoUKIXws2hL15JJpIR+BbTxaXQsMuuyjtNh2WqsSBS5nsaZHFsFecyw5CCAng== + dependencies: + array-find-index "^1.0.1" + +date-time@^3.1.0: + version "3.1.0" + resolved "https://registry.npmjs.org/date-time/-/date-time-3.1.0.tgz" + integrity sha512-uqCUKXE5q1PNBXjPqvwhwJf9SwMoAHBgWJ6DcrnS5o+W2JOiIILl0JEdVD8SGujrNS02GGxgwAg2PN2zONgtjg== + dependencies: + time-zone "^1.0.0" + +debug@^4.3.7, debug@4: + version "4.4.0" + resolved "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz" + integrity sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA== + dependencies: + ms "^2.1.3" + +detect-libc@^2.0.0: + version "2.0.3" + resolved "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz" + integrity sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw== + +emittery@^1.0.3: + version "1.1.0" + resolved "https://registry.npmjs.org/emittery/-/emittery-1.1.0.tgz" + integrity sha512-rsX7ktqARv/6UQDgMaLfIqUWAEzzbCQiVh7V9rhDXp6c37yoJcks12NVD+XPkgl4AEavmNhVfrhGoqYwIsMYYA== + +emoji-regex@^10.3.0: + version "10.4.0" + resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz" + integrity sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw== + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +escalade@^3.1.1: + version "3.2.0" + resolved "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz" + integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== + +escape-string-regexp@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz" + integrity sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w== + +escape-string-regexp@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz" + integrity sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw== + +esprima@^4.0.0: + version "4.0.1" + resolved "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz" + integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== + +estree-walker@^2.0.2, estree-walker@2.0.2: + version "2.0.2" + resolved "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz" + integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w== + +esutils@^2.0.3: + version "2.0.3" + resolved "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz" + integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== + +fast-diff@^1.2.0: + version "1.3.0" + resolved "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz" + integrity sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw== + +fast-glob@^3.3.3: + version "3.3.3" + resolved "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz" + integrity sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg== + dependencies: + "@nodelib/fs.stat" "^2.0.2" + "@nodelib/fs.walk" "^1.2.3" + glob-parent "^5.1.2" + merge2 "^1.3.0" + micromatch "^4.0.8" + +fastq@^1.6.0: + version "1.19.1" + resolved "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz" + integrity sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ== + dependencies: + reusify "^1.0.4" + +figures@^6.1.0: + version "6.1.0" + resolved "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz" + integrity sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg== + dependencies: + is-unicode-supported "^2.0.0" + +file-uri-to-path@1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz" + integrity sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw== + +fill-range@^7.1.1: + version "7.1.1" + resolved "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz" + integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== + dependencies: + to-regex-range "^5.0.1" + +find-up-simple@^1.0.0: + version "1.0.1" + resolved "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.1.tgz" + integrity sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ== + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz" + integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== + +get-caller-file@^2.0.5: + version "2.0.5" + resolved "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz" + integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== + +get-east-asian-width@^1.0.0: + version "1.3.0" + resolved "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz" + integrity sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ== + +glob-parent@^5.1.2: + version "5.1.2" + resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz" + integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== + dependencies: + is-glob "^4.0.1" + +glob@^7.1.3: + version "7.2.3" + resolved "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz" + integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.1.1" + once "^1.3.0" + path-is-absolute "^1.0.0" + +globby@^14.0.2: + version "14.1.0" + resolved "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz" + integrity sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA== + dependencies: + "@sindresorhus/merge-streams" "^2.1.0" + fast-glob "^3.3.3" + ignore "^7.0.3" + path-type "^6.0.0" + slash "^5.1.0" + unicorn-magic "^0.3.0" + +graceful-fs@^4.2.9: + version "4.2.11" + resolved "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz" + integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== + +https-proxy-agent@^7.0.5: + version "7.0.6" + resolved "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz" + integrity sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw== + dependencies: + agent-base "^7.1.2" + debug "4" + +ignore-by-default@^2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-2.1.0.tgz" + integrity sha512-yiWd4GVmJp0Q6ghmM2B/V3oZGRmjrKLXvHR3TE1nfoXsmoggllfZUQe74EN0fJdPFZu2NIvNdrMMLm3OsV7Ohw== + +ignore@^7.0.3: + version "7.0.3" + resolved "https://registry.npmjs.org/ignore/-/ignore-7.0.3.tgz" + integrity sha512-bAH5jbK/F3T3Jls4I0SO1hmPR0dKU0a7+SY6n1yzRtG54FLO8d6w/nxLFX2Nb7dBu6cCWXPaAME6cYqFUMmuCA== + +imurmurhash@^0.1.4: + version "0.1.4" + resolved "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz" + integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== + +indent-string@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz" + integrity sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg== + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz" + integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2: + version "2.0.4" + resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +irregular-plurals@^3.3.0: + version "3.5.0" + resolved "https://registry.npmjs.org/irregular-plurals/-/irregular-plurals-3.5.0.tgz" + integrity sha512-1ANGLZ+Nkv1ptFb2pa8oG8Lem4krflKuX/gINiHJHjJUKaJHk/SXk5x6K3J+39/p0h1RQ2saROclJJ+QLvETCQ== + +is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz" + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== + +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + +is-fullwidth-code-point@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz" + integrity sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ== + +is-glob@^4.0.1: + version "4.0.3" + resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz" + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + +is-plain-object@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz" + integrity sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q== + +is-promise@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz" + integrity sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ== + +is-unicode-supported@^2.0.0: + version "2.1.0" + resolved "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz" + integrity sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ== + +js-string-escape@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/js-string-escape/-/js-string-escape-1.0.1.tgz" + integrity sha512-Smw4xcfIQ5LVjAOuJCvN/zIodzA/BBSsluuoSykP+lUvScIi4U6RJLfwHet5cxFnCswUjISV8oAXaqaJDY3chg== + +js-yaml@^3.14.1: + version "3.14.1" + resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz" + integrity sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g== + dependencies: + argparse "^1.0.7" + esprima "^4.0.0" + +load-json-file@^7.0.1: + version "7.0.1" + resolved "https://registry.npmjs.org/load-json-file/-/load-json-file-7.0.1.tgz" + integrity sha512-Gnxj3ev3mB5TkVBGad0JM6dmLiQL+o0t23JPBZ9sd+yvSLk05mFoqKBw5N8gbbkU4TNXyqCgIrl/VM17OgUIgQ== + +lodash@^4.17.15: + version "4.17.21" + resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + +matcher@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/matcher/-/matcher-5.0.0.tgz" + integrity sha512-s2EMBOWtXFc8dgqvoAzKJXxNHibcdJMV0gwqKUaw9E2JBJuGUK7DrNKrA6g/i+v72TT16+6sVm5mS3thaMLQUw== + dependencies: + escape-string-regexp "^5.0.0" + +md5-hex@^3.0.1: + version "3.0.1" + resolved "https://registry.npmjs.org/md5-hex/-/md5-hex-3.0.1.tgz" + integrity sha512-BUiRtTtV39LIJwinWBjqVsU9xhdnz7/i889V859IBFpuqGAj6LuOvHv5XLbgZ2R7ptJoJaEcxkv88/h25T7Ciw== + dependencies: + blueimp-md5 "^2.10.0" + +memoize@^10.0.0: + version "10.1.0" + resolved "https://registry.npmjs.org/memoize/-/memoize-10.1.0.tgz" + integrity sha512-MMbFhJzh4Jlg/poq1si90XRlTZRDHVqdlz2mPyGJ6kqMpyHUyVpDd5gpFAvVehW64+RA1eKE9Yt8aSLY7w2Kgg== + dependencies: + mimic-function "^5.0.1" + +merge2@^1.3.0: + version "1.4.1" + resolved "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz" + integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== + +micromatch@^4.0.8: + version "4.0.8" + resolved "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz" + integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA== + dependencies: + braces "^3.0.3" + picomatch "^2.3.1" + +mimic-function@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz" + integrity sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA== + +minimatch@^3.1.1: + version "3.1.2" + resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz" + integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== + dependencies: + brace-expansion "^1.1.7" + +minipass@^7.0.4, minipass@^7.1.2: + version "7.1.2" + resolved "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz" + integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== + +minizlib@^3.0.1: + version "3.0.2" + resolved "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz" + integrity sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA== + dependencies: + minipass "^7.1.2" + +mkdirp@^3.0.1: + version "3.0.1" + resolved "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz" + integrity sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg== + +ms@^2.1.3: + version "2.1.3" + resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +node-fetch@^2.6.7: + version "2.7.0" + resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz" + integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== + dependencies: + whatwg-url "^5.0.0" + +node-gyp-build@^4.2.2: + version "4.8.4" + resolved "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz" + integrity sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ== + +nofilter@^3.1.0: + version "3.1.0" + resolved "https://registry.npmjs.org/nofilter/-/nofilter-3.1.0.tgz" + integrity sha512-l2NNj07e9afPnhAhvgVrCD/oy2Ai1yfLpuo3EpiO1jFTsB4sFz6oIfAfSZyQzVpkZQ9xS8ZS5g1jCBgq4Hwo0g== + +nopt@^8.0.0: + version "8.1.0" + resolved "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz" + integrity sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A== + dependencies: + abbrev "^3.0.0" + +once@^1.3.0: + version "1.4.0" + resolved "https://registry.npmjs.org/once/-/once-1.4.0.tgz" + integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== + dependencies: + wrappy "1" + +p-map@^7.0.2: + version "7.0.3" + resolved "https://registry.npmjs.org/p-map/-/p-map-7.0.3.tgz" + integrity sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA== + +package-config@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/package-config/-/package-config-5.0.0.tgz" + integrity sha512-GYTTew2slBcYdvRHqjhwaaydVMvn/qrGC323+nKclYioNSLTDUM/lGgtGTgyHVtYcozb+XkE8CNhwcraOmZ9Mg== + dependencies: + find-up-simple "^1.0.0" + load-json-file "^7.0.1" + +parse-ms@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz" + integrity sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw== + +path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz" + integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== + +path-type@^6.0.0: + version "6.0.0" + resolved "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz" + integrity sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ== + +picomatch@^2.3.1: + version "2.3.1" + resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz" + integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + +picomatch@^4.0.2: + version "4.0.2" + resolved "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz" + integrity sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg== + +plur@^5.1.0: + version "5.1.0" + resolved "https://registry.npmjs.org/plur/-/plur-5.1.0.tgz" + integrity sha512-VP/72JeXqak2KiOzjgKtQen5y3IZHn+9GOuLDafPv0eXa47xq0At93XahYBs26MsifCQ4enGKwbjBTKgb9QJXg== + dependencies: + irregular-plurals "^3.3.0" + +pretty-ms@^9.1.0: + version "9.2.0" + resolved "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.2.0.tgz" + integrity sha512-4yf0QO/sllf/1zbZWYnvWw3NxCQwLXKzIj0G849LSufP15BXKM0rbD2Z3wVnkMfjdn/CB0Dpp444gYAACdsplg== + dependencies: + parse-ms "^4.0.0" + +queue-microtask@^1.2.2: + version "1.2.3" + resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz" + integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== + +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz" + integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== + +resolve-cwd@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz" + integrity sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg== + dependencies: + resolve-from "^5.0.0" + +resolve-from@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz" + integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== + +reusify@^1.0.4: + version "1.1.0" + resolved "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz" + integrity sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw== + +run-parallel@^1.1.9: + version "1.2.0" + resolved "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz" + integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA== + dependencies: + queue-microtask "^1.2.2" + +semver@^7.3.2, semver@^7.5.3: + version "7.7.1" + resolved "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz" + integrity sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA== + +serialize-error@^7.0.1: + version "7.0.1" + resolved "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz" + integrity sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw== + dependencies: + type-fest "^0.13.1" + +signal-exit@^4.0.1: + version "4.1.0" + resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz" + integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== + +slash@^5.1.0: + version "5.1.0" + resolved "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz" + integrity sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg== + +slice-ansi@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz" + integrity sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ== + dependencies: + ansi-styles "^6.0.0" + is-fullwidth-code-point "^4.0.0" + +sprintf-js@~1.0.2: + version "1.0.3" + resolved "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz" + integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== + +stack-utils@^2.0.6: + version "2.0.6" + resolved "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz" + integrity sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ== + dependencies: + escape-string-regexp "^2.0.0" + +string-width@^4.1.0: + version "4.2.3" + resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.2.0: + version "4.2.3" + resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^7.0.0: + version "7.2.0" + resolved "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz" + integrity sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ== + dependencies: + emoji-regex "^10.3.0" + get-east-asian-width "^1.0.0" + strip-ansi "^7.1.0" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^7.0.1, strip-ansi@^7.1.0: + version "7.1.0" + resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz" + integrity sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ== + dependencies: + ansi-regex "^6.0.1" + +supertap@^3.0.1: + version "3.0.1" + resolved "https://registry.npmjs.org/supertap/-/supertap-3.0.1.tgz" + integrity sha512-u1ZpIBCawJnO+0QePsEiOknOfCRq0yERxiAchT0i4li0WHNUJbf0evXXSXOcCAR4M8iMDoajXYmstm/qO81Isw== + dependencies: + indent-string "^5.0.0" + js-yaml "^3.14.1" + serialize-error "^7.0.1" + strip-ansi "^7.0.1" + +tar@^7.4.0: + version "7.4.3" + resolved "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz" + integrity sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw== + dependencies: + "@isaacs/fs-minipass" "^4.0.0" + chownr "^3.0.0" + minipass "^7.1.2" + minizlib "^3.0.1" + mkdirp "^3.0.1" + yallist "^5.0.0" + +temp-dir@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/temp-dir/-/temp-dir-3.0.0.tgz" + integrity sha512-nHc6S/bwIilKHNRgK/3jlhDoIHcp45YgyiwcAk46Tr0LfEqGBVpmiAyuiuxeVE44m3mXnEeVhaipLOEWmH+Njw== + +time-zone@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/time-zone/-/time-zone-1.0.0.tgz" + integrity sha512-TIsDdtKo6+XrPtiTm1ssmMngN1sAhyKnTO2kunQWqNPWIVvCm15Wmw4SWInwTVgJ5u/Tr04+8Ei9TNcw4x4ONA== + +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + dependencies: + is-number "^7.0.0" + +tr46@~0.0.3: + version "0.0.3" + resolved "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz" + integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== + +type-fest@^0.13.1: + version "0.13.1" + resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz" + integrity sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg== + +unicorn-magic@^0.3.0: + version "0.3.0" + resolved "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz" + integrity sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA== + +webidl-conversions@^3.0.0: + version "3.0.1" + resolved "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz" + integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== + +well-known-symbols@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/well-known-symbols/-/well-known-symbols-2.0.0.tgz" + integrity sha512-ZMjC3ho+KXo0BfJb7JgtQ5IBuvnShdlACNkKkdsqBmYw3bPAaJfPeYUo6tLUaT5tG/Gkh7xkpBhKRQ9e7pyg9Q== + +whatwg-url@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz" + integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== + dependencies: + tr46 "~0.0.3" + webidl-conversions "^3.0.0" + +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrappy@1: + version "1.0.2" + resolved "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz" + integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== + +write-file-atomic@^6.0.0: + version "6.0.0" + resolved "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-6.0.0.tgz" + integrity sha512-GmqrO8WJ1NuzJ2DrziEI2o57jKAVIQNf8a18W3nCYU3H7PNWqCCVTeH6/NQE93CIllIgQS98rrmVkYgTX9fFJQ== + dependencies: + imurmurhash "^0.1.4" + signal-exit "^4.0.1" + +y18n@^5.0.5: + version "5.0.8" + resolved "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz" + integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== + +yallist@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz" + integrity sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw== + +yargs-parser@^21.1.1: + version "21.1.1" + resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz" + integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== + +yargs@^17.7.2: + version "17.7.2" + resolved "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz" + integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== + dependencies: + cliui "^8.0.1" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.3" + y18n "^5.0.5" + yargs-parser "^21.1.1" diff --git a/parameterized-test/Cargo.toml b/parameterized-test/Cargo.toml new file mode 100644 index 00000000..7f379260 --- /dev/null +++ b/parameterized-test/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "parameterized-test" +version = "0.27.0" +edition = "2024" +license = "MIT" + +[dependencies] +proc-macro2 = "1.0.95" +quote = "1.0.40" +syn = { version = "2.0.100", features = ["full"] } + +[lib] +proc-macro = true diff --git a/parameterized-test/src/lib.rs b/parameterized-test/src/lib.rs new file mode 100644 index 00000000..272be6b0 --- /dev/null +++ b/parameterized-test/src/lib.rs @@ -0,0 +1,140 @@ +use proc_macro::{TokenStream, TokenTree}; +use quote::{ToTokens, format_ident, quote}; +use syn::{Expr, ExprLit, FnArg, Ident, ItemConst, ItemFn, Lit, Pat, PatIdent, PatType, parse}; + +#[proc_macro_attribute] +pub fn parameterized_test(input: TokenStream, annotated_item: TokenStream) -> TokenStream { + let macro_name: Ident = parse(input).unwrap(); + + let test: ItemFn = parse(annotated_item.clone()).unwrap(); + + let Some(FnArg::Typed(PatType { + pat: type_pattern, .. + })) = test.sig.inputs.first() + else { + panic!("Expected the first test function argument a type pattern"); + }; + + let Pat::Ident(PatIdent { + ident: inner_func_arg_name, + .. + }) = type_pattern.as_ref() + else { + panic!("Expected the first test function argument pattern to be an ident"); + }; + + let inner_func_name = test.sig.ident.clone(); + + quote! { + mod #inner_func_name { + use super::*; + + #test + + #macro_name!{#inner_func_name, #inner_func_arg_name} + } + } + .into() +} + +#[proc_macro_attribute] +pub fn test_parameter(_input: TokenStream, annotated_item: TokenStream) -> TokenStream { + let item: ItemConst = parse(annotated_item.clone()).unwrap(); + + let Expr::Array(array) = item.expr.as_ref() else { + panic!("Expected expression to be an array"); + }; + + let values: Vec<_> = array + .elems + .iter() + .map(|n| match n { + Expr::Path(path) => path.path.segments.last().unwrap().ident.to_token_stream(), + Expr::Lit(ExprLit { + lit: Lit::Int(lit_int), + .. + }) => lit_int.to_token_stream(), + _ => panic!("Expected array element to be a path or int literal"), + }) + .collect(); + + let annotated_item = proc_macro2::TokenStream::from(annotated_item); + + let const_item_name = item.ident; + let macro_name = format_ident!("{}_macro", &const_item_name); + + let macro_output = quote! { + macro_rules! #macro_name { + ( $inner_test_name:ident, $inner_test_arg_name:ident ) => { + parameterized_test::generate_tests!{ + $inner_test_name, + $inner_test_arg_name, + #const_item_name, + [#(#values),*] + } + }; + } + + #[allow(unused_imports)] + pub(crate) use #macro_name as #const_item_name; + + #annotated_item + }; + + macro_output.into() +} + +#[proc_macro] +pub fn generate_tests(item: TokenStream) -> TokenStream { + let mut item_iter = item.into_iter(); + let TokenTree::Ident(inner_test_name) = item_iter.next().unwrap() else { + panic!("Expected an ident for the inner_test_name"); + }; + + let _ = item_iter.next(); + + let TokenTree::Ident(inner_test_param_name) = item_iter.next().unwrap() else { + panic!("Expected an ident for the inner_test_param_name"); + }; + + let _ = item_iter.next(); + + let TokenTree::Ident(const_item_name) = item_iter.next().unwrap() else { + panic!("Expected an ident for the const_item_name"); + }; + + let _ = item_iter.next(); + + let TokenTree::Group(const_item_values) = item_iter.next().unwrap() else { + panic!("Expected a group for the const_item_values"); + }; + + let inner_test_name: Ident = parse(TokenTree::from(inner_test_name).into()).unwrap(); + let const_item_name: Ident = parse(TokenTree::from(const_item_name).into()).unwrap(); + + let tokens: proc_macro2::TokenStream = const_item_values + .stream() + .into_iter() + .step_by(2) + .enumerate() + .flat_map(|(i, value)| { + let suffix = match value { + TokenTree::Ident(ident) => ident.to_string(), + TokenTree::Literal(literal) => literal.to_string(), + _ => panic!("Expected const item value to be an ident or literal"), + }; + + let test_name = format_ident!("{inner_test_param_name}_{i:02}_{suffix}"); + + quote! { + #[test] + #[allow(non_snake_case)] + fn #test_name() { + #inner_test_name(#const_item_name[#i]); + } + } + }) + .collect(); + + tokens.into() +} diff --git a/python/.github/workflows/CI.yml b/python/.github/workflows/CI.yml new file mode 100644 index 00000000..6574c1fa --- /dev/null +++ b/python/.github/workflows/CI.yml @@ -0,0 +1,181 @@ +# This file is autogenerated by maturin v1.8.3 +# To update, run +# +# maturin generate-ci github +# +name: CI + +on: + push: + branches: + - main + - master + tags: + - '*' + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + linux: + runs-on: ${{ matrix.platform.runner }} + strategy: + matrix: + platform: + - runner: ubuntu-22.04 + target: x86_64 + - runner: ubuntu-22.04 + target: x86 + - runner: ubuntu-22.04 + target: aarch64 + - runner: ubuntu-22.04 + target: armv7 + - runner: ubuntu-22.04 + target: s390x + - runner: ubuntu-22.04 + target: ppc64le + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: 3.x + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.platform.target }} + args: --release --out dist --find-interpreter + sccache: ${{ !startsWith(github.ref, 'refs/tags/') }} + manylinux: auto + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-linux-${{ matrix.platform.target }} + path: dist + + musllinux: + runs-on: ${{ matrix.platform.runner }} + strategy: + matrix: + platform: + - runner: ubuntu-22.04 + target: x86_64 + - runner: ubuntu-22.04 + target: x86 + - runner: ubuntu-22.04 + target: aarch64 + - runner: ubuntu-22.04 + target: armv7 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: 3.x + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.platform.target }} + args: --release --out dist --find-interpreter + sccache: ${{ !startsWith(github.ref, 'refs/tags/') }} + manylinux: musllinux_1_2 + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-musllinux-${{ matrix.platform.target }} + path: dist + + windows: + runs-on: ${{ matrix.platform.runner }} + strategy: + matrix: + platform: + - runner: windows-latest + target: x64 + - runner: windows-latest + target: x86 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: 3.x + architecture: ${{ matrix.platform.target }} + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.platform.target }} + args: --release --out dist --find-interpreter + sccache: ${{ !startsWith(github.ref, 'refs/tags/') }} + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-windows-${{ matrix.platform.target }} + path: dist + + macos: + runs-on: ${{ matrix.platform.runner }} + strategy: + matrix: + platform: + - runner: macos-13 + target: x86_64 + - runner: macos-14 + target: aarch64 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: 3.x + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.platform.target }} + args: --release --out dist --find-interpreter + sccache: ${{ !startsWith(github.ref, 'refs/tags/') }} + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-macos-${{ matrix.platform.target }} + path: dist + + sdist: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build sdist + uses: PyO3/maturin-action@v1 + with: + command: sdist + args: --out dist + - name: Upload sdist + uses: actions/upload-artifact@v4 + with: + name: wheels-sdist + path: dist + + release: + name: Release + runs-on: ubuntu-latest + if: ${{ startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' }} + needs: [linux, musllinux, windows, macos, sdist] + permissions: + # Use to sign the release artifacts + id-token: write + # Used to upload release artifacts + contents: write + # Used to generate artifact attestation + attestations: write + steps: + - uses: actions/download-artifact@v4 + - name: Generate artifact attestation + uses: actions/attest-build-provenance@v2 + with: + subject-path: 'wheels-*/*' + - name: Publish to PyPI + if: ${{ startsWith(github.ref, 'refs/tags/') }} + uses: PyO3/maturin-action@v1 + env: + MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }} + with: + command: upload + args: --non-interactive --skip-existing wheels-*/* diff --git a/python/.gitignore b/python/.gitignore new file mode 100644 index 00000000..c8f04429 --- /dev/null +++ b/python/.gitignore @@ -0,0 +1,72 @@ +/target + +# Byte-compiled / optimized / DLL files +__pycache__/ +.pytest_cache/ +*.py[cod] + +# C extensions +*.so + +# Distribution / packaging +.Python +.venv/ +env/ +bin/ +build/ +develop-eggs/ +dist/ +eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +include/ +man/ +venv/ +*.egg-info/ +.installed.cfg +*.egg + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt +pip-selfcheck.json + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.cache +nosetests.xml +coverage.xml + +# Translations +*.mo + +# Mr Developer +.mr.developer.cfg +.project +.pydevproject + +# Rope +.ropeproject + +# Django stuff: +*.log +*.pot + +.DS_Store + +# Sphinx documentation +docs/_build/ + +# PyCharm +.idea/ + +# VSCode +.vscode/ + +# Pyenv +.python-version diff --git a/python/Cargo.toml b/python/Cargo.toml new file mode 100644 index 00000000..699a8739 --- /dev/null +++ b/python/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "libloot-python" +version = "0.27.0" +edition = "2024" +license = "GPL-3.0-or-later" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[lib] +name = "loot" +crate-type = ["cdylib"] + +[dependencies] +libloot = { path = ".." } +libloot-ffi-errors = { path = "../ffi-errors" } +pyo3 = "0.24.0" +pyo3-log = "0.12.2" diff --git a/python/README.md b/python/README.md new file mode 100644 index 00000000..5f0a51ff --- /dev/null +++ b/python/README.md @@ -0,0 +1,50 @@ +# libloot-python + +An **experimental** Python wrapper around the libloot Rust implementation, built using [PyO3](https://pyo3.rs). + +## Build + +To build, first set up a Python virtual environment and install [maturin](https://github.com/PyO3/maturin): + +``` +# pipx +pipx install maturin +# uv +uv tool install maturin +``` + +or using pip on Windows: + +```powershell +python -m venv .venv +.\.venv\Scripts\activate +pip install maturin +``` + +or using pip on Linux: + +```sh +python -m venv .venv +. .venv/bin/activate +pip install maturin +``` + +Then build the library in the virtual environment: + +``` +maturin develop +``` + +The library can then be imported in Python: + +``` +python +> import loot +``` + +## Usage notes + +- The Python exceptions that errors are mapped to are not the same as in the Rust or C++ interfaces: + - The API provides the custom `CyclicInteractionError`, `UndefinedGroupError`, `PluginNotLoadedError` exception types. + - All other errors are raised as `ValueError` exceptions. +- The `LogLevel` enum and `set_logging_callback()` and `set_log_level()` functions are not exposed because the logging is integrated with Python's `logging` module instead. diff --git a/python/pyproject.toml b/python/pyproject.toml new file mode 100644 index 00000000..42b8bb36 --- /dev/null +++ b/python/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["maturin>=1.8,<2.0"] +build-backend = "maturin" + +[project] +name = "libloot" +requires-python = ">=3.8" +classifiers = [ + "Programming Language :: Rust", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", +] +dynamic = ["version"] + +[tool.maturin] +features = ["pyo3/extension-module"] diff --git a/python/src/database.rs b/python/src/database.rs new file mode 100644 index 00000000..35331f1e --- /dev/null +++ b/python/src/database.rs @@ -0,0 +1,356 @@ +use std::{ + path::PathBuf, + sync::{Arc, RwLock}, +}; + +use libloot::{WriteMode, error::DatabaseLockPoisonError}; +use libloot_ffi_errors::UnsupportedEnumValueError; +use pyo3::{ + Bound, PyResult, pyclass, pymethods, + types::{PyAnyMethods, PyTypeMethods}, +}; + +use crate::{ + error::VerboseError, + metadata::{Group, Message, NONE_REPR, PluginMetadata}, +}; + +#[pyclass] +#[derive(Clone, Debug)] +pub struct Database(Arc>); + +#[pymethods] +impl Database { + #[expect(clippy::needless_pass_by_value, reason = "Required by PyO3")] + pub fn load_masterlist(&self, path: PathBuf) -> Result<(), VerboseError> { + self.0 + .write() + .map_err(DatabaseLockPoisonError::from)? + .load_masterlist(&path) + .map_err(Into::into) + } + + #[expect(clippy::needless_pass_by_value, reason = "Required by PyO3")] + pub fn load_masterlist_with_prelude( + &self, + masterlist_path: PathBuf, + prelude_path: PathBuf, + ) -> Result<(), VerboseError> { + self.0 + .write() + .map_err(DatabaseLockPoisonError::from)? + .load_masterlist_with_prelude(&masterlist_path, &prelude_path) + .map_err(Into::into) + } + + #[expect(clippy::needless_pass_by_value, reason = "Required by PyO3")] + pub fn load_userlist(&self, path: PathBuf) -> Result<(), VerboseError> { + self.0 + .write() + .map_err(DatabaseLockPoisonError::from)? + .load_userlist(&path) + .map_err(Into::into) + } + + #[expect(clippy::needless_pass_by_value, reason = "Required by PyO3")] + pub fn write_user_metadata( + &self, + output_path: PathBuf, + overwrite: bool, + ) -> Result<(), VerboseError> { + let write_mode = if overwrite { + WriteMode::CreateOrTruncate + } else { + WriteMode::Create + }; + + self.0 + .read() + .map_err(DatabaseLockPoisonError::from)? + .write_user_metadata(&output_path, write_mode) + .map_err(Into::into) + } + + #[expect(clippy::needless_pass_by_value, reason = "Required by PyO3")] + pub fn write_minimal_list( + &self, + output_path: PathBuf, + overwrite: bool, + ) -> Result<(), VerboseError> { + let write_mode = if overwrite { + WriteMode::CreateOrTruncate + } else { + WriteMode::Create + }; + + self.0 + .read() + .map_err(DatabaseLockPoisonError::from)? + .write_minimal_list(&output_path, write_mode) + .map_err(Into::into) + } + + pub fn evaluate(&self, condition: &str) -> Result { + self.0 + .read() + .map_err(DatabaseLockPoisonError::from)? + .evaluate(condition) + .map_err(Into::into) + } + + pub fn known_bash_tags(&self) -> Result, VerboseError> { + Ok(self + .0 + .read() + .map_err(DatabaseLockPoisonError::from)? + .known_bash_tags()) + } + + pub fn general_messages( + &self, + evaluate_conditions: bool, + ) -> Result, VerboseError> { + self.0 + .write() + .map_err(DatabaseLockPoisonError::from)? + .general_messages(evaluate_conditions) + .map(|v| v.into_iter().map(Into::into).collect()) + .map_err(Into::into) + } + + pub fn groups(&self, include_user_metadata: bool) -> Result, VerboseError> { + Ok(self + .0 + .read() + .map_err(DatabaseLockPoisonError::from)? + .groups(include_user_metadata) + .into_iter() + .map(Into::into) + .collect()) + } + + fn user_groups(&self) -> Result, VerboseError> { + Ok(self + .0 + .read() + .map_err(DatabaseLockPoisonError::from)? + .user_groups() + .iter() + .cloned() + .map(Into::into) + .collect()) + } + + pub fn set_user_groups(&self, groups: Vec) -> Result<(), VerboseError> { + let groups = groups.into_iter().map(Into::into).collect(); + self.0 + .write() + .map_err(DatabaseLockPoisonError::from)? + .set_user_groups(groups); + Ok(()) + } + + pub fn groups_path( + &self, + from_group_name: &str, + to_group_name: &str, + ) -> Result, VerboseError> { + self.0 + .read() + .map_err(DatabaseLockPoisonError::from)? + .groups_path(from_group_name, to_group_name) + .map(|v| v.into_iter().map(Into::into).collect()) + .map_err(Into::into) + } + + pub fn plugin_metadata( + &self, + plugin_name: &str, + include_user_metadata: bool, + evaluate_conditions: bool, + ) -> Result, VerboseError> { + self.0 + .read() + .map_err(DatabaseLockPoisonError::from)? + .plugin_metadata(plugin_name, include_user_metadata, evaluate_conditions) + .map(|p| p.map(Into::into)) + .map_err(Into::into) + } + + pub fn plugin_user_metadata( + &self, + plugin_name: &str, + evaluate_conditions: bool, + ) -> Result, VerboseError> { + self.0 + .read() + .map_err(DatabaseLockPoisonError::from)? + .plugin_user_metadata(plugin_name, evaluate_conditions) + .map(|p| p.map(Into::into)) + .map_err(Into::into) + } + + pub fn set_plugin_user_metadata( + &mut self, + plugin_metadata: PluginMetadata, + ) -> Result<(), VerboseError> { + self.0 + .write() + .map_err(DatabaseLockPoisonError::from)? + .set_plugin_user_metadata(plugin_metadata.into()); + Ok(()) + } + + pub fn discard_plugin_user_metadata(&self, plugin: &str) -> Result<(), VerboseError> { + self.0 + .write() + .map_err(DatabaseLockPoisonError::from)? + .discard_plugin_user_metadata(plugin); + Ok(()) + } + + pub fn discard_all_user_metadata(&self) -> Result<(), VerboseError> { + self.0 + .write() + .map_err(DatabaseLockPoisonError::from)? + .discard_all_user_metadata(); + Ok(()) + } +} + +impl From>> for Database { + fn from(value: Arc>) -> Self { + Self(value) + } +} + +#[pyclass(eq, ord, frozen, hash, str = "{0:?}")] +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[repr(transparent)] +pub struct Vertex(libloot::Vertex); + +#[pymethods] +impl Vertex { + #[new] + fn new(name: String) -> Self { + Self(libloot::Vertex::new(name)) + } + + #[getter] + fn name(&self) -> &str { + self.0.name() + } + + #[getter] + fn out_edge_type(&self) -> Result, VerboseError> { + self.0 + .out_edge_type() + .map(|e| e.try_into().map_err(Into::into)) + .transpose() + } + + fn __repr__(slf: &Bound<'_, Self>) -> PyResult { + let class_name = slf.get_type().qualname()?; + let inner = &slf.borrow().0; + Ok(format!( + "{}({}, {})", + class_name, + inner.name(), + inner.out_edge_type().map_or(NONE_REPR, repr_edge_type), + )) + } +} + +impl From for Vertex { + fn from(value: libloot::Vertex) -> Self { + Self(value) + } +} + +impl From for libloot::Vertex { + fn from(value: Vertex) -> Self { + value.0 + } +} + +#[pyclass(eq, frozen, hash, ord)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub enum EdgeType { + Hardcoded, + MasterFlag, + Master, + MasterlistRequirement, + UserRequirement, + MasterlistLoadAfter, + UserLoadAfter, + MasterlistGroup, + UserGroup, + RecordOverlap, + AssetOverlap, + TieBreak, + BlueprintMaster, +} + +impl TryFrom for EdgeType { + type Error = UnsupportedEnumValueError; + + fn try_from(value: libloot::EdgeType) -> Result { + match value { + libloot::EdgeType::Hardcoded => Ok(EdgeType::Hardcoded), + libloot::EdgeType::MasterFlag => Ok(EdgeType::MasterFlag), + libloot::EdgeType::Master => Ok(EdgeType::Master), + libloot::EdgeType::MasterlistRequirement => Ok(EdgeType::MasterlistRequirement), + libloot::EdgeType::UserRequirement => Ok(EdgeType::UserRequirement), + libloot::EdgeType::MasterlistLoadAfter => Ok(EdgeType::MasterlistLoadAfter), + libloot::EdgeType::UserLoadAfter => Ok(EdgeType::UserLoadAfter), + libloot::EdgeType::MasterlistGroup => Ok(EdgeType::MasterlistGroup), + libloot::EdgeType::UserGroup => Ok(EdgeType::UserGroup), + libloot::EdgeType::RecordOverlap => Ok(EdgeType::RecordOverlap), + libloot::EdgeType::AssetOverlap => Ok(EdgeType::AssetOverlap), + libloot::EdgeType::TieBreak => Ok(EdgeType::TieBreak), + libloot::EdgeType::BlueprintMaster => Ok(EdgeType::BlueprintMaster), + _ => Err(UnsupportedEnumValueError), + } + } +} + +impl TryFrom for libloot::EdgeType { + type Error = UnsupportedEnumValueError; + + fn try_from(value: EdgeType) -> Result { + match value { + EdgeType::Hardcoded => Ok(libloot::EdgeType::Hardcoded), + EdgeType::MasterFlag => Ok(libloot::EdgeType::MasterFlag), + EdgeType::Master => Ok(libloot::EdgeType::Master), + EdgeType::MasterlistRequirement => Ok(libloot::EdgeType::MasterlistRequirement), + EdgeType::UserRequirement => Ok(libloot::EdgeType::UserRequirement), + EdgeType::MasterlistLoadAfter => Ok(libloot::EdgeType::MasterlistLoadAfter), + EdgeType::UserLoadAfter => Ok(libloot::EdgeType::UserLoadAfter), + EdgeType::MasterlistGroup => Ok(libloot::EdgeType::MasterlistGroup), + EdgeType::UserGroup => Ok(libloot::EdgeType::UserGroup), + EdgeType::RecordOverlap => Ok(libloot::EdgeType::RecordOverlap), + EdgeType::AssetOverlap => Ok(libloot::EdgeType::AssetOverlap), + EdgeType::TieBreak => Ok(libloot::EdgeType::TieBreak), + EdgeType::BlueprintMaster => Ok(libloot::EdgeType::BlueprintMaster), + } + } +} + +fn repr_edge_type(value: libloot::EdgeType) -> &'static str { + match value { + libloot::EdgeType::Hardcoded => "EdgeType.Hardcoded", + libloot::EdgeType::MasterFlag => "EdgeType.MasterFlag", + libloot::EdgeType::Master => "EdgeType.Master", + libloot::EdgeType::MasterlistRequirement => "EdgeType.MasterlistRequirement", + libloot::EdgeType::UserRequirement => "EdgeType.UserRequirement", + libloot::EdgeType::MasterlistLoadAfter => "EdgeType.MasterlistLoadAfter", + libloot::EdgeType::UserLoadAfter => "EdgeType.UserLoadAfter", + libloot::EdgeType::MasterlistGroup => "EdgeType.MasterlistGroup", + libloot::EdgeType::UserGroup => "EdgeType.UserGroup", + libloot::EdgeType::RecordOverlap => "EdgeType.RecordOverlap", + libloot::EdgeType::AssetOverlap => "EdgeType.AssetOverlap", + libloot::EdgeType::TieBreak => "EdgeType.TieBreak", + libloot::EdgeType::BlueprintMaster => "EdgeType.BlueprintMaster", + _ => "", + } +} diff --git a/python/src/error.rs b/python/src/error.rs new file mode 100644 index 00000000..6c552fde --- /dev/null +++ b/python/src/error.rs @@ -0,0 +1,92 @@ +use libloot::{ + error::{ + ConditionEvaluationError, DatabaseLockPoisonError, GameHandleCreationError, + GroupsPathError, LoadOrderError, LoadOrderStateError, LoadPluginsError, + MetadataRetrievalError, PluginDataError, SortPluginsError, + }, + metadata::error::{ + LoadMetadataError, MultilingualMessageContentsError, RegexError, WriteMetadataError, + }, +}; +use libloot_ffi_errors::{UnsupportedEnumValueError, fmt_error_chain, variant_box_from_error}; +use pyo3::{PyErr, exceptions::PyValueError}; + +use crate::{CyclicInteractionError, PluginNotLoadedError, UndefinedGroupError, database::Vertex}; + +#[derive(Debug)] +pub enum VerboseError { + CyclicInteractionError(Vec), + UndefinedGroupError(String), + PluginNotLoadedError(String), + Other(Box), +} + +impl std::fmt::Display for VerboseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::CyclicInteractionError(c) => SortPluginsError::CycleFound(c.clone()).fmt(f), + Self::UndefinedGroupError(g) => SortPluginsError::UndefinedGroup(g.clone()).fmt(f), + Self::PluginNotLoadedError(p) => SortPluginsError::PluginNotLoaded(p.clone()).fmt(f), + Self::Other(e) => fmt_error_chain(e.as_ref(), f), + } + } +} + +variant_box_from_error!(UnsupportedEnumValueError, VerboseError::Other); +variant_box_from_error!(DatabaseLockPoisonError, VerboseError::Other); +variant_box_from_error!(LoadPluginsError, VerboseError::Other); +variant_box_from_error!(LoadOrderError, VerboseError::Other); +variant_box_from_error!(LoadMetadataError, VerboseError::Other); +variant_box_from_error!(WriteMetadataError, VerboseError::Other); +variant_box_from_error!(ConditionEvaluationError, VerboseError::Other); +variant_box_from_error!(MultilingualMessageContentsError, VerboseError::Other); +variant_box_from_error!(RegexError, VerboseError::Other); +variant_box_from_error!(GameHandleCreationError, VerboseError::Other); +variant_box_from_error!(LoadOrderStateError, VerboseError::Other); +variant_box_from_error!(MetadataRetrievalError, VerboseError::Other); +variant_box_from_error!(PluginDataError, VerboseError::Other); + +impl From for VerboseError { + fn from(value: SortPluginsError) -> Self { + match value { + SortPluginsError::UndefinedGroup(g) => Self::UndefinedGroupError(g), + SortPluginsError::CycleFound(cycle) => Self::CyclicInteractionError(cycle), + SortPluginsError::PluginNotLoaded(n) => Self::PluginNotLoadedError(n), + SortPluginsError::DatabaseLockPoisoned + | SortPluginsError::CycleFoundInvolving(_) + | SortPluginsError::PathfindingError(_) + | SortPluginsError::PluginDataError(_) + | _ => Self::Other(Box::new(value)), + } + } +} + +impl From for VerboseError { + fn from(value: GroupsPathError) -> Self { + match value { + GroupsPathError::UndefinedGroup(g) => Self::UndefinedGroupError(g), + GroupsPathError::CycleFound(cycle) => Self::CyclicInteractionError(cycle), + GroupsPathError::PathfindingError(_) | _ => Self::Other(Box::new(value)), + } + } +} + +impl From for PyErr { + fn from(value: VerboseError) -> Self { + let message = value.to_string(); + + match value { + VerboseError::CyclicInteractionError(c) => PyErr::new::(( + c.into_iter().map(Vertex::from).collect::>(), + message, + )), + VerboseError::UndefinedGroupError(g) => { + PyErr::new::((g, message)) + } + VerboseError::PluginNotLoadedError(p) => { + PyErr::new::((p, message)) + } + VerboseError::Other(_) => PyValueError::new_err(message), + } + } +} diff --git a/python/src/game.rs b/python/src/game.rs new file mode 100644 index 00000000..2eabb52d --- /dev/null +++ b/python/src/game.rs @@ -0,0 +1,182 @@ +use std::path::{Path, PathBuf}; + +use libloot_ffi_errors::UnsupportedEnumValueError; +use pyo3::{pyclass, pymethods}; + +use crate::{database::Database, error::VerboseError, plugin::Plugin}; + +#[pyclass(eq, frozen, hash, ord)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub enum GameType { + Oblivion, + Skyrim, + Fallout3, + FalloutNV, + Fallout4, + SkyrimSE, + Fallout4VR, + SkyrimVR, + Morrowind, + Starfield, + OpenMW, + OblivionRemastered, +} + +impl TryFrom for GameType { + type Error = UnsupportedEnumValueError; + + fn try_from(value: libloot::GameType) -> Result { + match value { + libloot::GameType::Oblivion => Ok(GameType::Oblivion), + libloot::GameType::Skyrim => Ok(GameType::Skyrim), + libloot::GameType::Fallout3 => Ok(GameType::Fallout3), + libloot::GameType::FalloutNV => Ok(GameType::FalloutNV), + libloot::GameType::Fallout4 => Ok(GameType::Fallout4), + libloot::GameType::SkyrimSE => Ok(GameType::SkyrimSE), + libloot::GameType::Fallout4VR => Ok(GameType::Fallout4VR), + libloot::GameType::SkyrimVR => Ok(GameType::SkyrimVR), + libloot::GameType::Morrowind => Ok(GameType::Morrowind), + libloot::GameType::Starfield => Ok(GameType::Starfield), + libloot::GameType::OpenMW => Ok(GameType::OpenMW), + libloot::GameType::OblivionRemastered => Ok(GameType::OblivionRemastered), + _ => Err(UnsupportedEnumValueError), + } + } +} + +impl TryFrom for libloot::GameType { + type Error = UnsupportedEnumValueError; + + fn try_from(value: GameType) -> Result { + match value { + GameType::Oblivion => Ok(libloot::GameType::Oblivion), + GameType::Skyrim => Ok(libloot::GameType::Skyrim), + GameType::Fallout3 => Ok(libloot::GameType::Fallout3), + GameType::FalloutNV => Ok(libloot::GameType::FalloutNV), + GameType::Fallout4 => Ok(libloot::GameType::Fallout4), + GameType::SkyrimSE => Ok(libloot::GameType::SkyrimSE), + GameType::Fallout4VR => Ok(libloot::GameType::Fallout4VR), + GameType::SkyrimVR => Ok(libloot::GameType::SkyrimVR), + GameType::Morrowind => Ok(libloot::GameType::Morrowind), + GameType::Starfield => Ok(libloot::GameType::Starfield), + GameType::OpenMW => Ok(libloot::GameType::OpenMW), + GameType::OblivionRemastered => Ok(libloot::GameType::OblivionRemastered), + } + } +} + +#[pyclass] +#[derive(Debug)] +pub struct Game(libloot::Game); + +#[pymethods] +impl Game { + #[new] + #[pyo3(signature = (game_type, game_path, local_path = None))] + #[expect(clippy::needless_pass_by_value, reason = "Required by PyO3")] + fn new( + game_type: GameType, + game_path: PathBuf, + local_path: Option, + ) -> Result { + match local_path { + Some(local_path) => Ok(Game(libloot::Game::with_local_path( + game_type.try_into()?, + &game_path, + &local_path, + )?)), + None => Ok(Game(libloot::Game::new(game_type.try_into()?, &game_path)?)), + } + } + + fn game_type(&self) -> Result { + self.0.game_type().try_into().map_err(Into::into) + } + + fn additional_data_paths(&self) -> &[PathBuf] { + self.0.additional_data_paths() + } + + #[expect(clippy::needless_pass_by_value, reason = "Required by PyO3")] + fn set_additional_data_paths(&mut self, paths: Vec) -> Result<(), VerboseError> { + self.0.set_additional_data_paths(&as_paths(&paths))?; + Ok(()) + } + + fn database(&self) -> Database { + self.0.database().into() + } + + #[expect(clippy::needless_pass_by_value, reason = "Required by PyO3")] + fn is_valid_plugin(&self, plugin_path: PathBuf) -> bool { + self.0.is_valid_plugin(&plugin_path) + } + + #[expect(clippy::needless_pass_by_value, reason = "Required by PyO3")] + fn load_plugins(&mut self, plugin_paths: Vec) -> Result<(), VerboseError> { + self.0.load_plugins(&as_paths(&plugin_paths))?; + Ok(()) + } + + #[expect(clippy::needless_pass_by_value, reason = "Required by PyO3")] + fn load_plugin_headers(&mut self, plugin_paths: Vec) -> Result<(), VerboseError> { + self.0.load_plugin_headers(&as_paths(&plugin_paths))?; + Ok(()) + } + + fn clear_loaded_plugins(&mut self) { + self.0.clear_loaded_plugins(); + } + + fn plugin(&self, plugin_name: &str) -> Option { + self.0.plugin(plugin_name).map(Into::into) + } + + fn loaded_plugins(&self) -> Vec { + self.0 + .loaded_plugins() + .into_iter() + .map(Into::into) + .collect() + } + + #[expect(clippy::needless_pass_by_value, reason = "Required by PyO3")] + fn sort_plugins(&self, plugin_names: Vec) -> Result, VerboseError> { + Ok(self.0.sort_plugins(&as_strs(&plugin_names))?) + } + + fn load_current_load_order_state(&mut self) -> Result<(), VerboseError> { + self.0.load_current_load_order_state()?; + Ok(()) + } + + fn is_load_order_ambiguous(&self) -> Result { + Ok(self.0.is_load_order_ambiguous()?) + } + + fn active_plugins_file_path(&self) -> &PathBuf { + self.0.active_plugins_file_path() + } + + fn is_plugin_active(&self, plugin_name: &str) -> bool { + self.0.is_plugin_active(plugin_name) + } + + fn load_order(&self) -> Vec<&str> { + self.0.load_order() + } + + #[expect(clippy::needless_pass_by_value, reason = "Required by PyO3")] + fn set_load_order(&mut self, load_order: Vec) -> Result<(), VerboseError> { + self.0.set_load_order(&as_strs(&load_order))?; + Ok(()) + } +} + +fn as_paths(pathbufs: &[PathBuf]) -> Vec<&Path> { + pathbufs.iter().map(PathBuf::as_ref).collect() +} + +fn as_strs(strings: &[String]) -> Vec<&str> { + strings.iter().map(String::as_ref).collect() +} diff --git a/python/src/lib.rs b/python/src/lib.rs new file mode 100644 index 00000000..803a8ae9 --- /dev/null +++ b/python/src/lib.rs @@ -0,0 +1,174 @@ +// Deny some rustc lints that are allow-by-default. +#![deny( + ambiguous_negative_literals, + impl_trait_overcaptures, + let_underscore_drop, + missing_copy_implementations, + missing_debug_implementations, + non_ascii_idents, + redundant_imports, + redundant_lifetimes, + trivial_casts, + trivial_numeric_casts, + unit_bindings, + unreachable_pub, + unsafe_code +)] +#![deny(clippy::pedantic)] +// Selectively deny clippy restriction lints. +#![deny( + clippy::allow_attributes, + clippy::as_conversions, + clippy::as_underscore, + clippy::assertions_on_result_states, + clippy::big_endian_bytes, + clippy::cfg_not_test, + clippy::clone_on_ref_ptr, + clippy::create_dir, + clippy::dbg_macro, + clippy::decimal_literal_representation, + clippy::default_numeric_fallback, + clippy::doc_include_without_cfg, + clippy::empty_drop, + clippy::error_impl_error, + clippy::exit, + clippy::exhaustive_enums, + clippy::expect_used, + clippy::filetype_is_file, + clippy::float_cmp_const, + clippy::fn_to_numeric_cast_any, + clippy::get_unwrap, + clippy::host_endian_bytes, + clippy::if_then_some_else_none, + clippy::indexing_slicing, + clippy::infinite_loop, + clippy::integer_division, + clippy::integer_division_remainder_used, + clippy::iter_over_hash_type, + clippy::let_underscore_must_use, + clippy::lossy_float_literal, + clippy::map_err_ignore, + clippy::map_with_unused_argument_over_ranges, + clippy::mem_forget, + clippy::missing_assert_message, + clippy::missing_asserts_for_indexing, + clippy::mixed_read_write_in_expression, + clippy::multiple_inherent_impl, + clippy::multiple_unsafe_ops_per_block, + clippy::mutex_atomic, + clippy::mutex_integer, + clippy::needless_raw_strings, + clippy::non_ascii_literal, + clippy::non_zero_suggestions, + clippy::panic, + clippy::panic_in_result_fn, + clippy::partial_pub_fields, + clippy::pathbuf_init_then_push, + clippy::precedence_bits, + clippy::print_stderr, + clippy::print_stdout, + clippy::rc_buffer, + clippy::rc_mutex, + clippy::redundant_type_annotations, + clippy::ref_patterns, + clippy::rest_pat_in_fully_bound_structs, + clippy::str_to_string, + clippy::string_lit_chars_any, + clippy::string_slice, + clippy::string_to_string, + clippy::suspicious_xor_used_as_pow, + clippy::tests_outside_test_module, + clippy::todo, + clippy::try_err, + clippy::undocumented_unsafe_blocks, + clippy::unimplemented, + clippy::unnecessary_safety_comment, + clippy::unneeded_field_pattern, + clippy::unreachable, + clippy::unused_result_ok, + clippy::unwrap_in_result, + clippy::unwrap_used, + clippy::use_debug, + clippy::verbose_file_reads, + clippy::wildcard_enum_match_arm +)] + +mod database; +mod error; +mod game; +mod metadata; +mod plugin; + +use database::{Database, EdgeType, Vertex}; +use game::{Game, GameType}; +use metadata::{ + File, Filename, Group, Location, Message, MessageContent, MessageType, PluginCleaningData, + PluginMetadata, Tag, TagSuggestion, select_message_content, +}; +use plugin::Plugin; +use pyo3::{create_exception, exceptions::PyException, prelude::*}; + +#[pyfunction] +fn is_compatible(major: u32, minor: u32, patch: u32) -> bool { + libloot::is_compatible(major, minor, patch) +} + +#[pyfunction] +fn libloot_revision() -> String { + libloot::libloot_revision() +} + +#[pyfunction] +fn libloot_version() -> String { + libloot::libloot_version() +} + +create_exception!(loot, CyclicInteractionError, PyException); +create_exception!(loot, UndefinedGroupError, PyException); +create_exception!(loot, PluginNotLoadedError, PyException); + +/// A Python module implemented in Rust. +#[pymodule(name = "loot")] +fn libloot_pyo3(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { + pyo3_log::init(); + + m.add("LIBLOOT_VERSION_MAJOR", libloot::LIBLOOT_VERSION_MAJOR)?; + m.add("LIBLOOT_VERSION_MINOR", libloot::LIBLOOT_VERSION_MINOR)?; + m.add("LIBLOOT_VERSION_PATCH", libloot::LIBLOOT_VERSION_PATCH)?; + + m.add_function(wrap_pyfunction!(is_compatible, m)?)?; + m.add_function(wrap_pyfunction!(libloot_revision, m)?)?; + m.add_function(wrap_pyfunction!(libloot_version, m)?)?; + + m.add_function(wrap_pyfunction!(select_message_content, m)?)?; + + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + + m.add( + "CyclicInteractionError", + py.get_type::(), + )?; + m.add("UndefinedGroupError", py.get_type::())?; + m.add( + "PluginNotLoadedError", + py.get_type::(), + )?; + + Ok(()) +} diff --git a/python/src/metadata.rs b/python/src/metadata.rs new file mode 100644 index 00000000..42b310b0 --- /dev/null +++ b/python/src/metadata.rs @@ -0,0 +1,802 @@ +use std::hash::{DefaultHasher, Hash, Hasher}; + +use libloot_ffi_errors::UnsupportedEnumValueError; +use pyo3::{ + Bound, FromPyObject, PyResult, pyclass, pyfunction, pymethods, + types::{PyAnyMethods, PyTypeMethods}, +}; + +use crate::error::VerboseError; + +pub(crate) const NONE_REPR: &str = "None"; + +#[pyclass(eq, ord, frozen, hash, str = "{0:?}")] +#[repr(transparent)] +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct Group(libloot::metadata::Group); + +#[pymethods] +impl Group { + #[classattr] + fn default_name() -> &'static str { + libloot::metadata::Group::DEFAULT_NAME + } + + #[new] + #[pyo3(signature = (name, description = None, after_groups = None))] + fn new(name: String, description: Option, after_groups: Option>) -> Self { + let mut group = libloot::metadata::Group::new(name); + + if let Some(description) = description { + group = group.with_description(description); + } + + if let Some(after_groups) = after_groups { + group = group.with_after_groups(after_groups); + } + + Self(group) + } + + #[getter] + fn name(&self) -> &str { + self.0.name() + } + + #[getter] + fn description(&self) -> Option<&str> { + self.0.description() + } + + #[getter] + fn after_groups(&self) -> &[String] { + self.0.after_groups() + } + + fn __repr__(slf: &Bound<'_, Self>) -> PyResult { + let class_name = slf.get_type().qualname()?; + let inner = &slf.borrow().0; + Ok(format!( + "{}({}, {}, [{}])", + class_name, + inner.name(), + inner.description().unwrap_or(NONE_REPR), + inner.after_groups().join(",") + )) + } +} + +impl From for Group { + fn from(value: libloot::metadata::Group) -> Self { + Self(value) + } +} + +impl From for libloot::metadata::Group { + fn from(value: Group) -> Self { + value.0 + } +} + +#[pyclass(eq, ord, frozen, hash, str = "{0:?}")] +#[repr(transparent)] +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct MessageContent(libloot::metadata::MessageContent); + +#[pymethods] +impl MessageContent { + #[classattr] + fn default_language() -> &'static str { + libloot::metadata::MessageContent::DEFAULT_LANGUAGE + } + + #[new] + #[pyo3(signature = (text, language = None))] + fn new(text: String, language: Option) -> Self { + let mut content = libloot::metadata::MessageContent::new(text); + + if let Some(language) = language { + content = content.with_language(language); + } + + Self(content) + } + + #[getter] + fn text(&self) -> &str { + self.0.text() + } + + #[getter] + fn language(&self) -> &str { + self.0.language() + } + + fn __repr__(slf: &Bound<'_, Self>) -> PyResult { + let class_name = slf.get_type().qualname()?; + let inner = &slf.borrow().0; + Ok(format!( + "{}({}, {})", + class_name, + inner.text(), + inner.language() + )) + } +} + +impl From for MessageContent { + fn from(value: libloot::metadata::MessageContent) -> Self { + Self(value) + } +} + +impl From for libloot::metadata::MessageContent { + fn from(value: MessageContent) -> Self { + value.0 + } +} + +fn repr_message_contents(contents: &[libloot::metadata::MessageContent]) -> String { + contents + .iter() + .map(|c| format!("MessageContent({}, {})", c.text(), c.language())) + .collect::>() + .join(",") +} + +#[expect( + unreachable_pub, + reason = "It's exported by PyO3, so while the pub isn't necessary, it's misleading to not have it" +)] +#[pyfunction] +pub fn select_message_content( + content: Vec, + language: &str, +) -> Option { + let content: Vec<_> = content.into_iter().map(Into::into).collect(); + libloot::metadata::select_message_content(&content, language) + .cloned() + .map(Into::into) +} + +#[pyclass(eq, frozen, hash, ord)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub enum MessageType { + Say, + Warn, + Error, +} + +impl From for MessageType { + fn from(value: libloot::metadata::MessageType) -> Self { + match value { + libloot::metadata::MessageType::Say => MessageType::Say, + libloot::metadata::MessageType::Warn => MessageType::Warn, + libloot::metadata::MessageType::Error => MessageType::Error, + } + } +} + +impl From for libloot::metadata::MessageType { + fn from(value: MessageType) -> Self { + match value { + MessageType::Say => libloot::metadata::MessageType::Say, + MessageType::Warn => libloot::metadata::MessageType::Warn, + MessageType::Error => libloot::metadata::MessageType::Error, + } + } +} + +#[pyclass(eq, ord, frozen, hash, str = "{0:?}")] +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[repr(transparent)] +pub struct Message(libloot::metadata::Message); + +#[derive(FromPyObject)] +enum MessageContents { + Monolingual(String), + Multilingual(Vec), +} + +#[pymethods] +impl Message { + #[new] + #[pyo3(signature = (message_type, contents, condition = None))] + fn new( + message_type: MessageType, + contents: MessageContents, + condition: Option, + ) -> Result { + let mut message = match contents { + MessageContents::Monolingual(c) => { + libloot::metadata::Message::new(message_type.into(), c) + } + MessageContents::Multilingual(c) => { + let c = c.into_iter().map(Into::into).collect(); + libloot::metadata::Message::multilingual(message_type.into(), c)? + } + }; + + if let Some(condition) = condition { + message = message.with_condition(condition); + } + + Ok(Self(message)) + } + + #[getter] + fn message_type(&self) -> MessageType { + self.0.message_type().into() + } + + #[getter] + fn content(&self) -> Vec { + self.0.content().iter().cloned().map(Into::into).collect() + } + + #[getter] + fn condition(&self) -> Option<&str> { + self.0.condition() + } + + fn __repr__(slf: &Bound<'_, Self>) -> PyResult { + let class_name = slf.get_type().qualname()?; + let inner = &slf.borrow().0; + Ok(format!( + "{}({}, [{}], {})", + class_name, + inner.message_type(), + repr_message_contents(inner.content()), + inner.condition().unwrap_or(NONE_REPR), + )) + } +} + +impl From for Message { + fn from(value: libloot::metadata::Message) -> Self { + Self(value) + } +} + +impl From for libloot::metadata::Message { + fn from(value: Message) -> Self { + value.0 + } +} + +#[pyclass(eq, ord, frozen, hash, str = "{0:?}")] +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[repr(transparent)] +pub struct File(libloot::metadata::File); + +#[pymethods] +impl File { + #[new] + #[pyo3(signature = (name, display_name = None, detail = None, condition = None, constraint = None))] + fn new( + name: String, + display_name: Option, + detail: Option>, + condition: Option, + constraint: Option, + ) -> Result { + let mut file = libloot::metadata::File::new(name); + + if let Some(display_name) = display_name { + file = file.with_display_name(display_name); + } + + if let Some(detail) = detail { + let detail = detail.into_iter().map(Into::into).collect(); + file = file.with_detail(detail)?; + } + + if let Some(condition) = condition { + file = file.with_condition(condition); + } + + if let Some(constraint) = constraint { + file = file.with_constraint(constraint); + } + + Ok(Self(file)) + } + + #[getter] + fn name(&self) -> Filename { + self.0.name().clone().into() + } + + #[getter] + fn display_name(&self) -> Option<&str> { + self.0.display_name() + } + + #[getter] + fn detail(&self) -> Vec { + self.0.detail().iter().cloned().map(Into::into).collect() + } + + #[getter] + fn condition(&self) -> Option<&str> { + self.0.condition() + } + + #[getter] + fn constraint(&self) -> Option<&str> { + self.0.constraint() + } + + fn __repr__(slf: &Bound<'_, Self>) -> PyResult { + let class_name = slf.get_type().qualname()?; + let inner = &slf.borrow().0; + Ok(format!( + "{}({}, {}, {}, {}, {})", + class_name, + inner.name(), + inner.display_name().unwrap_or(NONE_REPR), + repr_message_contents(inner.detail()), + inner.condition().unwrap_or(NONE_REPR), + inner.constraint().unwrap_or(NONE_REPR) + )) + } +} + +impl From for File { + fn from(value: libloot::metadata::File) -> Self { + Self(value) + } +} + +impl From for libloot::metadata::File { + fn from(value: File) -> Self { + value.0 + } +} + +#[pyclass(eq, ord, frozen, hash, str = "{0:?}")] +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[repr(transparent)] +pub struct Filename(libloot::metadata::Filename); + +#[pymethods] +impl Filename { + #[new] + fn new(name: String) -> Self { + Self(libloot::metadata::Filename::new(name)) + } + + fn as_str(&self) -> &str { + self.0.as_str() + } + + fn __repr__(slf: &Bound<'_, Self>) -> PyResult { + let class_name = slf.get_type().qualname()?; + let inner = &slf.borrow().0; + Ok(format!("{}({})", class_name, inner.as_str(),)) + } +} + +impl From for Filename { + fn from(value: libloot::metadata::Filename) -> Self { + Self(value) + } +} + +#[pyclass(eq, ord, frozen, hash, str = "{0:?}")] +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[repr(transparent)] +pub struct PluginCleaningData(libloot::metadata::PluginCleaningData); + +#[pymethods] +impl PluginCleaningData { + #[new] + #[pyo3(signature = (crc, cleaning_utility, itm_count = None, deleted_reference_count = None, deleted_navmesh_count = None, detail = None))] + fn new( + crc: u32, + cleaning_utility: String, + itm_count: Option, + deleted_reference_count: Option, + deleted_navmesh_count: Option, + detail: Option>, + ) -> Result { + let mut data = libloot::metadata::PluginCleaningData::new(crc, cleaning_utility); + + if let Some(count) = itm_count { + data = data.with_itm_count(count); + } + + if let Some(count) = deleted_reference_count { + data = data.with_deleted_reference_count(count); + } + + if let Some(count) = deleted_navmesh_count { + data = data.with_deleted_navmesh_count(count); + } + + if let Some(detail) = detail { + let detail = detail.into_iter().map(Into::into).collect(); + data = data.with_detail(detail)?; + } + + Ok(Self(data)) + } + + #[getter] + fn crc(&self) -> u32 { + self.0.crc() + } + + #[getter] + fn itm_count(&self) -> u32 { + self.0.itm_count() + } + + #[getter] + fn deleted_reference_count(&self) -> u32 { + self.0.deleted_reference_count() + } + + #[getter] + fn deleted_navmesh_count(&self) -> u32 { + self.0.deleted_navmesh_count() + } + + #[getter] + fn cleaning_utility(&self) -> &str { + self.0.cleaning_utility() + } + + #[getter] + fn detail(&self) -> Vec { + self.0.detail().iter().cloned().map(Into::into).collect() + } + + fn __repr__(slf: &Bound<'_, Self>) -> PyResult { + let class_name = slf.get_type().qualname()?; + let inner = &slf.borrow().0; + Ok(format!( + "{}({}, {}, {}, {}, {}, {})", + class_name, + inner.crc(), + inner.cleaning_utility(), + inner.itm_count(), + inner.deleted_reference_count(), + inner.deleted_navmesh_count(), + repr_message_contents(inner.detail()) + )) + } +} + +impl From for PluginCleaningData { + fn from(value: libloot::metadata::PluginCleaningData) -> Self { + Self(value) + } +} + +impl From for libloot::metadata::PluginCleaningData { + fn from(value: PluginCleaningData) -> Self { + value.0 + } +} + +#[pyclass(eq, frozen, hash, ord)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub enum TagSuggestion { + Addition, + Removal, +} + +impl TryFrom for libloot::metadata::TagSuggestion { + type Error = UnsupportedEnumValueError; + + fn try_from(value: TagSuggestion) -> Result { + match value { + TagSuggestion::Addition => Ok(libloot::metadata::TagSuggestion::Addition), + TagSuggestion::Removal => Ok(libloot::metadata::TagSuggestion::Removal), + } + } +} + +#[pyclass(eq, ord, frozen, hash, str = "{0:?}")] +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[repr(transparent)] +pub struct Tag(libloot::metadata::Tag); + +#[pymethods] +impl Tag { + #[new] + #[pyo3(signature = (name, suggestion, condition = None))] + fn new( + name: String, + suggestion: TagSuggestion, + condition: Option, + ) -> Result { + let mut tag = libloot::metadata::Tag::new(name, suggestion.try_into()?); + + if let Some(condition) = condition { + tag = tag.with_condition(condition); + } + + Ok(Self(tag)) + } + + #[getter] + fn name(&self) -> &str { + self.0.name() + } + + #[getter] + fn is_addition(&self) -> bool { + self.0.is_addition() + } + + #[getter] + fn condition(&self) -> Option<&str> { + self.0.condition() + } + + fn __repr__(slf: &Bound<'_, Self>) -> PyResult { + let class_name = slf.get_type().qualname()?; + let inner = &slf.borrow().0; + let suggestion = if inner.is_addition() { + "TagSuggestion.Addition" + } else { + "TagSuggestion.Removal" + }; + Ok(format!( + "{}({}, {}, {})", + class_name, + inner.name(), + suggestion, + inner.condition().unwrap_or(NONE_REPR) + )) + } +} + +impl From for Tag { + fn from(value: libloot::metadata::Tag) -> Self { + Self(value) + } +} + +impl From for libloot::metadata::Tag { + fn from(value: Tag) -> Self { + value.0 + } +} + +#[pyclass(eq, ord, frozen, hash, str = "{0:?}")] +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[repr(transparent)] +pub struct Location(libloot::metadata::Location); + +#[pymethods] +impl Location { + #[new] + #[pyo3(signature = (url, name = None))] + fn new(url: String, name: Option) -> Self { + let mut location = libloot::metadata::Location::new(url); + + if let Some(name) = name { + location = location.with_name(name); + } + + Self(location) + } + + #[getter] + fn url(&self) -> &str { + self.0.url() + } + + #[getter] + fn name(&self) -> Option<&str> { + self.0.name() + } + + fn __repr__(slf: &Bound<'_, Self>) -> PyResult { + let class_name = slf.get_type().qualname()?; + let inner = &slf.borrow().0; + Ok(format!( + "{}({}, {})", + class_name, + inner.url(), + inner.name().unwrap_or(NONE_REPR) + )) + } +} + +impl From for Location { + fn from(value: libloot::metadata::Location) -> Self { + Self(value) + } +} + +impl From for libloot::metadata::Location { + fn from(value: Location) -> Self { + value.0 + } +} + +#[pyclass(eq, ord, str = "{0:?}")] +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[repr(transparent)] +pub struct PluginMetadata(libloot::metadata::PluginMetadata); + +#[pymethods] +impl PluginMetadata { + #[new] + fn new(name: &str) -> Result { + Ok(Self(libloot::metadata::PluginMetadata::new(name)?)) + } + + #[getter] + fn name(&self) -> &str { + self.0.name() + } + + #[getter] + fn group(&self) -> Option<&str> { + self.0.group() + } + + #[getter] + fn load_after_files(&self) -> Vec { + self.0 + .load_after_files() + .iter() + .cloned() + .map(Into::into) + .collect() + } + + #[getter] + fn requirements(&self) -> Vec { + self.0 + .requirements() + .iter() + .cloned() + .map(Into::into) + .collect() + } + + #[getter] + fn incompatibilities(&self) -> Vec { + self.0 + .incompatibilities() + .iter() + .cloned() + .map(Into::into) + .collect() + } + + #[getter] + fn messages(&self) -> Vec { + self.0.messages().iter().cloned().map(Into::into).collect() + } + + #[getter] + fn tags(&self) -> Vec { + self.0.tags().iter().cloned().map(Into::into).collect() + } + + #[getter] + fn dirty_info(&self) -> Vec { + self.0 + .dirty_info() + .iter() + .cloned() + .map(Into::into) + .collect() + } + + #[getter] + fn clean_info(&self) -> Vec { + self.0 + .clean_info() + .iter() + .cloned() + .map(Into::into) + .collect() + } + + #[getter] + fn locations(&self) -> Vec { + self.0.locations().iter().cloned().map(Into::into).collect() + } + + #[setter] + fn set_group(&mut self, group: Option) { + match group { + Some(g) => self.0.set_group(g), + None => self.0.unset_group(), + } + } + + #[setter] + fn set_load_after_files(&mut self, value: Vec) { + let value = value.into_iter().map(Into::into).collect(); + self.0.set_load_after_files(value); + } + + #[setter] + fn set_requirements(&mut self, value: Vec) { + let value = value.into_iter().map(Into::into).collect(); + self.0.set_requirements(value); + } + + #[setter] + fn set_incompatibilities(&mut self, value: Vec) { + let value = value.into_iter().map(Into::into).collect(); + self.0.set_incompatibilities(value); + } + + #[setter] + fn set_messages(&mut self, value: Vec) { + let value = value.into_iter().map(Into::into).collect(); + self.0.set_messages(value); + } + + #[setter] + fn set_tags(&mut self, value: Vec) { + let value = value.into_iter().map(Into::into).collect(); + self.0.set_tags(value); + } + + #[setter] + fn set_dirty_info(&mut self, value: Vec) { + let value = value.into_iter().map(Into::into).collect(); + self.0.set_dirty_info(value); + } + + #[setter] + fn set_clean_info(&mut self, value: Vec) { + let value = value.into_iter().map(Into::into).collect(); + self.0.set_clean_info(value); + } + + #[setter] + fn set_locations(&mut self, value: Vec) { + let value = value.into_iter().map(Into::into).collect(); + self.0.set_locations(value); + } + + fn merge_metadata(&mut self, other: &PluginMetadata) { + self.0.merge_metadata(&other.0); + } + + fn has_name_only(&self) -> bool { + self.0.has_name_only() + } + + fn is_regex_plugin(&self) -> bool { + self.0.is_regex_plugin() + } + + fn name_matches(&self, other_name: &str) -> bool { + self.0.name_matches(other_name) + } + + fn as_yaml(&self) -> String { + self.0.as_yaml() + } + + fn __hash__(&self) -> u64 { + let mut hasher = DefaultHasher::new(); + self.0.hash(&mut hasher); + hasher.finish() + } +} + +impl From for PluginMetadata { + fn from(value: libloot::metadata::PluginMetadata) -> Self { + Self(value) + } +} + +impl From for libloot::metadata::PluginMetadata { + fn from(value: PluginMetadata) -> Self { + value.0 + } +} diff --git a/python/src/plugin.rs b/python/src/plugin.rs new file mode 100644 index 00000000..94eafcd9 --- /dev/null +++ b/python/src/plugin.rs @@ -0,0 +1,87 @@ +use std::sync::Arc; + +use pyo3::{pyclass, pymethods}; + +use crate::error::VerboseError; + +#[pyclass(eq, frozen)] +#[derive(Clone, Debug, Eq, PartialEq)] +#[repr(transparent)] +pub struct Plugin(Arc); + +#[pymethods] +impl Plugin { + fn name(&self) -> &str { + self.0.name() + } + + fn header_version(&self) -> Option { + self.0.header_version() + } + + fn version(&self) -> Option<&str> { + self.0.version() + } + + fn masters(&self) -> Result, VerboseError> { + Ok(self.0.masters()?) + } + + fn bash_tags(&self) -> &[String] { + self.0.bash_tags() + } + + fn crc(&self) -> Option { + self.0.crc() + } + + fn is_master(&self) -> bool { + self.0.is_master() + } + + fn is_light_plugin(&self) -> bool { + self.0.is_light_plugin() + } + + fn is_medium_plugin(&self) -> bool { + self.0.is_medium_plugin() + } + + fn is_update_plugin(&self) -> bool { + self.0.is_update_plugin() + } + + fn is_blueprint_plugin(&self) -> bool { + self.0.is_blueprint_plugin() + } + + fn is_valid_as_light_plugin(&self) -> Result { + Ok(self.0.is_valid_as_light_plugin()?) + } + + fn is_valid_as_medium_plugin(&self) -> Result { + Ok(self.0.is_valid_as_medium_plugin()?) + } + + fn is_valid_as_update_plugin(&self) -> Result { + Ok(self.0.is_valid_as_update_plugin()?) + } + + fn is_empty(&self) -> bool { + self.0.is_empty() + } + + fn loads_archive(&self) -> bool { + self.0.loads_archive() + } + + fn do_records_overlap(&self, plugin: &Self) -> Result { + Ok(self.0.do_records_overlap(&plugin.0)?) + } +} + +impl From> for Plugin { + fn from(value: Arc) -> Self { + Self(value) + } +} diff --git a/scripts/set_version_number.py b/scripts/set_version_number.py index c6b55148..eb15e788 100644 --- a/scripts/set_version_number.py +++ b/scripts/set_version_number.py @@ -37,6 +37,12 @@ def update_cmakelists(path, version): replace_in_file(path, 'TARGET loot PROPERTY SOVERSION \\d+', 'TARGET loot PROPERTY SOVERSION {}'.format(version_parts[0])) replace_in_file(path, 'INTERFACE_libloot_MAJOR_VERSION \\d+', 'INTERFACE_libloot_MAJOR_VERSION {}'.format(version_parts[0])) +def update_cargo_toml(path, version): + replace_in_file(path, '^version = "\\d+\\.\\d+\\.\\d+"$', 'version = "{}"'.format(version)) + +def update_package_json(path, version): + replace_in_file(path, '"version": "\\d+\\.\\d+\\.\\d+",', '"version": "{}",'.format(version)) + if __name__ == "__main__": parser = argparse.ArgumentParser(description = 'Set the libloot version number') parser.add_argument('version', nargs='+') @@ -49,6 +55,20 @@ if __name__ == "__main__": if len(arguments.version[0].split('.')) != 3: raise RuntimeError('The version number must be a three-part semantic version.') - update_cpp_file(os.path.join('include', 'loot', 'loot_version.h'), arguments.version[0]) - update_resource_file(os.path.join('src', 'api', 'resource.rc'), arguments.version[0]) - update_cmakelists(os.path.join('CMakeLists.txt'), arguments.version[0]) + update_cpp_file(os.path.join('cpp', 'include', 'loot', 'loot_version.h'), arguments.version[0]) + update_resource_file(os.path.join('cpp', 'src', 'api', 'resource.rc'), arguments.version[0]) + update_cmakelists(os.path.join('cpp', 'CMakeLists.txt'), arguments.version[0]) + + update_cargo_toml('Cargo.toml', arguments.version[0]) + update_cargo_toml(os.path.join('cpp', 'Cargo.toml'), arguments.version[0]) + update_cargo_toml(os.path.join('ffi-errors', 'Cargo.toml'), arguments.version[0]) + update_cargo_toml(os.path.join('nodejs', 'Cargo.toml'), arguments.version[0]) + update_cargo_toml(os.path.join('parameterized-test', 'Cargo.toml'), arguments.version[0]) + update_cargo_toml(os.path.join('python', 'Cargo.toml'), arguments.version[0]) + + # update_cargo_toml also works for pyproject.toml + update_cargo_toml(os.path.join('cpp', 'docs', 'pyproject.toml'), arguments.version[0]) + + update_package_json(os.path.join('nodejs', 'package.json'), arguments.version[0]) + update_package_json(os.path.join('nodejs', 'npm', 'linux-x64-gnu', 'package.json'), arguments.version[0]) + update_package_json(os.path.join('nodejs', 'npm', 'win32-x64-msvc', 'package.json'), arguments.version[0]) diff --git a/src/api/api.cpp b/src/api/api.cpp deleted file mode 100644 index d9b67f09..00000000 --- a/src/api/api.cpp +++ /dev/null @@ -1,136 +0,0 @@ -/* LOOT - - A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and - Fallout: New Vegas. - - Copyright (C) 2013-2016 WrinklyNinja - - This file is part of LOOT. - - LOOT is free software: you can redistribute - it and/or modify it under the terms of the GNU General Public License - as published by the Free Software Foundation, either version 3 of - the License, or (at your option) any later version. - - LOOT is distributed in the hope that it will - be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with LOOT. If not, see - . - */ - -#include "loot/api.h" - -#include - -#include "api/game/game.h" -#include "api/helpers/logging.h" - -namespace fs = std::filesystem; - -namespace loot { -const char* DescribeGameType(GameType gameType) { - switch (gameType) { - case GameType::tes4: - return "The Elder Scrolls IV: Oblivion"; - case GameType::tes5: - return "The Elder Scrolls V: Skyrim"; - case GameType::fo3: - return "Fallout 3"; - case GameType::fonv: - return "Fallout: New Vegas"; - case GameType::fo4: - return "Fallout 4"; - case GameType::tes5se: - return "The Elder Scrolls V: Skyrim Special Edition"; - case GameType::fo4vr: - return "Fallout 4 VR"; - case GameType::tes5vr: - return "The Elder Scrolls V: Skyrim VR"; - case GameType::tes3: - return "The Elder Scrolls III: Morrowind"; - case GameType::starfield: - return "Starfield"; - case GameType::openmw: - return "OpenMW"; - case GameType::oblivionRemastered: - return "The Elder Scrolls IV: Oblivion Remastered"; - default: - return "Unknown"; - } -} - -std::filesystem::path ResolvePath(const std::filesystem::path& path) { - // is_symlink can throw on MSVC with the message - // "symlink_status: The parameter is incorrect." - // even though a perfectly valid (non-symlink) path is given. This has been - // seen with a non C: drive path, but not reproduced, so just catch the - // exception and log it. - try { - if (fs::is_symlink(path)) - return fs::read_symlink(path); - } catch (const std::exception& e) { - auto logger = getLogger(); - if (logger) { - logger->error("Could not check or read potential symlink path \"{}\": {}", - path.u8string(), - e.what()); - } - } - - return path; -} - -LOOT_API void SetLoggingCallback( - std::function callback) { - const auto logger = createLogger(callback); - - spdlog::drop(logger->name()); - spdlog::register_logger(logger); -} - -LOOT_API void SetLogLevel(LogLevel level) { setLoggerLevel(level); } - -LOOT_API bool IsCompatible(const unsigned int versionMajor, - const unsigned int versionMinor, - const unsigned int) { - if (versionMajor > 0) - return versionMajor == LIBLOOT_VERSION_MAJOR; - else - return versionMinor == LIBLOOT_VERSION_MINOR; -} - -LOOT_API std::unique_ptr CreateGameHandle( - const GameType game, - const std::filesystem::path& gamePath, - const std::filesystem::path& gameLocalPath) { - auto logger = getLogger(); - if (logger) { - logger->info( - "Attempting to create a game handle for game type \"{}\" with game " - "path \"{}\" and game local path \"{}\"", - DescribeGameType(game), - gamePath.u8string(), - gameLocalPath.u8string()); - } - - auto resolvedGamePath = ResolvePath(gamePath); - if (!fs::is_directory(resolvedGamePath)) { - throw std::invalid_argument("Given game path \"" + gamePath.u8string() + - "\" does not resolve to a valid directory."); - } - - auto resolvedGameLocalPath = ResolvePath(gameLocalPath); - if (!gameLocalPath.empty() && fs::exists(resolvedGameLocalPath) && - !fs::is_directory(resolvedGameLocalPath)) { - throw std::invalid_argument( - "Given game local path \"" + gameLocalPath.u8string() + - "\" resolves to a path that exists but is not a valid directory."); - } - - return std::make_unique(game, resolvedGamePath, resolvedGameLocalPath); -} -} diff --git a/src/api/api_database.cpp b/src/api/api_database.cpp deleted file mode 100644 index 2eff574a..00000000 --- a/src/api/api_database.cpp +++ /dev/null @@ -1,291 +0,0 @@ -/* LOOT - - A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and - Fallout: New Vegas. - - Copyright (C) 2013-2016 WrinklyNinja - - This file is part of LOOT. - - LOOT is free software: you can redistribute - it and/or modify it under the terms of the GNU General Public License - as published by the Free Software Foundation, either version 3 of - the License, or (at your option) any later version. - - LOOT is distributed in the hope that it will - be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with LOOT. If not, see - . - */ - -#include "api/api_database.h" - -#include -#include -#include - -#include "api/game/game.h" -#include "api/metadata/condition_evaluator.h" -#include "api/metadata/yaml/plugin_metadata.h" -#include "api/sorting/group_sort.h" -#include "api/sorting/plugin_sort.h" -#include "loot/metadata/group.h" - -namespace { -using loot::Group; - -std::vector MergeGroups(const std::vector& masterlistGroups, - const std::vector& userGroups) { - auto mergedGroups = masterlistGroups; - - std::vector newGroups; - for (const auto& userGroup : userGroups) { - auto groupIt = - std::find_if(mergedGroups.begin(), - mergedGroups.end(), - [&](const Group& existingGroup) { - return existingGroup.GetName() == userGroup.GetName(); - }); - - if (groupIt == mergedGroups.end()) { - newGroups.push_back(userGroup); - } else { - // Replace the masterlist group description with the userlist group - // description if the latter is not empty. - auto description = userGroup.GetDescription().empty() - ? groupIt->GetDescription() - : userGroup.GetDescription(); - - auto afterGroups = groupIt->GetAfterGroups(); - auto userAfterGroups = userGroup.GetAfterGroups(); - afterGroups.insert( - afterGroups.end(), userAfterGroups.begin(), userAfterGroups.end()); - - *groupIt = Group(userGroup.GetName(), afterGroups, description); - } - } - - mergedGroups.insert(mergedGroups.end(), newGroups.cbegin(), newGroups.cend()); - - return mergedGroups; -} -} - -namespace loot { -ApiDatabase::ApiDatabase( - std::shared_ptr conditionEvaluator) : - conditionEvaluator_(conditionEvaluator) {} - -/////////////////////////////////// -// Database Loading Functions -/////////////////////////////////// - -void ApiDatabase::LoadMasterlist(const std::filesystem::path& masterlistPath) { - MetadataList temp; - - if (std::filesystem::exists(masterlistPath)) { - temp.Load(masterlistPath); - } else { - throw std::runtime_error("The given masterlist path does not exist: " + - masterlistPath.u8string()); - } - - masterlist_ = temp; -} - -void ApiDatabase::LoadMasterlistWithPrelude( - const std::filesystem::path& masterlistPath, - const std::filesystem::path& masterlistPreludePath) { - MetadataList temp; - - if (std::filesystem::exists(masterlistPath)) { - if (std::filesystem::exists(masterlistPreludePath)) { - temp.LoadWithPrelude(masterlistPath, masterlistPreludePath); - } else { - throw std::runtime_error( - "The given masterlist prelude path does not exist: " + - masterlistPreludePath.u8string()); - } - } else { - throw std::runtime_error("The given masterlist path does not exist: " + - masterlistPath.u8string()); - } - - masterlist_ = temp; -} - -void ApiDatabase::LoadUserlist(const std::filesystem::path& userlistPath) { - MetadataList temp; - - if (std::filesystem::exists(userlistPath)) { - temp.Load(userlistPath); - } else { - throw std::runtime_error("The given userlist path does not exist: " + - userlistPath.u8string()); - } - - userlist_ = temp; -} - -void ApiDatabase::WriteUserMetadata(const std::filesystem::path& outputFile, - const bool overwrite) const { - if (!std::filesystem::exists(outputFile.parent_path())) - throw std::invalid_argument("Output directory does not exist."); - - if (std::filesystem::exists(outputFile) && !overwrite) - throw std::runtime_error( - "Output file exists but overwrite is not set to true."); - - userlist_.Save(outputFile); -} - -bool ApiDatabase::Evaluate(const std::string& condition) const { - return conditionEvaluator_->Evaluate(condition); -} - -////////////////////////// -// DB Access Functions -////////////////////////// - -std::vector ApiDatabase::GetKnownBashTags() const { - auto masterlistTags = masterlist_.BashTags(); - auto userlistTags = userlist_.BashTags(); - - if (!userlistTags.empty()) { - masterlistTags.insert(std::end(masterlistTags), - std::begin(userlistTags), - std::end(userlistTags)); - } - - return masterlistTags; -} - -std::vector ApiDatabase::GetGeneralMessages( - bool evaluateConditions) const { - auto masterlistMessages = masterlist_.Messages(); - auto userlistMessages = userlist_.Messages(); - - if (!userlistMessages.empty()) { - masterlistMessages.insert(std::end(masterlistMessages), - std::begin(userlistMessages), - std::end(userlistMessages)); - } - - if (evaluateConditions) { - // Evaluate conditions from scratch. - conditionEvaluator_->ClearConditionCache(); - for (auto it = std::begin(masterlistMessages); - it != std::end(masterlistMessages);) { - if (!conditionEvaluator_->Evaluate(it->GetCondition())) - it = masterlistMessages.erase(it); - else - ++it; - } - } - - return masterlistMessages; -} - -std::vector ApiDatabase::GetGroups(bool includeUserMetadata) const { - if (includeUserMetadata) { - return MergeGroups(masterlist_.Groups(), userlist_.Groups()); - } - - return masterlist_.Groups(); -} - -std::vector ApiDatabase::GetUserGroups() const { - return userlist_.Groups(); -} - -void ApiDatabase::SetUserGroups(const std::vector& groups) { - userlist_.SetGroups(groups); -} - -std::vector ApiDatabase::GetGroupsPath( - std::string_view fromGroupName, - std::string_view toGroupName) const { - auto masterlistGroups = GetGroups(false); - auto userGroups = GetUserGroups(); - - const auto groupGraph = BuildGroupGraph(masterlistGroups, userGroups); - - return loot::GetGroupsPath(groupGraph, fromGroupName, toGroupName); -} - -std::optional ApiDatabase::GetPluginMetadata( - std::string_view plugin, - bool includeUserMetadata, - bool evaluateConditions) const { - auto metadata = masterlist_.FindPlugin(plugin); - - if (includeUserMetadata) { - auto userMetadata = userlist_.FindPlugin(plugin); - if (userMetadata.has_value()) { - if (metadata.has_value()) { - userMetadata.value().MergeMetadata(metadata.value()); - } - metadata = userMetadata; - } - } - - if (evaluateConditions && metadata.has_value()) { - return conditionEvaluator_->EvaluateAll(metadata.value()); - } - - return metadata; -} - -std::optional ApiDatabase::GetPluginUserMetadata( - std::string_view plugin, - bool evaluateConditions) const { - auto metadata = userlist_.FindPlugin(plugin); - - if (evaluateConditions && metadata) { - return conditionEvaluator_->EvaluateAll(metadata.value()); - } - - return metadata; -} - -void ApiDatabase::SetPluginUserMetadata(const PluginMetadata& pluginMetadata) { - userlist_.ErasePlugin(pluginMetadata.GetName()); - userlist_.AddPlugin(pluginMetadata); -} - -void ApiDatabase::DiscardPluginUserMetadata(std::string_view plugin) { - userlist_.ErasePlugin(plugin); -} - -void ApiDatabase::DiscardAllUserMetadata() { userlist_.Clear(); } - -// Writes a minimal masterlist that only contains mods that have Bash Tag -// suggestions, and/or dirty messages, plus the Tag suggestions and/or messages -// themselves and their conditions, in order to create the Wrye Bash taglist. -// outputFile is the path to use for output. If outputFile already exists, it -// will only be overwritten if overwrite is true. -void ApiDatabase::WriteMinimalList(const std::filesystem::path& outputFile, - const bool overwrite) const { - if (!std::filesystem::exists(outputFile.parent_path())) - throw std::invalid_argument("Output directory does not exist."); - - if (std::filesystem::exists(outputFile) && !overwrite) - throw std::runtime_error( - "Output file exists but overwrite is not set to true."); - - MetadataList minimalList; - for (const auto& plugin : masterlist_.Plugins()) { - PluginMetadata minimalPlugin(plugin.GetName()); - minimalPlugin.SetTags(plugin.GetTags()); - minimalPlugin.SetDirtyInfo(plugin.GetDirtyInfo()); - - minimalList.AddPlugin(minimalPlugin); - } - - minimalList.Save(outputFile); -} -} diff --git a/src/api/api_database.h b/src/api/api_database.h deleted file mode 100644 index 6e34392d..00000000 --- a/src/api/api_database.h +++ /dev/null @@ -1,94 +0,0 @@ -/* LOOT - - A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and - Fallout: New Vegas. - - Copyright (C) 2013-2016 WrinklyNinja - - This file is part of LOOT. - - LOOT is free software: you can redistribute - it and/or modify it under the terms of the GNU General Public License - as published by the Free Software Foundation, either version 3 of - the License, or (at your option) any later version. - - LOOT is distributed in the hope that it will - be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with LOOT. If not, see - . - */ - -#ifndef LOOT_API_LOOT_DB -#define LOOT_API_LOOT_DB - -#include -#include -#include - -#include "api/metadata/condition_evaluator.h" -#include "api/metadata_list.h" -#include "loot/database_interface.h" -#include "loot/enum/game_type.h" -#include "loot/vertex.h" - -namespace loot { -struct ApiDatabase final : public DatabaseInterface { - explicit ApiDatabase(std::shared_ptr conditionEvaluator); - - void LoadMasterlist( - const std::filesystem::path& masterlistPath) override; - - void LoadMasterlistWithPrelude( - const std::filesystem::path& masterlistPath, - const std::filesystem::path& masterlistPreludePath) override; - - void LoadUserlist( - const std::filesystem::path& userlistPath) override; - - void WriteUserMetadata(const std::filesystem::path& outputFile, - const bool overwrite) const override; - - void WriteMinimalList(const std::filesystem::path& outputFile, - const bool overwrite) const override; - - bool Evaluate(const std::string& condition) const override; - - std::vector GetKnownBashTags() const override; - - std::vector GetGeneralMessages( - bool evaluateConditions = false) const override; - - std::vector GetGroups(bool includeUserMetadata = true) const override; - std::vector GetUserGroups() const override; - void SetUserGroups(const std::vector& groups) override; - std::vector GetGroupsPath( - std::string_view fromGroupName, - std::string_view toGroupName) const override; - - std::optional GetPluginMetadata( - std::string_view plugin, - bool includeUserMetadata = true, - bool evaluateConditions = false) const override; - - std::optional GetPluginUserMetadata( - std::string_view plugin, - bool evaluateConditions = false) const override; - - void SetPluginUserMetadata(const PluginMetadata& pluginMetadata) override; - - void DiscardPluginUserMetadata(std::string_view plugin) override; - - void DiscardAllUserMetadata() override; - -private: - std::shared_ptr conditionEvaluator_; - MetadataList masterlist_; - MetadataList userlist_; -}; -} - -#endif diff --git a/src/api/bsa.cpp b/src/api/bsa.cpp deleted file mode 100644 index 7aea15fd..00000000 --- a/src/api/bsa.cpp +++ /dev/null @@ -1,391 +0,0 @@ -/* LOOT - - A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and - Fallout: New Vegas. - - Copyright (C) 2022 Oliver Hamlet - - This file is part of LOOT. - - LOOT is free software: you can redistribute - it and/or modify it under the terms of the GNU General Public License - as published by the Free Software Foundation, either version 3 of - the License, or (at your option) any later version. - - LOOT is distributed in the hope that it will - be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with LOOT. If not, see - . - */ - -#include "api/bsa.h" - -#include -#include -#include - -#include "api/bsa_detail.h" -#include "api/helpers/logging.h" - -namespace loot { -/* -BSA format documentation: - -- Oblivion: https://en.uesp.net/wiki/Oblivion_Mod:BSA_File_Format -- Fallout 3, Fallout New Vegas, Skyrim, Skyrim Special Edition: - https://en.uesp.net/wiki/Skyrim_Mod:Archive_File_Format - -*/ -constexpr std::array BSA_TYPE_ID = {'B', 'S', 'A', '\0'}; -constexpr std::array BA2_TYPE_ID = {'B', 'T', 'D', 'X'}; -constexpr std::array BA2_GENERAL_TYPE = {'G', 'N', 'R', 'L'}; -constexpr std::array BA2_TEXTURE_TYPE = {'D', 'X', '1', '0'}; - -namespace bsa { -namespace v103 { -struct FolderRecord { - uint64_t nameHash{0}; - uint32_t fileCount{0}; - uint32_t fileRecordsOffset{0}; -}; - -std::map> GetAssetsInBSA(std::istream& in, - const Header& header) { - return detail::GetAssetsInBSA(in, header); -} -} - -namespace v104 { -using v103::FolderRecord; - -using v103::GetAssetsInBSA; -} - -namespace v105 { -struct FolderRecord { - uint64_t nameHash{0}; - uint32_t fileCount{0}; - uint32_t padding1{0}; - uint32_t fileRecordsOffset{0}; - uint32_t padding2{0}; -}; - -std::map> GetAssetsInBSA(std::istream& in, - const Header& header) { - return detail::GetAssetsInBSA(in, header); -} -} - -std::map> GetAssetsInBSA( - std::istream& in, - const bsa::Header& header) { - const auto logger = getLogger(); - - // Validate the header. - if (header.typeId != BSA_TYPE_ID || - !(header.version == 103 || header.version == 104 || - header.version == 105) || - header.recordsOffset != 36) { - throw std::runtime_error("BSA file has an invalid header"); - } - - if ((header.archiveFlags & 0x40) != 0) { - throw std::runtime_error("BSA file uses big-endian numbers"); - } - - if (header.version == 103) { - return bsa::v103::GetAssetsInBSA(in, header); - } - - if (header.version == 104) { - return bsa::v104::GetAssetsInBSA(in, header); - } - - if (header.version == 105) { - return bsa::v105::GetAssetsInBSA(in, header); - } - - throw std::runtime_error("BSA file has an unrecognised version"); -} -} - -namespace ba2 { -struct Header { - std::array typeId{}; - uint32_t version{0}; - std::array archiveType{}; - uint32_t fileCount{0}; - uint64_t filePathsOffset{0}; -}; - -void StoreHashes(std::map>& folderFileHashes, - const uint64_t fileHash, - const uint64_t folderHash) { - const auto folderResult = - folderFileHashes.emplace(folderHash, std::set({fileHash})); - - if (!folderResult.second) { - // Folder hash already stored, add file hash to existing set. - const auto fileResult = folderResult.first->second.insert(fileHash); - - if (!fileResult.second) { - const auto message = fmt::format( - "Unexpected collision for file name hash {:x} in set for folder name " - "hash {:x}", - fileHash, - folderHash); - throw std::runtime_error(message); - } - } -} - -// Normalise the path the same way that BA2 hashes do (it's the same as for -// BSAs). -void NormalisePath(std::string& filePath) { - for (size_t i = 0; i < filePath.size(); ++i) { - // Ignore any non-ASCII characters. - if (filePath[i] > 127) { - continue; - } - - // Replace any forwardslashes with backslashes and - // lowercase any other characters. - filePath[i] = filePath[i] == '/' - ? '\\' - : static_cast(std::tolower( - static_cast(filePath[i]))); - } -} - -std::map> GetAssetsInBA2FromFilePaths( - std::istream& in, - const Header& header) { - // BA2s use 32-bit hashes and I've observed collisions between different - // official Fallout 4 BA2s, so calculate new 64-bit hashes instead of - // using the hashes in the BA2. - const auto logger = getLogger(); - - std::map> folderFileHashes; - - // Skip to list of file paths at the end of the BA2. - in.seekg(header.filePathsOffset, std::ios_base::beg); - - // The file paths are prefixed by a two-byte length, and not null-terminated. - for (size_t i = 0; i < header.fileCount; ++i) { - uint16_t pathLength = 0; - in.read(reinterpret_cast(&pathLength), sizeof(pathLength)); - - std::string filePath(pathLength, '\0'); - in.read(filePath.data(), pathLength); - - // Normalise the path the same as is done for BSA/BA2 hash calculation, - // so that equivalent but not equal paths (e.g. due to upper/lowercase - // differences) are hashed to the same value. - NormalisePath(filePath); - - // Trim trailing and leading slashes. - boost::trim_if(filePath, [](const char c) { return c == '\\'; }); - - // Now split the path so that its folder and file hashes can be calculated. - const auto index = filePath.rfind("\\"); - if (index == std::string::npos) { - // No slash, no directory, use a hash of zero. - const uint64_t fileHash = std::hash{}(filePath); - const uint64_t folderHash = 0; - StoreHashes(folderFileHashes, fileHash, folderHash); - } else { - // Split the string in two. - const auto folderPath = filePath.substr(0, index); - filePath = filePath.substr(index + 1); - - const uint64_t fileHash = std::hash{}(filePath); - const uint64_t folderHash = std::hash{}(folderPath); - StoreHashes(folderFileHashes, fileHash, folderHash); - } - } - - return folderFileHashes; -} - -std::map> GetAssetsInBA2(std::istream& in, - const Header& header) { - // Validate the header. - if (header.typeId != BA2_TYPE_ID) { - throw std::runtime_error("BA2 file header type ID is invalid"); - } - - // The header version is 1, 7 or 8 for Fallout 4 and 2 or 3 for Starfield. - if (header.version != 1 && header.version != 2 && header.version != 3 && - header.version != 7 && header.version != 8) { - throw std::runtime_error("BA2 file header version is invalid"); - } - - if (header.archiveType != BA2_GENERAL_TYPE && - header.archiveType != BA2_TEXTURE_TYPE) { - throw std::runtime_error("BA2 file header archive type is invalid"); - } - - return GetAssetsInBA2FromFilePaths(in, header); -} -} - -// Fallout4.esm and DLCUltraHighResolution.esm from Fallout 4 have the -// same file path appearing in multiple BA2 files, so ignore hash -// collision warnings for those files as otherwise they cause a lot of -// noise in the logs. -bool ShouldWarnAboutHashCollisions(const std::filesystem::path& archivePath) { - const auto filename = archivePath.filename().u8string(); - - return !boost::iends_with(filename, BA2_FILE_EXTENSION) || - (!boost::istarts_with(filename, "Fallout4 - ") && - !boost::istarts_with(filename, "DLCUltraHighResolution - ")); -} - -bool DoFileNameHashSetsIntersect(const std::set& left, - const std::set& right) { - auto leftIt = left.begin(); - auto rightIt = right.begin(); - - while (leftIt != left.end() && rightIt != right.end()) { - if (*leftIt < *rightIt) { - ++leftIt; - } else if (*leftIt > *rightIt) { - ++rightIt; - } else { - return true; - } - } - - return false; -} - -std::map> GetAssetsInBethesdaArchive( - const std::filesystem::path& archivePath) { - // If parsing the BSA fails, log the error but don't throw an exception as - // an issue with one archive (which may just be invalid) shouldn't cause - // others not to be loaded. - - const auto logger = getLogger(); - - if (!std::filesystem::exists(archivePath)) { - if (logger) { - throw std::runtime_error("Bethesda archive does not exist"); - } - } - - std::ifstream in(archivePath, std::ios::binary); - in.exceptions(std::ios::failbit | std::ios::badbit | - std::ios::eofbit); // Causes ifstream::failure to be thrown if - // a problem is encountered. - - std::array typeId{}; - in.read(typeId.data(), typeId.size()); - - if (typeId == BSA_TYPE_ID) { - bsa::Header header; - header.typeId = typeId; - - // Read the rest of the header. - in.read(reinterpret_cast(&header) + typeId.size(), - sizeof(bsa::Header) - typeId.size()); - - return bsa::GetAssetsInBSA(in, header); - } - - if (typeId == BA2_TYPE_ID) { - ba2::Header header; - header.typeId = typeId; - - // Read the rest of the header. - in.read(reinterpret_cast(&header) + typeId.size(), - sizeof(ba2::Header) - typeId.size()); - - return ba2::GetAssetsInBA2(in, header); - } - - throw std::runtime_error("Bethesda archive has unrecognised typeId"); -} - -std::map> GetAssetsInBethesdaArchives( - const std::vector& archivePaths) { - const auto logger = getLogger(); - - std::map> archiveAssets; - - for (const auto& archivePath : archivePaths) { - try { - if (logger) { - logger->trace( - "Getting assets loaded from the Bethesda archive at \"{}\"", - archivePath.u8string()); - } - - const auto warnAboutHashCollisions = - ShouldWarnAboutHashCollisions(archivePath); - - const auto assets = GetAssetsInBethesdaArchive(archivePath); - for (const auto& asset : assets) { - const auto folderResult = archiveAssets.insert(asset); - if (!folderResult.second) { - // Folder already exists, add the files to its set. - // Don't just insert the range, as it would be good to - // log if a file's hash is already present - you wouldn't - // expect the same file to appear in the same folder in - // two different BSAs loaded by the same plugin. - /*result.first->second.insert(asset.second.begin(), - asset.second.end());*/ - for (const auto& fileNameHash : asset.second) { - const auto fileResult = - folderResult.first->second.insert(fileNameHash); - if (!fileResult.second && warnAboutHashCollisions && logger) { - logger->warn( - "The folder and file with hashes {:x} and {:x} in \"{}\" are " - "present in another BSA.", - asset.first, - fileNameHash, - archivePath.u8string()); - } - } - } - } - } catch (const std::exception& e) { - if (logger) { - logger->error( - "Caught exception while trying to read Bethesda archive file " - "at \"{}\": {}", - archivePath.u8string(), - e.what()); - } - } - } - - return archiveAssets; -} - -bool DoAssetsIntersect(const std::map>& left, - const std::map>& right) { - auto leftIt = left.begin(); - auto rightIt = right.begin(); - - while (leftIt != left.end() && rightIt != right.end()) { - if (leftIt->first < rightIt->first) { - ++leftIt; - } else if (leftIt->first > rightIt->first) { - ++rightIt; - } else if (DoFileNameHashSetsIntersect(leftIt->second, rightIt->second)) { - return true; - } else { - // The folder hashes are equal but they don't contain any of the same - // file hashes, move on to the next folder. It doesn't matter which - // iterator gets incremented. - ++leftIt; - } - } - - return false; -} -} diff --git a/src/api/bsa.h b/src/api/bsa.h deleted file mode 100644 index c5898119..00000000 --- a/src/api/bsa.h +++ /dev/null @@ -1,47 +0,0 @@ -/* LOOT - - A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and - Fallout: New Vegas. - - Copyright (C) 2022 Oliver Hamlet - - This file is part of LOOT. - - LOOT is free software: you can redistribute - it and/or modify it under the terms of the GNU General Public License - as published by the Free Software Foundation, either version 3 of - the License, or (at your option) any later version. - - LOOT is distributed in the hope that it will - be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with LOOT. If not, see - . - */ -#ifndef LOOT_API_BSA -#define LOOT_API_BSA - -#include -#include -#include -#include -#include - -namespace loot { -inline constexpr std::string_view BSA_FILE_EXTENSION = ".bsa"; -inline constexpr std::string_view BA2_FILE_EXTENSION = ".ba2"; - -std::map> GetAssetsInBethesdaArchive( - const std::filesystem::path& archivePath); - -std::map> GetAssetsInBethesdaArchives( - const std::vector& archivePaths); - -bool DoAssetsIntersect(const std::map>& left, - const std::map>& right); -} - -#endif diff --git a/src/api/bsa_detail.h b/src/api/bsa_detail.h deleted file mode 100644 index 56231cb8..00000000 --- a/src/api/bsa_detail.h +++ /dev/null @@ -1,138 +0,0 @@ -/* LOOT - - A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and - Fallout: New Vegas. - - Copyright (C) 2022 Oliver Hamlet - - This file is part of LOOT. - - LOOT is free software: you can redistribute - it and/or modify it under the terms of the GNU General Public License - as published by the Free Software Foundation, either version 3 of - the License, or (at your option) any later version. - - LOOT is distributed in the hope that it will - be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with LOOT. If not, see - . - */ -#ifndef LOOT_API_BSA_DETAIL -#define LOOT_API_BSA_DETAIL - -#include -#include -#include -#include -#include - -#include "api/helpers/logging.h" - -namespace loot::bsa { -struct Header { - std::array typeId{}; // Should always be "BSA\0" - uint32_t version{0}; // 103 (0x67) for TES4, 104 (0x68) for FO3, FONV, TES5, - // 105 - // (0x69) for TES5SE. - uint32_t recordsOffset{0}; - uint32_t archiveFlags{0}; - uint32_t folderCount{0}; - uint32_t totalFileCount{0}; - uint32_t totalFolderNamesLength{0}; - uint32_t totalFileNamesLength{0}; - uint32_t contentTypeFlags{0}; -}; - -struct FileRecord { - uint64_t nameHash{0}; - uint32_t dataLength{0}; - uint32_t dataOffset{0}; -}; -} - -namespace loot::bsa::detail { -template -std::map> GetAssetsInBSA(std::istream& in, - const Header& header) { - const auto logger = getLogger(); - - std::vector folderRecords(header.folderCount); - in.read(reinterpret_cast(folderRecords.data()), - sizeof(FolderRecord) * folderRecords.size()); - - // The next block consists of per-folder subblocks that are each a - // byte containing the folder name length, the null-terminated folder name - // and then the file records for that folder. - const auto fileRecordsSize = header.folderCount + - header.totalFolderNamesLength + - sizeof(FileRecord) * header.totalFileCount; - std::vector fileRecordsBytes(fileRecordsSize); - in.read(reinterpret_cast(fileRecordsBytes.data()), fileRecordsSize); - - // For each folder record, store its hash with the hashes of the files in that - // folder. - std::map> folderFileHashes; - - // FolderRecord.fileRecordsOffset is relative to this baseline. In the file - // fileRecordsOffset - header.totalFileNamesLength is the start off the - // folder's subblock relative to the start of the file, but the baseline is - // from the start of the fileRecords vector. - const auto folderRecordOffsetBaseline = - sizeof(Header) + sizeof(FolderRecord) * header.folderCount + - header.totalFileNamesLength; - - for (const auto& folderRecord : folderRecords) { - const auto folderHash = folderRecord.nameHash; - - const auto folderResult = - folderFileHashes.emplace(folderHash, std::set()); - - if (!folderResult.second) { - throw std::runtime_error("Unexpected collision for folder name hash " + - std::to_string(folderHash)); - } - - size_t fileRecordsOffset = 0; - if ((header.archiveFlags & 0x1) == 0) { - // Directory names are not included. - fileRecordsOffset = - folderRecord.fileRecordsOffset - folderRecordOffsetBaseline; - } else { - // Directory names are included. - const auto folderNameLengthOffset = - folderRecord.fileRecordsOffset - folderRecordOffsetBaseline; - - const auto folderNameLength = fileRecordsBytes.at(folderNameLengthOffset); - - // The real file records offset. - fileRecordsOffset = folderNameLengthOffset + 1 + folderNameLength; - } - - for (size_t i = 0; i < folderRecord.fileCount; ++i) { - const auto fileRecordOffset = - fileRecordsBytes.data() + fileRecordsOffset + i * sizeof(FileRecord); - - const FileRecord* fileRecord = - reinterpret_cast(fileRecordOffset); - - const auto result = - folderResult.first->second.insert(fileRecord->nameHash); - - if (!result.second) { - throw std::runtime_error("Unexpected collision for file name hash " + - std::to_string(fileRecord->nameHash) + - " in set for folder name hash " + - std::to_string(folderHash)); - } - } - } - - return folderFileHashes; -} -} - -#endif diff --git a/src/api/game/game.cpp b/src/api/game/game.cpp deleted file mode 100644 index 66691d08..00000000 --- a/src/api/game/game.cpp +++ /dev/null @@ -1,347 +0,0 @@ -/* LOOT - - A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and - Fallout: New Vegas. - - Copyright (C) 2013-2016 WrinklyNinja - - This file is part of LOOT. - - LOOT is free software: you can redistribute - it and/or modify it under the terms of the GNU General Public License - as published by the Free Software Foundation, either version 3 of - the License, or (at your option) any later version. - - LOOT is distributed in the hope that it will - be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with LOOT. If not, see - . - */ - -#include "api/game/game.h" - -#include -#include -#include -#include -#include -#include - -#include "api/api_database.h" -#include "api/helpers/logging.h" -#include "api/sorting/plugin_sort.h" -#include "loot/exception/plugin_not_loaded_error.h" - -#ifdef _WIN32 -#ifndef UNICODE -#define UNICODE -#endif -#ifndef _UNICODE -#define _UNICODE -#endif -#include "shlobj.h" -#include "shlwapi.h" -#include "windows.h" -#endif - -using std::filesystem::u8path; - -namespace { -using loot::GameType; - -std::filesystem::path ResolvePluginPath( - GameType gameType, - const std::filesystem::path& dataPath, - const std::filesystem::path& pluginPath) { - auto absolutePath = - pluginPath.is_absolute() ? pluginPath : dataPath / pluginPath; - - // In case the plugin is ghosted. - if (gameType != GameType::openmw && !std::filesystem::exists(absolutePath)) { - const auto logger = loot::getLogger(); - if (logger) { - logger->debug("Could not find plugin at {}, adding {} file extension", - absolutePath.u8string(), - loot::GHOST_FILE_EXTENSION); - } - absolutePath += loot::GHOST_FILE_EXTENSION; - } - - return absolutePath; -} - -std::vector FindArchives( - const std::filesystem::path& parentPath, - std::string_view archiveFileExtension) { - if (!std::filesystem::is_directory(parentPath)) { - return {}; - } - - std::vector archivePaths; - - for (std::filesystem::directory_iterator it(parentPath); - it != std::filesystem::directory_iterator(); - ++it) { - // This is only correct for ASCII strings, but that's all that - // GetArchiveFileExtension() can return. It's a lot faster than the more - // generally-correct approach of testing file path equivalence when - // there are a lot of entries in DataPath(). - if (it->is_regular_file() && - boost::iends_with(it->path().u8string(), archiveFileExtension)) { - archivePaths.push_back(it->path()); - } - } - - return archivePaths; -} -} - -namespace loot { -Game::Game(const GameType gameType, - const std::filesystem::path& gamePath, - const std::filesystem::path& localDataPath) : - type_(gameType), - gamePath_(gamePath), - loadOrderHandler_(type_, gamePath_, localDataPath), - conditionEvaluator_( - std::make_shared(GetType(), DataPath())), - database_(ApiDatabase(conditionEvaluator_)) { - additionalDataPaths_ = loadOrderHandler_.GetAdditionalDataPaths(); - conditionEvaluator_->SetAdditionalDataPaths(additionalDataPaths_); -} - -GameType Game::GetType() const { return type_; } - -std::filesystem::path Game::DataPath() const { - if (type_ == GameType::tes3) { - return gamePath_ / "Data Files"; - } else if (type_ == GameType::openmw) { - return gamePath_ / "resources" / "vfs"; - } else if (type_ == GameType::oblivionRemastered) { - return gamePath_ / "OblivionRemastered" / "Content" / "Dev" / "ObvData" / - "Data"; - } else { - return gamePath_ / "Data"; - } -} - -GameCache& Game::GetCache() { return cache_; } - -const GameCache& Game::GetCache() const { return cache_; } - -LoadOrderHandler& Game::GetLoadOrderHandler() { return loadOrderHandler_; } - -const LoadOrderHandler& Game::GetLoadOrderHandler() const { - return loadOrderHandler_; -} - -const DatabaseInterface& Game::GetDatabase() const { return database_; } - -DatabaseInterface& Game::GetDatabase() { return database_; } - -std::vector Game::GetAdditionalDataPaths() const { - return additionalDataPaths_; -} - -void Game::SetAdditionalDataPaths( - const std::vector& additionalDataPaths) { - additionalDataPaths_ = additionalDataPaths; - - conditionEvaluator_->SetAdditionalDataPaths(additionalDataPaths_); - conditionEvaluator_->ClearConditionCache(); - loadOrderHandler_.SetAdditionalDataPaths(additionalDataPaths_); -} - -bool Game::IsValidPlugin(const std::filesystem::path& pluginPath) const { - return Plugin::IsValid(GetType(), - ResolvePluginPath(GetType(), DataPath(), pluginPath)); -} - -void Game::LoadPlugins(const std::vector& pluginPaths, - bool loadHeadersOnly) { - const auto logger = getLogger(); - - // Check that all plugin filenames are unique. - std::unordered_set filenames; - for (const auto& pluginPath : pluginPaths) { - const auto filename = NormalizeFilename(pluginPath.filename().u8string()); - const auto inserted = filenames.insert(filename).second; - if (!inserted) { - throw std::invalid_argument("The filename \"" + filename + - "\" is not unique."); - } - } - - // Validate the plugins (the validity check is done in parallel because - // it's relatively slow). - const auto invalidPluginIt = - std::find_if(std::execution::par_unseq, - pluginPaths.cbegin(), - pluginPaths.cend(), - [this](const std::filesystem::path& pluginPath) { - try { - return !IsValidPlugin(pluginPath); - } catch (...) { - return true; - } - }); - - if (invalidPluginIt != pluginPaths.end()) { - throw std::invalid_argument("\"" + invalidPluginIt->u8string() + - "\" is not a valid plugin"); - } - - // Search for and cache archives. - CacheArchives(); - - // Load the plugins. - if (logger) { - logger->trace("Starting plugin loading."); - } - - std::mutex mutex; - std::vector plugins; - std::for_each( - std::execution::par_unseq, - pluginPaths.begin(), - pluginPaths.end(), - [&](const std::filesystem::path& pluginPath) { - try { - const auto resolvedPluginPath = - ResolvePluginPath(GetType(), DataPath(), pluginPath); - - auto plugin = - Plugin(GetType(), cache_, resolvedPluginPath, loadHeadersOnly); - - std::lock_guard lock(mutex); - - plugins.push_back(std::move(plugin)); - } catch (const std::exception& e) { - if (logger) { - logger->error( - "Caught exception while trying to add {} to the cache: {}", - pluginPath.u8string(), - e.what()); - } - } - }); - - if (!loadHeadersOnly && - (GetType() == GameType::tes3 || GetType() == GameType::openmw || - GetType() == GameType::starfield)) { - const auto loadedPlugins = cache_.GetPluginsWithReplacements(plugins); - - const auto pluginsMetadata = Plugin::GetPluginsMetadata(loadedPlugins); - for (auto& plugin : plugins) { - plugin.ResolveRecordIds(pluginsMetadata.get()); - } - } - - for (auto& plugin : plugins) { - cache_.AddPlugin(std::move(plugin)); - } - - conditionEvaluator_->RefreshLoadedPluginsState(GetLoadedPlugins()); -} - -void Game::ClearLoadedPlugins() { cache_.ClearCachedPlugins(); } - -std::shared_ptr Game::GetPlugin( - std::string_view pluginName) const { - return cache_.GetPlugin(pluginName); -} - -std::vector> Game::GetLoadedPlugins() - const { - std::vector> interfacePointers; - for (const auto& plugin : cache_.GetPlugins()) { - interfacePointers.push_back(plugin); - } - - return interfacePointers; -} - -std::vector Game::SortPlugins( - const std::vector& pluginFilenames) { - std::vector plugins; - for (const auto& pluginFilename : pluginFilenames) { - const auto plugin = cache_.GetPlugin(pluginFilename); - if (plugin == nullptr) { - throw PluginNotLoadedError("The plugin \"" + pluginFilename + - "\" has not been loaded."); - } - - plugins.push_back(plugin.get()); - } - - auto pluginsSortingData = GetPluginsSortingData(database_, plugins); - - const auto logger = getLogger(); - if (logger) { - logger->debug("Current load order:"); - for (const auto& plugin : pluginFilenames) { - logger->debug("\t{}", plugin); - } - } - - const auto newLoadOrder = - loot::SortPlugins(std::move(pluginsSortingData), - database_.GetGroups(false), - database_.GetUserGroups(), - loadOrderHandler_.GetEarlyLoadingPlugins()); - - if (logger) { - logger->debug("Calculated order:"); - for (const auto& name : newLoadOrder) { - logger->debug("\t{}", name); - } - } - - return newLoadOrder; -} - -void Game::LoadCurrentLoadOrderState() { - loadOrderHandler_.LoadCurrentState(); - conditionEvaluator_->RefreshActivePluginsState( - loadOrderHandler_.GetActivePlugins()); -} - -bool Game::IsLoadOrderAmbiguous() const { - return loadOrderHandler_.IsAmbiguous(); -} - -std::filesystem::path Game::GetActivePluginsFilePath() const { - return loadOrderHandler_.GetActivePluginsFilePath(); -} - -bool Game::IsPluginActive(const std::string& pluginName) const { - return loadOrderHandler_.IsPluginActive(std::string(pluginName)); -} - -std::vector Game::GetLoadOrder() const { - return loadOrderHandler_.GetLoadOrder(); -} - -void Game::SetLoadOrder(const std::vector& loadOrder) { - loadOrderHandler_.SetLoadOrder(loadOrder); -} - -void Game::CacheArchives() { - const auto archiveFileExtension = GetArchiveFileExtension(GetType()); - - std::set archivePaths; - for (const auto& parentPath : additionalDataPaths_) { - const auto archives = FindArchives(parentPath, archiveFileExtension); - archivePaths.insert(archives.begin(), archives.end()); - } - - const auto archives = FindArchives(DataPath(), archiveFileExtension); - archivePaths.insert(archives.begin(), archives.end()); - - cache_.CacheArchivePaths(std::move(archivePaths)); -} -} diff --git a/src/api/game/game.h b/src/api/game/game.h deleted file mode 100644 index 1cd666f6..00000000 --- a/src/api/game/game.h +++ /dev/null @@ -1,110 +0,0 @@ -/* LOOT - - A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and - Fallout: New Vegas. - - Copyright (C) 2013-2016 WrinklyNinja - - This file is part of LOOT. - - LOOT is free software: you can redistribute - it and/or modify it under the terms of the GNU General Public License - as published by the Free Software Foundation, either version 3 of - the License, or (at your option) any later version. - - LOOT is distributed in the hope that it will - be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with LOOT. If not, see - . - */ - -#ifndef LOOT_API_GAME_GAME -#define LOOT_API_GAME_GAME - -#include -#include - -#include "api/api_database.h" -#include "api/game/game_cache.h" -#include "api/game/load_order_handler.h" -#include "api/metadata/condition_evaluator.h" -#include "loot/game_interface.h" - -namespace loot { -class Game final : public GameInterface { -public: - explicit Game(const GameType gameType, - const std::filesystem::path& gamePath, - const std::filesystem::path& gameLocalDataPath = ""); - - // Internal Methods // - ////////////////////// - - std::filesystem::path DataPath() const; - - GameCache& GetCache(); - const GameCache& GetCache() const; - - LoadOrderHandler& GetLoadOrderHandler(); - const LoadOrderHandler& GetLoadOrderHandler() const; - - // Game Interface Methods // - //////////////////////////// - - GameType GetType() const override; - - std::vector GetAdditionalDataPaths() const override; - - void SetAdditionalDataPaths( - const std::vector& additionalDataPaths) override; - - DatabaseInterface& GetDatabase() override; - const DatabaseInterface& GetDatabase() const override; - - bool IsValidPlugin(const std::filesystem::path& pluginPath) const override; - - void LoadPlugins(const std::vector& pluginPaths, - bool loadHeadersOnly) override; - - void ClearLoadedPlugins() override; - - std::shared_ptr GetPlugin( - std::string_view pluginName) const override; - - std::vector> GetLoadedPlugins() - const override; - - std::vector SortPlugins( - const std::vector& pluginFilenames) override; - - void LoadCurrentLoadOrderState() override; - - bool IsLoadOrderAmbiguous() const override; - - std::filesystem::path GetActivePluginsFilePath() const override; - - bool IsPluginActive(const std::string& pluginName) const override; - - std::vector GetLoadOrder() const override; - - void SetLoadOrder(const std::vector& loadOrder) override; - -private: - void CacheArchives(); - - GameType type_; - std::filesystem::path gamePath_; - - GameCache cache_; - LoadOrderHandler loadOrderHandler_; - std::shared_ptr conditionEvaluator_; - ApiDatabase database_; - - std::vector additionalDataPaths_; -}; -} -#endif diff --git a/src/api/game/game_cache.cpp b/src/api/game/game_cache.cpp deleted file mode 100644 index 89f63074..00000000 --- a/src/api/game/game_cache.cpp +++ /dev/null @@ -1,87 +0,0 @@ -/* LOOT - - A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and - Fallout: New Vegas. - - Copyright (C) 2012-2016 WrinklyNinja - - This file is part of LOOT. - - LOOT is free software: you can redistribute - it and/or modify it under the terms of the GNU General Public License - as published by the Free Software Foundation, either version 3 of - the License, or (at your option) any later version. - - LOOT is distributed in the hope that it will - be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with LOOT. If not, see - . - */ - -#include "api/game/game_cache.h" - -#include "api/helpers/text.h" - -namespace loot { -std::vector> GameCache::GetPlugins() const { - std::vector> output(plugins_.size()); - std::transform( - begin(plugins_), end(plugins_), begin(output), [](const auto& pair) { - return pair.second; - }); - return output; -} - -std::shared_ptr GameCache::GetPlugin( - std::string_view pluginName) const { - const auto it = plugins_.find(NormalizeFilename(pluginName)); - if (it != end(plugins_)) - return it->second; - - return nullptr; -} - -void GameCache::AddPlugin(Plugin&& plugin) { - auto normalizedName = NormalizeFilename(plugin.GetName()); - auto pluginPointer = std::make_shared(std::move(plugin)); - - const auto it = plugins_.find(normalizedName); - if (it != end(plugins_)) { - it->second = pluginPointer; - } else { - plugins_.emplace(normalizedName, pluginPointer); - } -} - -std::vector GameCache::GetPluginsWithReplacements( - const std::vector& newPlugins) const { - std::unordered_map pluginsMap; - for (const auto& plugin : newPlugins) { - pluginsMap.emplace(NormalizeFilename(plugin.GetName()), &plugin); - } - for (const auto& [key, plugin] : plugins_) { - pluginsMap.emplace(key, plugin.get()); - } - - std::vector loadedPlugins; - for (const auto& [key, plugin] : pluginsMap) { - loadedPlugins.push_back(plugin); - } - - return loadedPlugins; -} - -std::set GameCache::GetArchivePaths() const { - return archivePaths_; -} - -void GameCache::CacheArchivePaths(std::set&& paths) { - archivePaths_ = std::move(paths); -} - -void GameCache::ClearCachedPlugins() { plugins_.clear(); } -} diff --git a/src/api/game/game_cache.h b/src/api/game/game_cache.h deleted file mode 100644 index f94775e7..00000000 --- a/src/api/game/game_cache.h +++ /dev/null @@ -1,55 +0,0 @@ -/* LOOT - - A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and - Fallout: New Vegas. - - Copyright (C) 2012-2016 WrinklyNinja - - This file is part of LOOT. - - LOOT is free software: you can redistribute - it and/or modify it under the terms of the GNU General Public License - as published by the Free Software Foundation, either version 3 of - the License, or (at your option) any later version. - - LOOT is distributed in the hope that it will - be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with LOOT. If not, see - . - */ - -#ifndef LOOT_API_GAME_GAME_CACHE -#define LOOT_API_GAME_GAME_CACHE - -#include -#include -#include - -#include "api/plugin.h" - -namespace loot { -class GameCache { -public: - std::vector> GetPlugins() const; - std::shared_ptr GetPlugin(std::string_view pluginName) const; - void AddPlugin(Plugin&& plugin); - - std::vector GetPluginsWithReplacements( - const std::vector& newPlugins) const; - - std::set GetArchivePaths() const; - void CacheArchivePaths(std::set&& paths); - - void ClearCachedPlugins(); - -private: - std::unordered_map> plugins_; - std::set archivePaths_; -}; -} - -#endif diff --git a/src/api/game/load_order_handler.cpp b/src/api/game/load_order_handler.cpp deleted file mode 100644 index 171d246a..00000000 --- a/src/api/game/load_order_handler.cpp +++ /dev/null @@ -1,317 +0,0 @@ -/* LOOT - - A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and - Fallout: New Vegas. - - Copyright (C) 2012-2016 WrinklyNinja - - This file is part of LOOT. - - LOOT is free software: you can redistribute - it and/or modify it under the terms of the GNU General Public License - as published by the Free Software Foundation, either version 3 of - the License, or (at your option) any later version. - - LOOT is distributed in the hope that it will - be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with LOOT. If not, see - . - */ - -#include "api/game/load_order_handler.h" - -#include "api/helpers/logging.h" - -namespace loot { -unsigned int mapGameId(GameType gameType) { - switch (gameType) { - case GameType::tes3: - return LIBLO_GAME_TES3; - case GameType::tes4: - return LIBLO_GAME_TES4; - case GameType::tes5: - return LIBLO_GAME_TES5; - case GameType::tes5se: - return LIBLO_GAME_TES5SE; - case GameType::tes5vr: - return LIBLO_GAME_TES5VR; - case GameType::fo3: - return LIBLO_GAME_FO3; - case GameType::fonv: - return LIBLO_GAME_FNV; - case GameType::fo4: - return LIBLO_GAME_FO4; - case GameType::fo4vr: - return LIBLO_GAME_FO4VR; - case GameType::starfield: - return LIBLO_GAME_STARFIELD; - case GameType::openmw: - return LIBLO_GAME_OPENMW; - case GameType::oblivionRemastered: - return LIBLO_GAME_OBLIVION_REMASTERED; - default: - throw std::logic_error("Unexpected game type"); - } -} - -LoadOrderHandler::LoadOrderHandler( - const GameType& gameType, - const std::filesystem::path& gamePath, - const std::filesystem::path& gameLocalAppData) : - gh_(std::unique_ptr::type, - decltype(&lo_destroy_handle)>(nullptr, - lo_destroy_handle)) { - if (gamePath.empty()) { - throw std::invalid_argument("Game path is not initialised."); - } - - const char* gameLocalDataPath = nullptr; - std::string tempPathString = gameLocalAppData.u8string(); - if (!tempPathString.empty()) - gameLocalDataPath = tempPathString.c_str(); - - lo_game_handle handle = nullptr; - - int ret = lo_create_handle(&handle, - mapGameId(gameType), - gamePath.u8string().c_str(), - gameLocalDataPath); - - HandleError("create a game handle", ret); - - gh_ = - std::unique_ptr::type, - decltype(&lo_destroy_handle)>(handle, lo_destroy_handle); -} - -void LoadOrderHandler::LoadCurrentState() { - auto logger = getLogger(); - if (logger) { - logger->trace("Loading the current load order state."); - } - - const unsigned int ret = lo_load_current_state(gh_.get()); - - HandleError("load the current load order state", ret); -} - -bool LoadOrderHandler::IsAmbiguous() const { - auto logger = getLogger(); - if (logger) { - logger->trace("Checking if the load order is ambiguous."); - } - - bool result = false; - const unsigned int ret = lo_is_ambiguous(gh_.get(), &result); - - HandleError("check if the load order is ambiguous", ret); - - return result; -} - -bool LoadOrderHandler::IsPluginActive(const std::string& pluginName) const { - auto logger = getLogger(); - if (logger) { - logger->trace("Checking if plugin \"{}\" is active.", pluginName); - } - - bool result = false; - const unsigned int ret = - lo_get_plugin_active(gh_.get(), pluginName.c_str(), &result); - - HandleError("check if a plugin is active", ret); - - return result; -} - -std::vector LoadOrderHandler::GetLoadOrder() const { - auto logger = getLogger(); - if (logger) { - logger->trace("Getting load order."); - } - - char** pluginArr = nullptr; - size_t pluginArrSize = 0; - - const unsigned int ret = - lo_get_load_order(gh_.get(), &pluginArr, &pluginArrSize); - - HandleError("get the load order", ret); - - // NOLINTNEXTLINE(cppcoreguidelines-pro-bounds-pointer-arithmetic) - std::vector loadOrder(pluginArr, pluginArr + pluginArrSize); - lo_free_string_array(pluginArr, pluginArrSize); - - return loadOrder; -} - -std::vector LoadOrderHandler::GetActivePlugins() const { - auto logger = getLogger(); - if (logger) { - logger->trace("Getting active plugins."); - } - - char** pluginArr = nullptr; - size_t pluginArrSize = 0; - - const unsigned int ret = - lo_get_active_plugins(gh_.get(), &pluginArr, &pluginArrSize); - - HandleError("get active plugins", ret); - - // NOLINTNEXTLINE(cppcoreguidelines-pro-bounds-pointer-arithmetic) - std::vector loadOrder(pluginArr, pluginArr + pluginArrSize); - lo_free_string_array(pluginArr, pluginArrSize); - - return loadOrder; -} - -std::vector LoadOrderHandler::GetEarlyLoadingPlugins() const { - auto logger = getLogger(); - if (logger) { - logger->trace("Getting early loading plugins."); - } - - char** pluginArr = nullptr; - size_t pluginArrSize = 0; - - const unsigned int ret = - lo_get_early_loading_plugins(gh_.get(), &pluginArr, &pluginArrSize); - - HandleError("get early loading plugins", ret); - - // NOLINTNEXTLINE(cppcoreguidelines-pro-bounds-pointer-arithmetic) - std::vector loadOrder(pluginArr, pluginArr + pluginArrSize); - lo_free_string_array(pluginArr, pluginArrSize); - - return loadOrder; -} - -std::filesystem::path LoadOrderHandler::GetActivePluginsFilePath() const { - auto logger = getLogger(); - if (logger) { - logger->trace("Getting active plugins file path."); - } - - char* filePathCString = nullptr; - - const unsigned int ret = - lo_get_active_plugins_file_path(gh_.get(), &filePathCString); - - HandleError("get active plugins file path", ret); - - const auto filePath = std::filesystem::u8path(std::string_view(filePathCString)); - - lo_free_string(filePathCString); - - return filePath; -} - -std::vector LoadOrderHandler::GetAdditionalDataPaths() - const { - const auto logger = getLogger(); - if (logger) { - logger->trace("Getting additional data paths."); - } - - char** pathArr = nullptr; - size_t pathArrSize = 0; - - const unsigned int ret = - lo_get_additional_plugins_directories(gh_.get(), &pathArr, &pathArrSize); - - HandleError("get additional data paths", ret); - - std::vector loadOrder; - for (size_t i = 0; i < pathArrSize; i += 1) { - loadOrder.push_back(std::filesystem::u8path(std::string_view(pathArr[i]))); - } - lo_free_string_array(pathArr, pathArrSize); - - return loadOrder; -} - -void LoadOrderHandler::SetLoadOrder( - const std::vector& loadOrder) const { - auto logger = getLogger(); - if (logger) { - logger->debug("Setting load order:"); - for (const auto& plugin : loadOrder) { - logger->debug("\t{}", plugin); - } - } - - std::vector plugins; - plugins.reserve(loadOrder.size()); - for (const auto& plugin : loadOrder) { - plugins.push_back(plugin.c_str()); - } - - const unsigned int ret = - lo_set_load_order(gh_.get(), plugins.data(), plugins.size()); - - HandleError("set the load order", ret); - - if (logger) { - logger->debug("Load order set successfully."); - } -} - -void LoadOrderHandler::SetAdditionalDataPaths( - const std::vector& dataPaths) const { - auto logger = getLogger(); - if (logger) { - logger->debug("Setting additional data paths:"); - for (const auto& dataPath : dataPaths) { - logger->debug("\t{}", dataPath.u8string()); - } - } - - std::vector dataPathStrings; - for (const auto& dataPath : dataPaths) { - dataPathStrings.push_back(dataPath.u8string()); - } - - std::vector dataPathCStrings; - for (const auto& dataPath : dataPathStrings) { - dataPathCStrings.push_back(dataPath.c_str()); - } - - const unsigned int ret = lo_set_additional_plugins_directories( - gh_.get(), dataPathCStrings.data(), dataPathCStrings.size()); - - HandleError("set additional data paths", ret); - - if (logger) { - logger->debug("Additional data paths set successfully."); - } -} - -void LoadOrderHandler::HandleError(std::string_view operation, - unsigned int returnCode) const { - if (returnCode == LIBLO_OK || returnCode == LIBLO_WARN_LO_MISMATCH) { - return; - } - - const char* message = nullptr; - std::string err; - lo_get_error_message(&message); - if (message == nullptr) { - err = fmt::format( - "Failed to {}. libloadorder error code: {}", operation, returnCode); - } else { - err = fmt::format("Failed to {}. Details: {}", operation, message); - } - - auto logger = getLogger(); - if (logger) { - logger->error(err); - } - - throw std::runtime_error(err); -} -} diff --git a/src/api/game/load_order_handler.h b/src/api/game/load_order_handler.h deleted file mode 100644 index 5273773a..00000000 --- a/src/api/game/load_order_handler.h +++ /dev/null @@ -1,73 +0,0 @@ -/* LOOT - - A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and - Fallout: New Vegas. - - Copyright (C) 2012-2016 WrinklyNinja - - This file is part of LOOT. - - LOOT is free software: you can redistribute - it and/or modify it under the terms of the GNU General Public License - as published by the Free Software Foundation, either version 3 of - the License, or (at your option) any later version. - - LOOT is distributed in the hope that it will - be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with LOOT. If not, see - . - */ - -#ifndef LOOT_API_GAME_LOAD_ORDER_HANDLER -#define LOOT_API_GAME_LOAD_ORDER_HANDLER - -#include -#include -#include -#include -#include - -#include "loot/enum/game_type.h" - -namespace loot { -class LoadOrderHandler { -public: - explicit LoadOrderHandler(const GameType& game, - const std::filesystem::path& gamePath, - const std::filesystem::path& gameLocalAppData = ""); - - void LoadCurrentState(); - - bool IsAmbiguous() const; - - std::vector GetLoadOrder() const; - - std::vector GetActivePlugins() const; - - std::vector GetEarlyLoadingPlugins() const; - - std::filesystem::path GetActivePluginsFilePath() const; - - std::vector GetAdditionalDataPaths() const; - - bool IsPluginActive(const std::string& pluginName) const; - - void SetLoadOrder(const std::vector& loadOrder) const; - - void SetAdditionalDataPaths( - const std::vector& dataPaths) const; - -private: - void HandleError(std::string_view operation, unsigned int returnCode) const; - - std::unique_ptr::type, - decltype(&lo_destroy_handle)> - gh_; -}; -} - -#endif diff --git a/src/api/helpers/crc.cpp b/src/api/helpers/crc.cpp deleted file mode 100644 index 6aad89ac..00000000 --- a/src/api/helpers/crc.cpp +++ /dev/null @@ -1,86 +0,0 @@ -/* LOOT - - A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and - Fallout: New Vegas. - - Copyright (C) 2012-2016 WrinklyNinja - - This file is part of LOOT. - - LOOT is free software: you can redistribute - it and/or modify it under the terms of the GNU General Public License - as published by the Free Software Foundation, either version 3 of - the License, or (at your option) any later version. - - LOOT is distributed in the hope that it will - be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with LOOT. If not, see - . - */ - -#include "api/helpers/crc.h" - -#include - -#include -#include -#include -#include - -#include "api/helpers/logging.h" - -namespace loot { -size_t GetStreamSize(std::istream& stream) { - const std::streampos startingPosition = stream.tellg(); - - stream.seekg(0, std::ios_base::end); - const size_t streamSize = stream.tellg(); - stream.seekg(startingPosition, std::ios_base::beg); - - return streamSize; -} - -// Calculate the CRC of the given file for comparison purposes. -uint32_t GetCrc32(const std::filesystem::path& filename) { - try { - auto logger = getLogger(); - if (logger) { - logger->trace("Calculating CRC for: {}", filename.u8string()); - } - - std::ifstream ifile(filename, std::ios::binary); - ifile.exceptions(std::ios_base::badbit | std::ios_base::failbit); - - static constexpr size_t BUFFER_SIZE = 8192; - std::array buffer{}; - boost::crc_32_type result; - size_t bytesLeft = GetStreamSize(ifile); - while (bytesLeft > 0) { - if (bytesLeft > buffer.size()) - ifile.read(buffer.data(), buffer.size()); - else - ifile.read(buffer.data(), bytesLeft); - - result.process_bytes(buffer.data(), ifile.gcount()); - bytesLeft -= ifile.gcount(); - } - - uint32_t checksum = result.checksum(); - if (logger) { - auto u8Filename = filename.u8string(); - logger->debug("CRC32(\"{}\"): {:x}", u8Filename, checksum); - } - return checksum; - - } catch (const std::exception& e) { - throw std::runtime_error("Unable to open \"" + filename.u8string() + - "\" for CRC calulation: " + e.what()); - } -} - -std::string CrcToString(uint32_t crc) { return fmt::format("{:08X}", crc); } -} diff --git a/src/api/helpers/crc.h b/src/api/helpers/crc.h deleted file mode 100644 index 156f33d1..00000000 --- a/src/api/helpers/crc.h +++ /dev/null @@ -1,37 +0,0 @@ -/* LOOT - - A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and - Fallout: New Vegas. - - Copyright (C) 2012-2016 WrinklyNinja - - This file is part of LOOT. - - LOOT is free software: you can redistribute - it and/or modify it under the terms of the GNU General Public License - as published by the Free Software Foundation, either version 3 of - the License, or (at your option) any later version. - - LOOT is distributed in the hope that it will - be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with LOOT. If not, see - . - */ - -#ifndef LOOT_API_HELPERS_CRC -#define LOOT_API_HELPERS_CRC - -#include -#include - -namespace loot { -uint32_t GetCrc32(const std::filesystem::path& filename); - -std::string CrcToString(uint32_t crc); -} - -#endif diff --git a/src/api/helpers/logging.cpp b/src/api/helpers/logging.cpp deleted file mode 100644 index eb88d9ca..00000000 --- a/src/api/helpers/logging.cpp +++ /dev/null @@ -1,113 +0,0 @@ -/* LOOT - - A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and - Fallout: New Vegas. - - Copyright (C) 2012-2016 WrinklyNinja - - This file is part of LOOT. - - LOOT is free software: you can redistribute - it and/or modify it under the terms of the GNU General Public License - as published by the Free Software Foundation, either version 3 of - the License, or (at your option) any later version. - - LOOT is distributed in the hope that it will - be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with LOOT. If not, see - . - */ - -#include "api/helpers/logging.h" - -#include - -#include - -namespace { -using std::string_view_literals::operator""sv; -using loot::LogLevel; - -constexpr std::string_view LOGGER_NAME = "loot_api_logger"sv; - -LogLevel mapFromSpdlog(spdlog::level::level_enum severity) { - using spdlog::level::level_enum; - switch (severity) { - case level_enum::trace: - return LogLevel::trace; - case level_enum::debug: - return LogLevel::debug; - case level_enum::info: - return LogLevel::info; - case level_enum::warn: - return LogLevel::warning; - case level_enum::err: - case level_enum::critical: - return LogLevel::error; - default: - return LogLevel::trace; - } -} - -spdlog::level::level_enum mapToSpdlog(LogLevel severity) { - using spdlog::level::level_enum; - switch (severity) { - case LogLevel::trace: - return level_enum::trace; - case LogLevel::debug: - return level_enum::debug; - case LogLevel::info: - return level_enum::info; - case LogLevel::warning: - return level_enum::warn; - case LogLevel::error: - return level_enum::err; - default: - return level_enum::trace; - } -} - -class SpdLoggingSink : public spdlog::sinks::base_sink { -public: - explicit SpdLoggingSink(std::function callback) { - this->callback = callback; - } - -protected: - void sink_it_(const spdlog::details::log_msg& msg) override { - // string_view isn't necessarily null-terminated, so using - // msg.payload.data() directly isn't a good idea. - std::string payload = std::string(msg.payload.data(), msg.payload.size()); - callback(mapFromSpdlog(msg.level), payload.c_str()); - } - - void flush_() override {} - -private: - std::function callback; -}; -} - -namespace loot { -std::shared_ptr getLogger() { return spdlog::get(std::string(LOGGER_NAME)); } - -std::shared_ptr createLogger( - std::function callback) { - auto sink = std::make_shared(callback); - auto logger = std::make_shared(std::string(LOGGER_NAME), sink); - logger->set_level(spdlog::level::level_enum::trace); - - return logger; -} - -void setLoggerLevel(LogLevel level) { - auto logger = getLogger(); - if (logger) { - logger->set_level(mapToSpdlog(level)); - } -} -} diff --git a/src/api/helpers/logging.h b/src/api/helpers/logging.h deleted file mode 100644 index 316fc77d..00000000 --- a/src/api/helpers/logging.h +++ /dev/null @@ -1,42 +0,0 @@ -/* LOOT - - A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and - Fallout: New Vegas. - - Copyright (C) 2012-2016 WrinklyNinja - - This file is part of LOOT. - - LOOT is free software: you can redistribute - it and/or modify it under the terms of the GNU General Public License - as published by the Free Software Foundation, either version 3 of - the License, or (at your option) any later version. - - LOOT is distributed in the hope that it will - be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with LOOT. If not, see - . - */ -#ifndef LOOT_API_HELPERS_LOGGING -#define LOOT_API_HELPERS_LOGGING - -#include - -#include - -#include "loot/enum/log_level.h" - -namespace loot { -std::shared_ptr getLogger(); - -std::shared_ptr createLogger( - std::function callback); - -void setLoggerLevel(LogLevel level); -} - -#endif diff --git a/src/api/helpers/text.cpp b/src/api/helpers/text.cpp deleted file mode 100644 index e49abb4b..00000000 --- a/src/api/helpers/text.cpp +++ /dev/null @@ -1,245 +0,0 @@ -/* LOOT - - A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and - Fallout: New Vegas. - - Copyright (C) 2012-2016 WrinklyNinja - - This file is part of LOOT. - - LOOT is free software: you can redistribute - it and/or modify it under the terms of the GNU General Public License - as published by the Free Software Foundation, either version 3 of - the License, or (at your option) any later version. - - LOOT is distributed in the hope that it will - be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with LOOT. If not, see - . - */ -#include "api/helpers/text.h" - -#include -#include - -#ifdef _WIN32 -#include "windows.h" -#else -#include -#include -#endif - -namespace loot { -using std::string_view_literals::operator""sv; - -/* The string below matches timestamps that use forwardslashes for date - separators. However, Pseudosem v1.0.1 will only compare the first - two digits as it does not recognise forwardslashes as separators. */ -constexpr std::string_view dateRegex = - R"((\d{1,2}/\d{1,2}/\d{1,4} \d{1,2}:\d{1,2}:\d{1,2}))"sv; - -/* The string below matches the range of version strings supported by - Pseudosem v1.0.1, excluding space separators, as they make version - extraction from inside sentences very tricky and have not been - seen "in the wild". */ -constexpr std::string_view pseudosemVersionRegex = - R"((\d+(?:\.\d+)+(?:[-._:]?[A-Za-z0-9]+)*))"sv - // The string below prevents version numbers followed by a comma from - // matching. - R"((?!,))"sv; - -/* The string below matches a number containing one or more - digits found at the start of the search string or preceded by - 'v' or 'version:. */ -constexpr std::string_view digitsVersionRegex = R"((?:^|v|version:\s*)(\d+))"sv; - -std::vector ExtractBashTags(std::string_view description) { - static constexpr std::string_view BASH_TAGS_OPENER = "{{BASH:"sv; - - size_t startPos = description.find("{{BASH:"); - if (startPos == std::string::npos || - startPos + BASH_TAGS_OPENER.length() >= description.length()) { - return {}; - } - startPos += BASH_TAGS_OPENER.length(); - - const size_t endPos = description.find("}}", startPos); - if (endPos == std::string::npos) { - return {}; - } - - auto commaSeparatedTags = description.substr(startPos, endPos - startPos); - - std::vector bashTags; - boost::split(bashTags, commaSeparatedTags, [](char c) { return c == ','; }); - - for (auto& tag : bashTags) { - boost::trim(tag); - } - - return bashTags; -} - -std::optional ExtractVersion(std::string_view text) { - using std::regex; - - /* There are a few different version formats that can appear in strings - together, and in order to extract the correct one, they must be searched - for in order of priority. */ - static const std::vector versionRegexes({ - regex( - dateRegex.begin(), dateRegex.end(), regex::ECMAScript | regex::icase), - regex(R"(version:?\s)" + std::string(pseudosemVersionRegex), - regex::ECMAScript | regex::icase), - regex(R"((?:^|v|\s))" + std::string(pseudosemVersionRegex), - regex::ECMAScript | regex::icase), - regex(digitsVersionRegex.begin(), - digitsVersionRegex.end(), - regex::ECMAScript | regex::icase), - }); - - std::match_results what; - for (const auto& versionRegex : versionRegexes) { - if (std::regex_search(text.begin(), text.end(), what, versionRegex)) { - for (auto it = next(begin(what)); it != end(what); ++it) { - if (it->str().empty()) - continue; - - // Use the first non-empty sub-match. - std::string version = *it; - boost::trim(version); - return version; - } - } - } - - return std::nullopt; -} - -#ifdef _WIN32 -int narrow(size_t value) { - auto castValue = static_cast(value); - - // Cast back again to check if any data has been lost. - // Because one type is signed and the other is unsigned, also check that - // the sign has been preserved. - if (static_cast(castValue) != value || - ((castValue < int{}) != (value < size_t{}))) { - throw std::runtime_error("Failed to losslessly convert from size_t to int"); - } - - return castValue; -} - -std::wstring ToWinWide(std::string_view str) { - const size_t len = MultiByteToWideChar( - CP_UTF8, 0, str.data(), static_cast(str.length()), 0, 0); - - if (len == 0) { - return std::wstring(); - } - - std::wstring wstr(len, 0); - MultiByteToWideChar(CP_UTF8, - 0, - str.data(), - narrow(str.length()), - wstr.data(), - narrow(wstr.length())); - return wstr; -} - -std::string FromWinWide(const std::wstring& wstr) { - const size_t len = WideCharToMultiByte(CP_UTF8, - 0, - wstr.c_str(), - narrow(wstr.length()), - nullptr, - 0, - nullptr, - nullptr); - - if (len == 0) { - return std::string(); - } - - std::string str(len, 0); - WideCharToMultiByte(CP_UTF8, - 0, - wstr.c_str(), - narrow(wstr.length()), - str.data(), - narrow(str.length()), - nullptr, - nullptr); - return str; -} -#endif - -ComparableFilename ToComparableFilename(std::string_view filename) { -#ifdef _WIN32 - return ToWinWide(filename); -#else - return icu::UnicodeString::fromUTF8(filename); -#endif -} - -int CompareFilenames(std::string_view lhs, std::string_view rhs) { - return CompareFilenames(ToComparableFilename(lhs), ToComparableFilename(rhs)); -} - -int CompareFilenames(const ComparableFilename& lhs, - const ComparableFilename& rhs) { -#ifdef _WIN32 - // Use CompareStringOrdinal as that will perform case conversion - // using the operating system uppercase table information, which (I think) - // will give results that match the filesystem, and is not locale-dependent. - int result = CompareStringOrdinal(lhs.c_str(), -1, rhs.c_str(), -1, true); - switch (result) { - case CSTR_LESS_THAN: - return -1; - case CSTR_EQUAL: - return 0; - case CSTR_GREATER_THAN: - return 1; - default: - throw std::invalid_argument( - "One of the filenames to compare was invalid."); - } -#else - return lhs.caseCompare(rhs, U_FOLD_CASE_DEFAULT); -#endif -} - -std::string NormalizeFilename(std::string_view filename) { -#ifdef _WIN32 - auto wideString = ToWinWide(filename); - - if (wideString.empty()) { - return std::string(); - } - - CharUpperBuffW(wideString.data(), narrow(wideString.length())); - return FromWinWide(wideString); -#else - std::string normalizedFilename; - icu::UnicodeString::fromUTF8(filename) - .foldCase(U_FOLD_CASE_DEFAULT) - .toUTF8String(normalizedFilename); - return normalizedFilename; -#endif -} - -std::string TrimDotGhostExtension(std::string&& filename) { - // If the name passed ends in '.ghost', that should be trimmed. - if (boost::iends_with(filename, GHOST_FILE_EXTENSION)) { - return filename.substr(0, filename.length() - GHOST_FILE_EXTENSION.length()); - } - - return filename; -} -} diff --git a/src/api/helpers/text.h b/src/api/helpers/text.h deleted file mode 100644 index ff07f04e..00000000 --- a/src/api/helpers/text.h +++ /dev/null @@ -1,75 +0,0 @@ -/* LOOT - - A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and - Fallout: New Vegas. - - Copyright (C) 2012-2016 WrinklyNinja - - This file is part of LOOT. - - LOOT is free software: you can redistribute - it and/or modify it under the terms of the GNU General Public License - as published by the Free Software Foundation, either version 3 of - the License, or (at your option) any later version. - - LOOT is distributed in the hope that it will - be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with LOOT. If not, see - . - */ - -#ifndef LOOT_API_HELPERS_TEXT -#define LOOT_API_HELPERS_TEXT - -#include -#include -#include -#include - -#include "loot/metadata/tag.h" - -#ifndef _WIN32 -#define UNISTR_FROM_STRING_EXPLICIT explicit -#include -#endif - -namespace loot { -inline constexpr std::string_view GHOST_FILE_EXTENSION = ".ghost"; - -#ifdef _WIN32 -typedef std::wstring ComparableFilename; -#else -typedef icu::UnicodeString ComparableFilename; -#endif - -std::vector ExtractBashTags(std::string_view description); - -std::optional ExtractVersion(std::string_view text); - -ComparableFilename ToComparableFilename(std::string_view filename); - -// Compare strings as if they're filenames, respecting filesystem case -// insensitivity on Windows. Returns -1 if lhs < rhs, 0 if lhs == rhs, and 1 if -// lhs > rhs. The comparison may give different results on Linux, but is still -// locale-invariant. -int CompareFilenames(std::string_view lhs, std::string_view rhs); - -int CompareFilenames(const ComparableFilename& lhs, - const ComparableFilename& rhs); - -// Normalize the given filename in a way that is locale-invariant. On Windows, -// this uppercases the filename according to the same case mapping rules as used -// by the filesystem. On Linux, case folding is used and gives results that are -// different but hopefully still consistent enough with the behaviour on Windows -// that the normalized filenames distinguish characters in a similar way to the -// Windows filesystem. -std::string NormalizeFilename(std::string_view filename); - -std::string TrimDotGhostExtension(std::string&& filename); -} - -#endif diff --git a/src/api/loot_version.cpp.in b/src/api/loot_version.cpp.in deleted file mode 100644 index 67978063..00000000 --- a/src/api/loot_version.cpp.in +++ /dev/null @@ -1,39 +0,0 @@ -/* LOOT - - A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and - Fallout: New Vegas. - - Copyright (C) 2012-2016 WrinklyNinja - - This file is part of LOOT. - - LOOT is free software: you can redistribute - it and/or modify it under the terms of the GNU General Public License - as published by the Free Software Foundation, either version 3 of - the License, or (at your option) any later version. - - LOOT is distributed in the hope that it will - be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with LOOT. If not, see - . - */ - -#include "loot/loot_version.h" - -namespace loot { -LOOT_API std::string GetLiblootVersion() { - static const std::string version = - std::to_string(LIBLOOT_VERSION_MAJOR) + '.' + - std::to_string(LIBLOOT_VERSION_MINOR) + '.' + - std::to_string(LIBLOOT_VERSION_PATCH); - return version; -} - -LOOT_API std::string GetLiblootRevision() { - return "@GIT_COMMIT_STRING@"; -} -} diff --git a/src/api/metadata/condition_evaluator.cpp b/src/api/metadata/condition_evaluator.cpp deleted file mode 100644 index 5cf096cd..00000000 --- a/src/api/metadata/condition_evaluator.cpp +++ /dev/null @@ -1,289 +0,0 @@ -/* LOOT - - A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and - Fallout: New Vegas. - - Copyright (C) 2012-2016 WrinklyNinja - - This file is part of LOOT. - - LOOT is free software: you can redistribute - it and/or modify it under the terms of the GNU General Public License - as published by the Free Software Foundation, either version 3 of - the License, or (at your option) any later version. - - LOOT is distributed in the hope that it will - be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with LOOT. If not, see - . - */ - -#include "api/metadata/condition_evaluator.h" - -#include -#include - -#include "api/helpers/crc.h" -#include "api/helpers/logging.h" - -namespace loot { -void HandleError(std::string_view operation, int returnCode) { - if (returnCode == LCI_OK) { - return; - } - - const char* message = nullptr; - std::string err; - lci_get_error_message(&message); - if (message == nullptr) { - err = fmt::format("Failed to {}. loot-condition-interpreter error code: {}", - operation, - returnCode); - } else { - err = fmt::format("Failed to {}. Details: {}", operation, message); - } - - auto logger = getLogger(); - if (logger) { - logger->error(err); - } - - throw std::runtime_error(err); -} - -int mapGameType(GameType gameType) { - switch (gameType) { - case GameType::tes3: - return LCI_GAME_MORROWIND; - case GameType::tes4: - case GameType::oblivionRemastered: - return LCI_GAME_OBLIVION; - case GameType::tes5: - return LCI_GAME_SKYRIM; - case GameType::tes5se: - return LCI_GAME_SKYRIM_SE; - case GameType::tes5vr: - return LCI_GAME_SKYRIM_VR; - case GameType::fo3: - return LCI_GAME_FALLOUT_3; - case GameType::fonv: - return LCI_GAME_FALLOUT_NV; - case GameType::fo4: - return LCI_GAME_FALLOUT_4; - case GameType::fo4vr: - return LCI_GAME_FALLOUT_4_VR; - case GameType::starfield: - return LCI_GAME_STARFIELD; - case GameType::openmw: - return LCI_GAME_OPENMW; - default: - throw std::runtime_error( - "Unrecognised game type encountered while mapping for condition " - "evaluation."); - } -} - -ConditionEvaluator::ConditionEvaluator(const GameType gameType, - const std::filesystem::path& dataPath) : - lciState_(std::unique_ptr( - nullptr, - lci_state_destroy)) { - lci_state* state = nullptr; - - int result = lci_state_create( - &state, mapGameType(gameType), dataPath.u8string().c_str()); - HandleError("create state object for condition evaluation", result); - - lciState_ = std::unique_ptr( - state, lci_state_destroy); -} - -bool ConditionEvaluator::Evaluate(const std::string& condition) { - if (condition.empty()) - return true; - - auto logger = getLogger(); - if (logger) { - logger->trace("Evaluating condition: {}", condition); - } - - const int result = lci_condition_eval(condition.c_str(), lciState_.get()); - if (result != LCI_RESULT_FALSE && result != LCI_RESULT_TRUE) { - HandleError("evaluate condition \"" + condition + "\"", result); - } - - return result == LCI_RESULT_TRUE; -} - -std::optional ConditionEvaluator::EvaluateAll( - const PluginMetadata& pluginMetadata) { - PluginMetadata evaluatedMetadata(pluginMetadata.GetName()); - evaluatedMetadata.SetLocations(pluginMetadata.GetLocations()); - - if (pluginMetadata.GetGroup()) { - evaluatedMetadata.SetGroup(pluginMetadata.GetGroup().value()); - } - - std::vector files; - for (const auto& file : pluginMetadata.GetLoadAfterFiles()) { - if (Evaluate(file.GetCondition())) - files.push_back(file); - } - evaluatedMetadata.SetLoadAfterFiles(files); - - files.clear(); - for (const auto& file : pluginMetadata.GetRequirements()) { - if (Evaluate(file.GetCondition())) - files.push_back(file); - } - evaluatedMetadata.SetRequirements(files); - - files.clear(); - for (const auto& file : pluginMetadata.GetIncompatibilities()) { - if (Evaluate(file.GetCondition())) - files.push_back(file); - } - evaluatedMetadata.SetIncompatibilities(files); - - std::vector messages; - for (const auto& message : pluginMetadata.GetMessages()) { - if (Evaluate(message.GetCondition())) - messages.push_back(message); - } - evaluatedMetadata.SetMessages(messages); - - std::vector tags; - for (const auto& tag : pluginMetadata.GetTags()) { - if (Evaluate(tag.GetCondition())) - tags.push_back(tag); - } - evaluatedMetadata.SetTags(tags); - - if (!evaluatedMetadata.IsRegexPlugin()) { - std::vector infoVector; - for (const auto& info : pluginMetadata.GetDirtyInfo()) { - if (Evaluate(info, pluginMetadata.GetName())) - infoVector.push_back(info); - } - evaluatedMetadata.SetDirtyInfo(infoVector); - - infoVector.clear(); - for (const auto& info : pluginMetadata.GetCleanInfo()) { - if (Evaluate(info, pluginMetadata.GetName())) - infoVector.push_back(info); - } - evaluatedMetadata.SetCleanInfo(infoVector); - } - - if (evaluatedMetadata.HasNameOnly()) { - return std::nullopt; - } - - return evaluatedMetadata; -} - -void ConditionEvaluator::ClearConditionCache() { - const int result = lci_state_clear_condition_cache(lciState_.get()); - HandleError("clear the condition cache", result); -} - -void ConditionEvaluator::RefreshActivePluginsState( - const std::vector& activePluginNames) { - ClearConditionCache(); - - std::vector activePluginNameCStrings; - for (auto& pluginName : activePluginNames) { - activePluginNameCStrings.push_back(pluginName.c_str()); - } - - const int result = - lci_state_set_active_plugins(lciState_.get(), - activePluginNameCStrings.data(), - activePluginNameCStrings.size()); - HandleError("cache active plugins for condition evaluation", result); -} - -void ConditionEvaluator::RefreshLoadedPluginsState( - const std::vector>& plugins) { - ClearConditionCache(); - - std::vector pluginNames; - std::vector pluginVersionStrings; - std::vector crcs; - for (auto plugin : plugins) { - pluginNames.push_back(plugin->GetName()); - pluginVersionStrings.push_back(plugin->GetVersion().value_or("")); - crcs.push_back(plugin->GetCRC().value_or(0)); - } - - std::vector pluginVersions; - std::vector pluginCrcs; - for (size_t i = 0; i < pluginNames.size(); ++i) { - if (!pluginVersionStrings.at(i).empty()) { - plugin_version pluginVersion; - pluginVersion.plugin_name = pluginNames.at(i).c_str(); - pluginVersion.version = pluginVersionStrings.at(i).c_str(); - pluginVersions.push_back(pluginVersion); - } - - if (crcs.at(i) != 0) { - plugin_crc pluginCrc; - pluginCrc.plugin_name = pluginNames.at(i).c_str(); - pluginCrc.crc = crcs.at(i); - pluginCrcs.push_back(pluginCrc); - } - } - - int result = lci_state_set_plugin_versions( - lciState_.get(), pluginVersions.data(), pluginVersions.size()); - HandleError("cache plugin versions for condition evaluation", result); - - result = lci_state_set_crc_cache( - lciState_.get(), pluginCrcs.data(), pluginCrcs.size()); - HandleError("fill CRC cache for condition evaluation", result); -} - -void ConditionEvaluator::SetAdditionalDataPaths( - const std::vector& dataPaths) { - std::vector dataPathStrings; - for (const auto& dataPath : dataPaths) { - dataPathStrings.push_back(dataPath.u8string()); - } - - std::vector dataPathCStrings; - for (const auto& dataPath : dataPathStrings) { - dataPathCStrings.push_back(dataPath.c_str()); - } - - int result = lci_state_set_additional_data_paths( - lciState_.get(), dataPathCStrings.data(), dataPathCStrings.size()); - HandleError("create state object for condition evaluation", result); -} - -bool ConditionEvaluator::Evaluate(const PluginCleaningData& cleaningData, - std::string_view pluginName) { - if (pluginName.empty()) - return false; - - return Evaluate(fmt::format( - "checksum(\"{}\", {})", pluginName, CrcToString(cleaningData.GetCRC()))); -} - -void ParseCondition(const std::string& condition) { - if (condition.empty()) { - return; - } - - auto logger = getLogger(); - if (logger) { - logger->trace("Testing condition syntax: {}", condition); - } - - const int result = lci_condition_parse(condition.c_str()); - HandleError("parse condition \"" + condition + "\"", result); -} -} diff --git a/src/api/metadata/condition_evaluator.h b/src/api/metadata/condition_evaluator.h deleted file mode 100644 index ce6acbc5..00000000 --- a/src/api/metadata/condition_evaluator.h +++ /dev/null @@ -1,69 +0,0 @@ -/* LOOT - - A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and - Fallout: New Vegas. - - Copyright (C) 2012-2016 WrinklyNinja - - This file is part of LOOT. - - LOOT is free software: you can redistribute - it and/or modify it under the terms of the GNU General Public License - as published by the Free Software Foundation, either version 3 of - the License, or (at your option) any later version. - - LOOT is distributed in the hope that it will - be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with LOOT. If not, see - . - */ - -#ifndef LOOT_API_METADATA_CONDITION_EVALUATOR -#define LOOT_API_METADATA_CONDITION_EVALUATOR - -#include - -#include -#include -#include -#include - -#include "loot/enum/game_type.h" -#include "loot/metadata/plugin_cleaning_data.h" -#include "loot/metadata/plugin_metadata.h" -#include "loot/plugin_interface.h" - -namespace loot { -class ConditionEvaluator { -public: - explicit ConditionEvaluator(const GameType gameType, - const std::filesystem::path& dataPath); - - bool Evaluate(const std::string& condition); - std::optional EvaluateAll( - const PluginMetadata& pluginMetadata); - - void ClearConditionCache(); - void RefreshActivePluginsState( - const std::vector& activePluginNames); - void RefreshLoadedPluginsState( - const std::vector>& plugins); - - void SetAdditionalDataPaths( - const std::vector& dataPaths); - -private: - bool Evaluate(const PluginCleaningData& cleaningData, - std::string_view pluginName); - - std::unique_ptr lciState_; -}; - -void ParseCondition(const std::string& condition); -} - -#endif diff --git a/src/api/metadata/yaml/file.h b/src/api/metadata/yaml/file.h deleted file mode 100644 index de050d05..00000000 --- a/src/api/metadata/yaml/file.h +++ /dev/null @@ -1,179 +0,0 @@ -/* LOOT - - A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and - Fallout: New Vegas. - - Copyright (C) 2012-2016 WrinklyNinja - - This file is part of LOOT. - - LOOT is free software: you can redistribute - it and/or modify it under the terms of the GNU General Public License - as published by the Free Software Foundation, either version 3 of - the License, or (at your option) any later version. - - LOOT is distributed in the hope that it will - be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with LOOT. If not, see - . - */ -#ifndef LOOT_YAML_FILE -#define LOOT_YAML_FILE - -#define YAML_CPP_SUPPORT_MERGE_KEYS - -#include - -#include - -#include "api/helpers/text.h" -#include "api/metadata/condition_evaluator.h" -#include "api/metadata/yaml/message_content.h" -#include "loot/metadata/file.h" - -namespace loot { -inline bool emitAsScalar(const File& file) { - return file.GetCondition().empty() && file.GetDetail().empty() && - file.GetDisplayName().empty() && file.GetConstraint().empty(); -} -} - -namespace YAML { -template<> -struct convert { - static Node encode(const loot::File& rhs) { - Node node; - node["name"] = std::string(rhs.GetName()); - - if (!rhs.GetCondition().empty()) { - node["condition"] = rhs.GetCondition(); - } - - if (!rhs.GetConstraint().empty()) { - node["constraint"] = rhs.GetConstraint(); - } - - if (!rhs.GetDisplayName().empty()) { - node["display"] = rhs.GetDisplayName(); - } - - if (!rhs.GetDetail().empty()) { - node["detail"] = rhs.GetDetail(); - } - - return node; - } - - static bool decode(const Node& node, loot::File& rhs) { - if (!node.IsMap() && !node.IsScalar()) { - throw RepresentationException( - node.Mark(), "bad conversion: 'file' object must be a map or scalar"); - } - - if (node.IsMap()) { - if (!node["name"]) { - throw RepresentationException( - node.Mark(), - "bad conversion: 'name' key missing from 'file' map object"); - } - - std::string name = node["name"].as(); - std::string condition, constraint, display; - std::vector detail; - if (node["condition"]) { - condition = node["condition"].as(); - } - - if (node["constraint"]) { - constraint = node["constraint"].as(); - } - - if (node["display"]) { - display = node["display"].as(); - } - - if (node["detail"]) { - if (node["detail"].IsSequence()) { - detail = node["detail"].as>(); - } else { - detail.push_back( - loot::MessageContent(node["detail"].as())); - } - } - - // Check now that at least one item in info is English if there are - // multiple items. - if (detail.size() > 1) { - const auto found = std::any_of( - detail.begin(), detail.end(), [](const loot::MessageContent& mc) { - return mc.GetLanguage() == loot::MessageContent::DEFAULT_LANGUAGE; - }); - - if (!found) { - throw RepresentationException(node.Mark(), - "bad conversion: multilingual messages " - "must contain an English info string"); - } - } - - // Test condition syntax. - try { - loot::ParseCondition(condition); - loot::ParseCondition(constraint); - } catch (const std::exception& e) { - throw RepresentationException( - node.Mark(), - std::string("bad conversion: invalid condition syntax: ") + - e.what()); - } - - rhs = loot::File(name, display, condition, detail, constraint); - } else { - rhs = loot::File(node.as()); - } - - return true; - } -}; - -inline Emitter& operator<<(Emitter& out, const loot::File& rhs) { - if (loot::emitAsScalar(rhs)) { - out << YAML::SingleQuoted << std::string(rhs.GetName()); - } else { - out << BeginMap << Key << "name" << Value << YAML::SingleQuoted - << std::string(rhs.GetName()); - - if (!rhs.GetCondition().empty()) { - out << Key << "condition" << Value << YAML::SingleQuoted - << rhs.GetCondition(); - } - - if (!rhs.GetDisplayName().empty()) { - out << Key << "display" << Value << YAML::SingleQuoted - << rhs.GetDisplayName(); - } - - if (!rhs.GetConstraint().empty()) { - out << Key << "constraint" << Value << YAML::SingleQuoted - << rhs.GetConstraint(); - } - - if (rhs.GetDetail().size() == 1) { - out << Key << "detail" << Value << YAML::SingleQuoted - << rhs.GetDetail().front().GetText(); - } else if (!rhs.GetDetail().empty()) { - out << Key << "detail" << Value << rhs.GetDetail(); - } - - out << EndMap; - } - - return out; -} -} - -#endif diff --git a/src/api/metadata/yaml/group.h b/src/api/metadata/yaml/group.h deleted file mode 100644 index 7713867d..00000000 --- a/src/api/metadata/yaml/group.h +++ /dev/null @@ -1,101 +0,0 @@ -/* LOOT - - A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and - Fallout: New Vegas. - - Copyright (C) 2012-2016 WrinklyNinja - - This file is part of LOOT. - - LOOT is free software: you can redistribute - it and/or modify it under the terms of the GNU General Public License - as published by the Free Software Foundation, either version 3 of - the License, or (at your option) any later version. - - LOOT is distributed in the hope that it will - be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with LOOT. If not, see - . - */ -#ifndef LOOT_YAML_GROUP -#define LOOT_YAML_GROUP - -#define YAML_CPP_SUPPORT_MERGE_KEYS - -#include - -#include - -#include "loot/metadata/group.h" - -namespace YAML { -template<> -struct convert { - static Node encode(const loot::Group& rhs) { - Node node; - node["name"] = rhs.GetName(); - - if (!rhs.GetDescription().empty()) { - node["description"] = rhs.GetDescription(); - } - - auto afterGroups = rhs.GetAfterGroups(); - if (!afterGroups.empty()) - node["after"] = afterGroups; - - return node; - } - - static bool decode(const Node& node, loot::Group& rhs) { - if (!node.IsMap()) - throw RepresentationException( - node.Mark(), "bad conversion: 'group' object must be a map"); - - if (!node["name"]) - throw RepresentationException( - node.Mark(), - "bad conversion: 'name' key missing from 'file' map object"); - - std::string name = node["name"].as(); - std::string description; - std::vector afterGroups; - - if (node["description"]) { - description = node["description"].as(); - } - - if (node["after"]) { - afterGroups = node["after"].as>(); - } - - rhs = loot::Group(name, afterGroups, description); - - return true; - } -}; - -inline Emitter& operator<<(Emitter& out, const loot::Group& rhs) { - out << BeginMap << Key << "name" << Value << YAML::SingleQuoted - << rhs.GetName(); - - if (!rhs.GetDescription().empty()) { - out << Key << "description" << Value << YAML::SingleQuoted - << rhs.GetDescription(); - } - - auto afterGroups = rhs.GetAfterGroups(); - if (!afterGroups.empty()) { - out << Key << "after" << Value << afterGroups; - } - - out << EndMap; - - return out; -} -} - -#endif diff --git a/src/api/metadata/yaml/location.h b/src/api/metadata/yaml/location.h deleted file mode 100644 index ede695f3..00000000 --- a/src/api/metadata/yaml/location.h +++ /dev/null @@ -1,94 +0,0 @@ -/* LOOT - - A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and - Fallout: New Vegas. - - Copyright (C) 2012-2016 WrinklyNinja - - This file is part of LOOT. - - LOOT is free software: you can redistribute - it and/or modify it under the terms of the GNU General Public License - as published by the Free Software Foundation, either version 3 of - the License, or (at your option) any later version. - - LOOT is distributed in the hope that it will - be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with LOOT. If not, see - . - */ -#ifndef LOOT_YAML_LOCATION -#define LOOT_YAML_LOCATION - -#define YAML_CPP_SUPPORT_MERGE_KEYS - -#include - -#include -#include - -#include "loot/metadata/location.h" - -namespace loot { -inline bool emitAsScalar(const Location& location) { - return location.GetName().empty(); -} -} - -namespace YAML { -template<> -struct convert { - static Node encode(const loot::Location& rhs) { - Node node; - - node["link"] = rhs.GetURL(); - if (!rhs.GetName().empty()) - node["name"] = rhs.GetName(); - - return node; - } - - static bool decode(const Node& node, loot::Location& rhs) { - if (!node.IsMap() && !node.IsScalar()) - throw RepresentationException( - node.Mark(), - "bad conversion: 'location' object must be a map or scalar"); - - std::string url; - std::string name; - - if (node.IsMap()) { - if (!node["link"]) - throw RepresentationException( - node.Mark(), - "bad conversion: 'link' key missing from 'location' map object"); - - url = node["link"].as(); - if (node["name"]) - name = node["name"].as(); - } else - url = node.as(); - - rhs = loot::Location(url, name); - - return true; - } -}; - -inline Emitter& operator<<(Emitter& out, const loot::Location& rhs) { - if (emitAsScalar(rhs)) { - out << YAML::SingleQuoted << rhs.GetURL(); - } else { - out << BeginMap << Key << "link" << Value << YAML::SingleQuoted - << rhs.GetURL() << Key << "name" << Value << YAML::SingleQuoted - << rhs.GetName() << EndMap; - } - return out; -} -} - -#endif diff --git a/src/api/metadata/yaml/message.h b/src/api/metadata/yaml/message.h deleted file mode 100644 index eb5a3a3e..00000000 --- a/src/api/metadata/yaml/message.h +++ /dev/null @@ -1,170 +0,0 @@ -/* LOOT - - A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and - Fallout: New Vegas. - - Copyright (C) 2012-2016 WrinklyNinja - - This file is part of LOOT. - - LOOT is free software: you can redistribute - it and/or modify it under the terms of the GNU General Public License - as published by the Free Software Foundation, either version 3 of - the License, or (at your option) any later version. - - LOOT is distributed in the hope that it will - be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with LOOT. If not, see - . - */ -#ifndef LOOT_YAML_MESSAGE -#define LOOT_YAML_MESSAGE - -#define YAML_CPP_SUPPORT_MERGE_KEYS - -#include -#include - -#include -#include - -#include "api/metadata/condition_evaluator.h" -#include "loot/metadata/message.h" - -namespace YAML { -template<> -struct convert { - static Node encode(const loot::Message& rhs) { - Node node; - node["content"] = rhs.GetContent(); - - if (rhs.GetType() == loot::MessageType::say) - node["type"] = "say"; - else if (rhs.GetType() == loot::MessageType::warn) - node["type"] = "warn"; - else - node["type"] = "error"; - - if (!rhs.GetCondition().empty()) - node["condition"] = rhs.GetCondition(); - - return node; - } - - static bool decode(const Node& node, loot::Message& rhs) { - if (!node.IsMap()) - throw RepresentationException( - node.Mark(), "bad conversion: 'message' object must be a map"); - if (!node["type"]) - throw RepresentationException( - node.Mark(), - "bad conversion: 'type' key missing from 'message' object"); - if (!node["content"]) - throw RepresentationException( - node.Mark(), - "bad conversion: 'content' key missing from 'message' object"); - - std::string type; - type = node["type"].as(); - - loot::MessageType typeNo = loot::MessageType::say; - if (type == "warn") - typeNo = loot::MessageType::warn; - else if (type == "error") - typeNo = loot::MessageType::error; - - std::vector content; - if (node["content"].IsSequence()) - content = node["content"].as>(); - else { - content.push_back( - loot::MessageContent(node["content"].as())); - } - - // Check now that at least one item in content is English if there are - // multiple items. - if (content.size() > 1) { - bool found = false; - for (const auto& mc : content) { - if (mc.GetLanguage() == loot::MessageContent::DEFAULT_LANGUAGE) - found = true; - } - if (!found) - throw RepresentationException(node.Mark(), - "bad conversion: multilingual messages " - "must contain an English content string"); - } - - // Make any substitutions at this point. - if (node["subs"]) { - std::vector subs = - node["subs"].as>(); - - fmt::dynamic_format_arg_store formatArgStore; - for (const auto& sub : subs) { - formatArgStore.push_back(sub); - } - - for (auto& mc : content) { - try { - const auto formattedText = fmt::vformat(mc.GetText(), formatArgStore); - mc = loot::MessageContent(formattedText, mc.GetLanguage()); - } catch (const fmt::format_error& e) { - throw RepresentationException( - node.Mark(), - std::string("bad conversion: content substitution error: ") + - e.what()); - } - } - } - - std::string condition; - if (node["condition"]) - condition = node["condition"].as(); - - rhs = loot::Message(typeNo, content, condition); - - // Test condition syntax. - try { - loot::ParseCondition(rhs.GetCondition()); - } catch (const std::exception& e) { - throw RepresentationException( - node.Mark(), - std::string("bad conversion: invalid condition syntax: ") + e.what()); - } - - return true; - } -}; - -inline Emitter& operator<<(Emitter& out, const loot::Message& rhs) { - out << BeginMap; - - if (rhs.GetType() == loot::MessageType::say) - out << Key << "type" << Value << "say"; - else if (rhs.GetType() == loot::MessageType::warn) - out << Key << "type" << Value << "warn"; - else - out << Key << "type" << Value << "error"; - - if (rhs.GetContent().size() == 1) - out << Key << "content" << Value << YAML::SingleQuoted - << rhs.GetContent().front().GetText(); - else - out << Key << "content" << Value << rhs.GetContent(); - - if (!rhs.GetCondition().empty()) - out << Key << "condition" << Value << YAML::SingleQuoted - << rhs.GetCondition(); - - out << EndMap; - - return out; -} -} - -#endif diff --git a/src/api/metadata/yaml/message_content.h b/src/api/metadata/yaml/message_content.h deleted file mode 100644 index 5e9e5b71..00000000 --- a/src/api/metadata/yaml/message_content.h +++ /dev/null @@ -1,82 +0,0 @@ -/* LOOT - - A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and - Fallout: New Vegas. - - Copyright (C) 2012-2016 WrinklyNinja - - This file is part of LOOT. - - LOOT is free software: you can redistribute - it and/or modify it under the terms of the GNU General Public License - as published by the Free Software Foundation, either version 3 of - the License, or (at your option) any later version. - - LOOT is distributed in the hope that it will - be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with LOOT. If not, see - . - */ -#ifndef LOOT_YAML_MESSAGE_CONTENT -#define LOOT_YAML_MESSAGE_CONTENT - -#define YAML_CPP_SUPPORT_MERGE_KEYS - -#include - -#include - -#include "loot/metadata/message_content.h" - -namespace YAML { -template<> -struct convert { - static Node encode(const loot::MessageContent& rhs) { - Node node; - node["text"] = rhs.GetText(); - node["lang"] = rhs.GetLanguage(); - - return node; - } - - static bool decode(const Node& node, loot::MessageContent& rhs) { - if (!node.IsMap()) - throw RepresentationException( - node.Mark(), - "bad conversion: 'message content' object must be a map"); - if (!node["text"]) - throw RepresentationException( - node.Mark(), - "bad conversion: 'text' key missing from 'message content' object"); - if (!node["lang"]) - throw RepresentationException( - node.Mark(), - "bad conversion: 'lang' key missing from 'message content' object"); - - std::string text = node["text"].as(); - std::string lang = node["lang"].as(); - - rhs = loot::MessageContent(text, lang); - - return true; - } -}; - -inline Emitter& operator<<(Emitter& out, const loot::MessageContent& rhs) { - out << BeginMap; - - out << Key << "lang" << Value << rhs.GetLanguage(); - - out << Key << "text" << Value << YAML::SingleQuoted << rhs.GetText(); - - out << EndMap; - - return out; -} -} - -#endif diff --git a/src/api/metadata/yaml/plugin_cleaning_data.h b/src/api/metadata/yaml/plugin_cleaning_data.h deleted file mode 100644 index 7e2bb391..00000000 --- a/src/api/metadata/yaml/plugin_cleaning_data.h +++ /dev/null @@ -1,138 +0,0 @@ -/* LOOT - - A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and - Fallout: New Vegas. - - Copyright (C) 2012-2016 WrinklyNinja - - This file is part of LOOT. - - LOOT is free software: you can redistribute - it and/or modify it under the terms of the GNU General Public License - as published by the Free Software Foundation, either version 3 of - the License, or (at your option) any later version. - - LOOT is distributed in the hope that it will - be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with LOOT. If not, see - . - */ - -#ifndef LOOT_YAML_PLUGIN_CLEANING_DATA -#define LOOT_YAML_PLUGIN_CLEANING_DATA - -#define YAML_CPP_SUPPORT_MERGE_KEYS - -#include - -#include -#include - -#include "api/helpers/crc.h" -#include "loot/metadata/plugin_cleaning_data.h" - -namespace YAML { -template<> -struct convert { - static Node encode(const loot::PluginCleaningData& rhs) { - Node node; - node["crc"] = rhs.GetCRC(); - node["util"] = rhs.GetCleaningUtility(); - node["detail"] = rhs.GetDetail(); - - if (rhs.GetITMCount() > 0) - node["itm"] = rhs.GetITMCount(); - if (rhs.GetDeletedReferenceCount() > 0) - node["udr"] = rhs.GetDeletedReferenceCount(); - if (rhs.GetDeletedNavmeshCount() > 0) - node["nav"] = rhs.GetDeletedNavmeshCount(); - - return node; - } - - static bool decode(const Node& node, loot::PluginCleaningData& rhs) { - if (!node.IsMap()) - throw RepresentationException( - node.Mark(), "bad conversion: 'cleaning data' object must be a map"); - if (!node["crc"]) - throw RepresentationException( - node.Mark(), - "bad conversion: 'crc' key missing from 'cleaning data' object"); - if (!node["util"]) - throw RepresentationException( - node.Mark(), - "bad conversion: 'util' key missing from 'cleaning data' object"); - - uint32_t crc = node["crc"].as(); - int itm = 0, ref = 0, nav = 0; - - if (node["itm"]) - itm = node["itm"].as(); - if (node["udr"]) - ref = node["udr"].as(); - if (node["nav"]) - nav = node["nav"].as(); - - std::string utility = node["util"].as(); - - std::vector detail; - if (node["detail"]) { - if (node["detail"].IsSequence()) - detail = node["detail"].as>(); - else { - detail.push_back( - loot::MessageContent(node["detail"].as())); - } - } - - // Check now that at least one item in info is English if there are multiple - // items. - if (detail.size() > 1) { - bool found = false; - for (const auto& mc : detail) { - if (mc.GetLanguage() == loot::MessageContent::DEFAULT_LANGUAGE) - found = true; - } - if (!found) - throw RepresentationException(node.Mark(), - "bad conversion: multilingual messages " - "must contain an English info string"); - } - - rhs = loot::PluginCleaningData(crc, utility, detail, itm, ref, nav); - - return true; - } -}; - -inline Emitter& operator<<(Emitter& out, const loot::PluginCleaningData& rhs) { - out << BeginMap << Key << "crc" << Value - << "0x" + loot::CrcToString(rhs.GetCRC()) << Key << "util" << Value - << YAML::SingleQuoted << rhs.GetCleaningUtility(); - - if (!rhs.GetDetail().empty()) { - if (rhs.GetDetail().size() == 1) - out << Key << "detail" << Value << YAML::SingleQuoted - << rhs.GetDetail().front().GetText(); - else - out << Key << "detail" << Value << rhs.GetDetail(); - } - - if (rhs.GetITMCount() > 0) - out << Key << "itm" << Value << rhs.GetITMCount(); - if (rhs.GetDeletedReferenceCount() > 0) - out << Key << "udr" << Value << rhs.GetDeletedReferenceCount(); - if (rhs.GetDeletedNavmeshCount() > 0) - out << Key << "nav" << Value << rhs.GetDeletedNavmeshCount(); - - out << EndMap; - - return out; -} -} - -#endif diff --git a/src/api/metadata/yaml/plugin_metadata.h b/src/api/metadata/yaml/plugin_metadata.h deleted file mode 100644 index 71fe6b33..00000000 --- a/src/api/metadata/yaml/plugin_metadata.h +++ /dev/null @@ -1,187 +0,0 @@ -/* LOOT - - A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and - Fallout: New Vegas. - - Copyright (C) 2012-2016 WrinklyNinja - - This file is part of LOOT. - - LOOT is free software: you can redistribute - it and/or modify it under the terms of the GNU General Public License - as published by the Free Software Foundation, either version 3 of - the License, or (at your option) any later version. - - LOOT is distributed in the hope that it will - be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with LOOT. If not, see - . - */ -#ifndef LOOT_YAML_PLUGIN_METADATA -#define LOOT_YAML_PLUGIN_METADATA - -#define YAML_CPP_SUPPORT_MERGE_KEYS - -#include - -#include -#include -#include -#include -#include -#include - -#include "api/metadata/yaml/file.h" -#include "api/metadata/yaml/location.h" -#include "api/metadata/yaml/message.h" -#include "api/metadata/yaml/message_content.h" -#include "api/metadata/yaml/plugin_cleaning_data.h" -#include "api/metadata/yaml/set.h" -#include "api/metadata/yaml/tag.h" -#include "loot/metadata/plugin_metadata.h" - -namespace loot { -template -inline ::YAML::EMITTER_MANIP getNodeStyle(const std::vector& objects) { - if (objects.size() == 1 && emitAsScalar(objects.at(0))) { - return YAML::Flow; - } - - return YAML::Block; -} -} - -namespace YAML { -template<> -struct convert { - static Node encode(const loot::PluginMetadata& rhs) { - Node node; - node["name"] = rhs.GetName(); - - if (rhs.GetGroup()) - node["group"] = rhs.GetGroup().value(); - - if (!rhs.GetLoadAfterFiles().empty()) - node["after"] = rhs.GetLoadAfterFiles(); - if (!rhs.GetRequirements().empty()) - node["req"] = rhs.GetRequirements(); - if (!rhs.GetIncompatibilities().empty()) - node["inc"] = rhs.GetIncompatibilities(); - if (!rhs.GetMessages().empty()) - node["msg"] = rhs.GetMessages(); - if (!rhs.GetTags().empty()) - node["tag"] = rhs.GetTags(); - if (!rhs.GetDirtyInfo().empty()) - node["dirty"] = rhs.GetDirtyInfo(); - if (!rhs.GetCleanInfo().empty()) - node["clean"] = rhs.GetCleanInfo(); - if (!rhs.GetLocations().empty()) - node["url"] = rhs.GetLocations(); - - return node; - } - - static bool decode(const Node& node, loot::PluginMetadata& rhs) { - if (!node.IsMap()) - throw RepresentationException( - node.Mark(), - "bad conversion: 'plugin metadata' object must be a map"); - if (!node["name"]) - throw RepresentationException( - node.Mark(), - "bad conversion: 'name' key missing from 'plugin metadata' object"); - - try { - rhs = loot::PluginMetadata(node["name"].as()); - } catch (const std::regex_error& e) { - throw RepresentationException( - node.Mark(), - std::string("bad conversion: invalid regex in 'name' key: ") + - e.what()); - } - - if (node["group"]) - rhs.SetGroup(node["group"].as()); - - if (node["after"]) - rhs.SetLoadAfterFiles(node["after"].as>()); - if (node["req"]) - rhs.SetRequirements(node["req"].as>()); - if (node["inc"]) - rhs.SetIncompatibilities(node["inc"].as>()); - if (node["msg"]) - rhs.SetMessages(node["msg"].as>()); - if (node["tag"]) - rhs.SetTags(node["tag"].as>()); - if (node["dirty"]) { - rhs.SetDirtyInfo( - node["dirty"].as>()); - } - if (node["clean"]) { - rhs.SetCleanInfo( - node["clean"].as>()); - } - if (node["url"]) - rhs.SetLocations(node["url"].as>()); - - return true; - } -}; - -inline Emitter& operator<<(Emitter& out, const loot::PluginMetadata& rhs) { - if (!rhs.HasNameOnly()) { - out << BeginMap << Key << "name" << Value << YAML::SingleQuoted - << rhs.GetName(); - - const auto locations = rhs.GetLocations(); - if (!locations.empty()) { - out << Key << "url" << Value << loot::getNodeStyle(locations) - << locations; - } - - if (rhs.GetGroup()) { - out << Key << "group" << Value << YAML::SingleQuoted - << rhs.GetGroup().value(); - } - - const auto after = rhs.GetLoadAfterFiles(); - if (!after.empty()) { - out << Key << "after" << Value << loot::getNodeStyle(after) << after; - } - - const auto req = rhs.GetRequirements(); - if (!req.empty()) { - out << Key << "req" << Value << loot::getNodeStyle(req) << req; - } - - const auto inc = rhs.GetIncompatibilities(); - if (!inc.empty()) { - out << Key << "inc" << Value << loot::getNodeStyle(inc) << inc; - } - - if (!rhs.GetMessages().empty()) - out << Key << "msg" << Value << rhs.GetMessages(); - - const auto tags = rhs.GetTags(); - if (!tags.empty()) { - out << Key << "tag" << Value << loot::getNodeStyle(tags) << tags; - } - - if (!rhs.GetDirtyInfo().empty()) - out << Key << "dirty" << Value << rhs.GetDirtyInfo(); - - if (!rhs.GetCleanInfo().empty()) - out << Key << "clean" << Value << rhs.GetCleanInfo(); - - out << EndMap; - } - - return out; -} -} - -#endif diff --git a/src/api/metadata/yaml/set.h b/src/api/metadata/yaml/set.h deleted file mode 100644 index 0f35f92c..00000000 --- a/src/api/metadata/yaml/set.h +++ /dev/null @@ -1,111 +0,0 @@ -/* LOOT - - A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and - Fallout: New Vegas. - - Copyright (C) 2012-2016 WrinklyNinja - - This file is part of LOOT. - - LOOT is free software: you can redistribute - it and/or modify it under the terms of the GNU General Public License - as published by the Free Software Foundation, either version 3 of - the License, or (at your option) any later version. - - LOOT is distributed in the hope that it will - be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with LOOT. If not, see - . - */ - -#ifndef LOOT_YAML_SET -#define LOOT_YAML_SET - -#define YAML_CPP_SUPPORT_MERGE_KEYS - -#include - -#include -#include - -namespace YAML { -template -struct convert> { - static Node encode(const std::set& rhs) { - Node node; - for (const auto& element : rhs) { - node.push_back(element); - } - return node; - } - - static bool decode(const Node& node, std::set& rhs) { - if (!node.IsSequence()) - throw RepresentationException( - node.Mark(), "bad conversion: set must be a sequence of elements"); - - rhs.clear(); - for (const auto& element : node) { - if (!rhs.insert(element.template as()).second) - throw RepresentationException( - node.Mark(), "bad conversion: set elements must be unique"); - } - return true; - } -}; - -template -Emitter& operator<<(Emitter& out, const std::set& rhs) { - out << BeginSeq; - for (const auto& element : rhs) { - out << element; - } - out << EndSeq; - - return out; -} - -template -struct convert> { - static Node encode(const std::unordered_set& rhs) { - Node node; - for (const auto& element : rhs) { - node.push_back(element); - } - return node; - } - - static bool decode(const Node& node, std::unordered_set& rhs) { - if (!node.IsSequence()) - throw RepresentationException( - node.Mark(), - "bad conversion: unordered set must be a sequence of elements"); - - rhs.clear(); - for (const auto& element : node) { - if (!rhs.insert(element.template as()).second) - throw RepresentationException( - node.Mark(), - "bad conversion: unordered set elements must be unique"); - } - return true; - } -}; - -template -Emitter& operator<<(Emitter& out, const std::unordered_set& rhs) { - out << BeginSeq; - for (const auto& element : rhs) { - out << element; - } - out << EndSeq; - - return out; -} -} - -#endif diff --git a/src/api/metadata/yaml/tag.h b/src/api/metadata/yaml/tag.h deleted file mode 100644 index ffbdcc24..00000000 --- a/src/api/metadata/yaml/tag.h +++ /dev/null @@ -1,111 +0,0 @@ -/* LOOT - - A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and - Fallout: New Vegas. - - Copyright (C) 2012-2016 WrinklyNinja - - This file is part of LOOT. - - LOOT is free software: you can redistribute - it and/or modify it under the terms of the GNU General Public License - as published by the Free Software Foundation, either version 3 of - the License, or (at your option) any later version. - - LOOT is distributed in the hope that it will - be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with LOOT. If not, see - . - */ -#ifndef LOOT_YAML_TAG -#define LOOT_YAML_TAG - -#define YAML_CPP_SUPPORT_MERGE_KEYS - -#include - -#include - -#include "api/metadata/condition_evaluator.h" -#include "loot/metadata/tag.h" - -namespace loot { -inline bool emitAsScalar(const Tag& tag) { return tag.GetCondition().empty(); } -} - -namespace YAML { -template<> -struct convert { - static Node encode(const loot::Tag& rhs) { - Node node; - if (!rhs.GetCondition().empty()) - node["condition"] = rhs.GetCondition(); - if (rhs.IsAddition()) - node["name"] = rhs.GetName(); - else - node["name"] = "-" + rhs.GetName(); - return node; - } - - static bool decode(const Node& node, loot::Tag& rhs) { - if (!node.IsMap() && !node.IsScalar()) - throw RepresentationException( - node.Mark(), "bad conversion: 'tag' object must be a map or scalar"); - - std::string condition, tag; - if (node.IsMap()) { - if (!node["name"]) - throw RepresentationException( - node.Mark(), - "bad conversion: 'name' key missing from 'tag' map object"); - - tag = node["name"].as(); - if (node["condition"]) - condition = node["condition"].as(); - } else - tag = node.as(); - - if (!tag.empty() && tag.at(0) == '-') - rhs = loot::Tag(tag.substr(1), false, condition); - else - rhs = loot::Tag(tag, true, condition); - - // Test condition syntax. - try { - loot::ParseCondition(rhs.GetCondition()); - } catch (const std::exception& e) { - throw RepresentationException( - node.Mark(), - std::string("bad conversion: invalid condition syntax: ") + e.what()); - } - - return true; - } -}; - -inline Emitter& operator<<(Emitter& out, const loot::Tag& rhs) { - if (emitAsScalar(rhs)) { - if (rhs.IsAddition()) - out << rhs.GetName(); - else - out << ('-' + rhs.GetName()); - } else { - out << BeginMap; - if (rhs.IsAddition()) - out << Key << "name" << Value << rhs.GetName(); - else - out << Key << "name" << Value << ('-' + rhs.GetName()); - - out << Key << "condition" << Value << YAML::SingleQuoted - << rhs.GetCondition() << EndMap; - } - - return out; -} -} - -#endif diff --git a/src/api/metadata_list.cpp b/src/api/metadata_list.cpp deleted file mode 100644 index 16fbf35a..00000000 --- a/src/api/metadata_list.cpp +++ /dev/null @@ -1,374 +0,0 @@ -/* LOOT - - A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and - Fallout: New Vegas. - - Copyright (C) 2012-2016 WrinklyNinja - - This file is part of LOOT. - - LOOT is free software: you can redistribute - it and/or modify it under the terms of the GNU General Public License - as published by the Free Software Foundation, either version 3 of - the License, or (at your option) any later version. - - LOOT is distributed in the hope that it will - be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with LOOT. If not, see - . - */ - -#include "api/metadata_list.h" - -#include -#include -#include -#include -#include - -#include "api/game/game.h" -#include "api/helpers/logging.h" -#include "api/helpers/text.h" -#include "api/metadata/condition_evaluator.h" -#include "api/metadata/yaml/group.h" -#include "api/metadata/yaml/plugin_metadata.h" - -namespace loot { -using std::string_view_literals::operator""sv; - -constexpr std::string_view PRELUDE_ON_FIRST_LINE = "prelude:"sv; -constexpr std::string_view PRELUDE_ON_NEW_LINE = "\nprelude:"sv; - -std::string read_to_string(const std::filesystem::path& filePath) { - std::ifstream in(filePath); - if (!in.good()) { - throw std::runtime_error("Cannot open " + filePath.u8string()); - } - - auto content = std::string(std::istreambuf_iterator(in), - std::istreambuf_iterator()); - - in.close(); - - return content; -} - -std::optional> FindPreludeBounds( - std::string_view masterlist) { - size_t startOfPrelude = std::string::npos; - size_t endOfPrelude = std::string::npos; - - // This assumes that the metadata file is using block style at - // the top level, that ? indicators and tags are not used, and - // that key strings are unquoted. - if (boost::starts_with(masterlist, PRELUDE_ON_FIRST_LINE)) { - startOfPrelude = PRELUDE_ON_FIRST_LINE.size(); - } else { - startOfPrelude = masterlist.find(PRELUDE_ON_NEW_LINE); - - if (startOfPrelude != std::string::npos) { - // Skip the leading line break. - startOfPrelude += PRELUDE_ON_NEW_LINE.size(); - } - } - - if (startOfPrelude == std::string::npos) { - // No prelude to replace. - return std::nullopt; - } - - // The end of the prelude is marked by a line break followed by a - // non-space, non-hash (#) character, as this means what follows is - // unindented content. - auto pos = startOfPrelude; - const auto lastIndex = masterlist.size() - 1; - while (endOfPrelude == std::string::npos) { - const auto nextLineBreakPos = masterlist.find("\n", pos); - if (nextLineBreakPos == std::string::npos || - nextLineBreakPos == lastIndex) { - break; - } - - pos = nextLineBreakPos + 1; - - const auto nextChar = masterlist.at(pos); - if (nextChar != ' ' && nextChar != '#' && nextChar != '\n') { - endOfPrelude = nextLineBreakPos; - break; - } - } - - return std::make_pair(startOfPrelude, endOfPrelude); -} - -// Indent all prelude content by two spaces to ensure it's parsed as part -// of the prelude. -std::string IndentPrelude(const std::string& prelude) { - auto newPrelude = "\n " + boost::replace_all_copy(prelude, "\n", "\n "); - - boost::replace_all(newPrelude, " \n", "\n"); - - if (boost::ends_with(newPrelude, "\n ")) { - return newPrelude.substr(0, newPrelude.size() - 2); - } - - return newPrelude; -} - -std::string ReplaceMetadataListPrelude(const std::string& prelude, - std::string&& masterlist) { - auto preludeBounds = FindPreludeBounds(masterlist); - - if (!preludeBounds.has_value()) { - // No prelude to replace. - return masterlist; - } - - auto newPrelude = IndentPrelude(prelude); - - const auto [startOfPrelude, endOfPrelude] = preludeBounds.value(); - - if (endOfPrelude == std::string::npos) { - return masterlist.substr(0, startOfPrelude) + newPrelude; - } - - return masterlist.substr(0, startOfPrelude) + newPrelude + - masterlist.substr(endOfPrelude); -} - -void MetadataList::Load(const std::filesystem::path& filepath) { - Clear(); - - auto logger = getLogger(); - if (logger) { - logger->trace("Loading file: {}", filepath.u8string()); - } - - std::ifstream in(filepath); - if (!in.good()) - throw std::runtime_error("Cannot open " + filepath.u8string()); - - this->Load(in, filepath); - - in.close(); -} - -void MetadataList::LoadWithPrelude(const std::filesystem::path& filePath, - const std::filesystem::path& preludePath) { - // Parsing YAML resolves references such that replacing the - // referenced keys entirely (rather than just replacing their values) - // does not cause aliases to be re-resolved, so the old values are - // retained. - // As such, replacing the prelude needs to happen before parsing, - // which means reading the files and performing string manipulation. - auto masterlist_content = ReplaceMetadataListPrelude( - read_to_string(preludePath), read_to_string(filePath)); - - auto stream = std::istringstream(masterlist_content); - this->Load(stream, filePath); -} - -void MetadataList::Load(std::istream& istream, - const std::filesystem::path& source_path) { - YAML::Node metadataList = YAML::Load(istream); - - if (!metadataList.IsMap()) - throw std::runtime_error("The root of the metadata file " + - source_path.u8string() + " is not a YAML map."); - - if (metadataList["plugins"]) { - for (const auto& node : metadataList["plugins"]) { - PluginMetadata plugin(node.as()); - if (plugin.IsRegexPlugin()) - regexPlugins_.push_back(plugin); - else if (!plugins_.emplace(Filename(plugin.GetName()), plugin).second) - throw std::runtime_error("More than one entry exists for plugin \"" + - plugin.GetName() + "\""); - } - } - if (metadataList["globals"]) - messages_ = metadataList["globals"].as>(); - - std::unordered_set bashTags; - if (metadataList["bash_tags"]) { - for (const auto& node : metadataList["bash_tags"]) { - auto bashTag = node.as(); - if (bashTags.count(bashTag) != 0) { - throw std::runtime_error("More than one entry exists for Bash Tag \"" + - bashTag + "\""); - } - bashTags_.push_back(bashTag); - bashTags.insert(bashTag); - } - } - - std::unordered_set groupNames; - if (metadataList["groups"]) { - for (const auto& node : metadataList["groups"]) { - auto group = node.as(); - if (groupNames.count(group.GetName()) != 0) { - throw std::runtime_error("More than one entry exists for group \"" + - group.GetName() + "\""); - } - groups_.push_back(group); - groupNames.insert(group.GetName()); - } - } - - auto defaultGroup = Group(); - if (groupNames.count(defaultGroup.GetName()) == 0) { - groups_.insert(groups_.cbegin(), Group()); - } - - auto logger = getLogger(); - if (logger) { - logger->trace("Successfully loaded metadata from file at \"{}\".", - source_path.u8string()); - } -} - -void MetadataList::Save(const std::filesystem::path& filepath) const { - auto logger = getLogger(); - if (logger) { - logger->trace("Saving metadata list to: {}", filepath.u8string()); - } - YAML::Emitter emitter; - emitter.SetIndent(2); - emitter << YAML::BeginMap; - - if (!bashTags_.empty()) - emitter << YAML::Key << "bash_tags" << YAML::Value << bashTags_; - - if (!groups_.empty()) { - emitter << YAML::Key << "groups" << YAML::Value << groups_; - } - - if (!messages_.empty()) - emitter << YAML::Key << "globals" << YAML::Value << messages_; - - auto plugins = Plugins(); - std::sort(plugins.begin(), - plugins.end(), - [](const PluginMetadata& p1, const PluginMetadata& p2) { - return CompareFilenames(p1.GetName(), p2.GetName()) < 0; - }); - - if (!plugins.empty()) - emitter << YAML::Key << "plugins" << YAML::Value << plugins; - - emitter << YAML::EndMap; - - std::ofstream out(filepath); - if (out.fail()) - throw std::runtime_error("Couldn't open output file."); - - out << emitter.c_str(); - out.close(); -} - -void MetadataList::Clear() { - groups_.clear(); - bashTags_.clear(); - plugins_.clear(); - regexPlugins_.clear(); - messages_.clear(); -} - -std::vector MetadataList::Plugins() const { - std::vector plugins; - plugins.reserve(plugins_.size() + regexPlugins_.size()); - for (const auto& pluginPair : plugins_) { - plugins.push_back(pluginPair.second); - } - plugins.insert(plugins.end(), regexPlugins_.begin(), regexPlugins_.end()); - - return plugins; -} - -std::vector MetadataList::Messages() const { return messages_; } - -std::vector MetadataList::BashTags() const { return bashTags_; } - -std::vector MetadataList::Groups() const { - if (groups_.empty()) { - return {Group()}; - } - - return groups_; -} - -void MetadataList::SetGroups(const std::vector& groups) { - // In case the default group is missing. - auto defaultGroupName = Group().GetName(); - const auto defaultGroupsExists = - std::any_of(groups.cbegin(), groups.cend(), [&](const Group& group) { - return group.GetName() == defaultGroupName; - }); - - if (!defaultGroupsExists) { - groups_.clear(); - groups_.push_back(Group()); - groups_.insert(groups_.end(), groups.begin(), groups.end()); - } else { - groups_ = groups; - } -} - -// Merges multiple matching regex entries if any are found. -std::optional MetadataList::FindPlugin( - std::string_view pluginName) const { - PluginMetadata match(pluginName); - - const auto it = plugins_.find(Filename(pluginName)); - - if (it != plugins_.end()) - match = it->second; - - // Now we want to also match possibly multiple regex entries. - const auto nameMatches = [&](const PluginMetadata& pluginMetadata) { - return pluginMetadata.NameMatches(pluginName); - }; - auto regIt = find_if(regexPlugins_.begin(), regexPlugins_.end(), nameMatches); - while (regIt != regexPlugins_.end()) { - match.MergeMetadata(*regIt); - - regIt = find_if(++regIt, regexPlugins_.end(), nameMatches); - } - - if (match.HasNameOnly()) { - return std::nullopt; - } - - return match; -} - -void MetadataList::AddPlugin(const PluginMetadata& plugin) { - if (plugin.IsRegexPlugin()) - regexPlugins_.push_back(plugin); - else { - if (!plugins_.emplace(Filename(plugin.GetName()), plugin).second) - throw std::invalid_argument( - "Cannot add \"" + plugin.GetName() + - "\" to the metadata list as another entry already exists."); - } -} - -// Doesn't erase matching regex entries, because they might also -// be required for other plugins. -void MetadataList::ErasePlugin(std::string_view pluginName) { - const auto it = plugins_.find(Filename(pluginName)); - - if (it != plugins_.end()) { - plugins_.erase(it); - return; - } -} - -void MetadataList::AppendMessage(const Message& message) { - messages_.push_back(message); -} -} diff --git a/src/api/metadata_list.h b/src/api/metadata_list.h deleted file mode 100644 index 1eef8705..00000000 --- a/src/api/metadata_list.h +++ /dev/null @@ -1,93 +0,0 @@ -/* LOOT - - A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and - Fallout: New Vegas. - - Copyright (C) 2012-2016 WrinklyNinja - - This file is part of LOOT. - - LOOT is free software: you can redistribute - it and/or modify it under the terms of the GNU General Public License - as published by the Free Software Foundation, either version 3 of - the License, or (at your option) any later version. - - LOOT is distributed in the hope that it will - be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with LOOT. If not, see - . - */ - -#ifndef LOOT_API_METADATA_LIST -#define LOOT_API_METADATA_LIST - -#include -#include -#include -#include -#include -#include - -#include "api/helpers/text.h" -#include "api/metadata/condition_evaluator.h" -#include "loot/metadata/group.h" -#include "loot/metadata/plugin_metadata.h" - -namespace std { -template<> -struct hash { - size_t operator()(const loot::Filename& filename) const { - return hash()(loot::NormalizeFilename(std::string(filename))); - } -}; -} - -namespace loot { - -// This assumes that the prelude and masterlist files both use -// YAML's block style (at least up to the end of the prelude in the -// latter). This is true for all official files. -std::string ReplaceMetadataListPrelude(const std::string& prelude, - std::string&& masterlist); - -class MetadataList { -public: - void Load(const std::filesystem::path& filepath); - void LoadWithPrelude(const std::filesystem::path& filePath, - const std::filesystem::path& preludePath); - void Save(const std::filesystem::path& filepath) const; - void Clear(); - - std::vector Plugins() const; - std::vector Messages() const; - std::vector BashTags() const; - std::vector Groups() const; - - void SetGroups(const std::vector& groups); - - // Merges multiple matching regex entries if any are found. - std::optional FindPlugin(std::string_view pluginName) const; - void AddPlugin(const PluginMetadata& plugin); - - // Doesn't erase matching regex entries, because they might also - // be required for other plugins. - void ErasePlugin(std::string_view pluginName); - - void AppendMessage(const Message& message); - -private: - std::vector groups_; - std::vector bashTags_; - std::unordered_map plugins_; - std::vector regexPlugins_; - std::vector messages_; - - void Load(std::istream& istream, const std::filesystem::path& source_path); -}; -} - -#endif diff --git a/src/api/plugin.cpp b/src/api/plugin.cpp deleted file mode 100644 index 2aae36b7..00000000 --- a/src/api/plugin.cpp +++ /dev/null @@ -1,710 +0,0 @@ -/* LOOT - - A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and - Fallout: New Vegas. - - Copyright (C) 2012-2016 WrinklyNinja - - This file is part of LOOT. - - LOOT is free software: you can redistribute - it and/or modify it under the terms of the GNU General Public License - as published by the Free Software Foundation, either version 3 of - the License, or (at your option) any later version. - - LOOT is distributed in the hope that it will - be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with LOOT. If not, see - . - */ - -#include "api/plugin.h" - -#include -#include -#include -#include - -#include "api/bsa.h" -#include "api/game/game.h" -#include "api/helpers/crc.h" -#include "api/helpers/logging.h" -#include "api/helpers/text.h" -#include "loot/exception/plugin_not_loaded_error.h" - -namespace { -using loot::BSA_FILE_EXTENSION; -using loot::GameCache; -using loot::GameType; - -// Intentionally takes a copy of the first parameter. -std::filesystem::path ReplaceExtension(std::filesystem::path path, - std::string_view newExtension) { - return path.replace_extension(std::filesystem::u8path(newExtension)); -} - -// Intentionally takes a copy of the first parameter. -std::filesystem::path GetSuffixedArchivePath(std::filesystem::path pluginPath, - std::string_view suffix, - std::string_view newExtension) { - // replace_extension() with no argument just removes the existing extension. - pluginPath.replace_extension(); - pluginPath += suffix; - pluginPath += newExtension; - return pluginPath; -} - -std::vector FindAssociatedArchive( - const std::filesystem::path& pluginPath) { - const auto archiveFilename = ReplaceExtension(pluginPath, BSA_FILE_EXTENSION); - - if (std::filesystem::exists(archiveFilename)) { - return {archiveFilename}; - } - - return {}; -} - -std::vector FindAssociatedArchivesWithSuffixes( - const std::filesystem::path& pluginPath, - std::string_view archiveExtension, - const std::vector& supportedSuffixes) { - std::vector paths; - - for (const auto& suffix : supportedSuffixes) { - const auto archivePath = - GetSuffixedArchivePath(pluginPath, suffix, archiveExtension); - - if (std::filesystem::exists(archivePath)) { - paths.push_back(archivePath); - } - } - - return paths; -} - -std::vector FindAssociatedArchivesWithArbitrarySuffixes( - const GameCache& gameCache, - const std::filesystem::path& pluginPath) { - const auto basenameLength = pluginPath.stem().native().length(); - const auto pluginExtension = pluginPath.extension().native(); - - std::vector paths; - for (const auto& archivePath : gameCache.GetArchivePaths()) { - // 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. - const auto bsaPluginFilename = - archivePath.filename().native().substr(0, basenameLength) + - pluginExtension; - const auto bsaPluginPath = pluginPath.parent_path() / bsaPluginFilename; - if (loot::equivalent(pluginPath, bsaPluginPath)) { - paths.push_back(archivePath); - } - } - - return paths; -} - -std::vector FindAssociatedArchives( - const GameType gameType, - const GameCache& gameCache, - const std::filesystem::path& pluginPath) { - switch (gameType) { - case GameType::tes3: - case GameType::openmw: - return {}; - case GameType::tes5: - // Skyrim (non-SE) plugins can only load BSAs that have exactly the same - // basename, ignoring file extensions. - return FindAssociatedArchive(pluginPath); - case GameType::tes5se: - case GameType::tes5vr: - // Skyrim SE can load BSAs that have exactly the same - // basename, ignoring file extensions, and also BSAs with filenames of - // the form " - Textures.bsa" (case-insensitively). - // This assumes that Skyrim VR works the same way as Skyrim SE. - return FindAssociatedArchivesWithSuffixes( - pluginPath, BSA_FILE_EXTENSION, {"", " - Textures"}); - case GameType::tes4: - case GameType::oblivionRemastered: { - // Oblivion .esp files can load archives which begin with the plugin - // basename. - if (!boost::iends_with(pluginPath.filename().u8string(), ".esp")) { - return {}; - } - - return FindAssociatedArchivesWithArbitrarySuffixes(gameCache, pluginPath); - } - case GameType::fo3: - case GameType::fonv: - case GameType::fo4: - case GameType::fo4vr: { - // FO3, FNV, FO4 plugins can load archives which begin with the plugin - // basename. This assumes that FO4 VR works the same way as FO4. - return FindAssociatedArchivesWithArbitrarySuffixes(gameCache, pluginPath); - } - case GameType::starfield: - // The game will load a BA2 that's suffixed with " - Voices_" - // where 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. - return FindAssociatedArchivesWithSuffixes( - pluginPath, - loot::BA2_FILE_EXTENSION, - {" - Main", " - Textures", " - Localization", " - Voices_en"}); - default: - throw std::logic_error("Unrecognised game type"); - } -} - -void HandleEspluginError(unsigned int returnCode, std::string_view operation) { - if (returnCode == ESP_OK) { - return; - } - - auto err = fmt::format( - "Failed to {}. esplugin error code: {}", operation, returnCode); - - const char* message = nullptr; - esp_get_error_message(&message); - if (message == nullptr) { - err += ". Details could not be fetched."; - } else { - err += ". Details: " + std::string(message); - } - - auto logger = loot::getLogger(); - if (logger) { - logger->error(err); - } - - if (returnCode == ESP_ERROR_PLUGIN_METADATA_NOT_FOUND) { - throw loot::PluginNotLoadedError(err); - } - - throw std::runtime_error(err); -} - -template -void HandleEspluginError(unsigned int returnCode, - std::string_view message, - Args... args) { - if (returnCode == ESP_OK) { - return; - } - - auto operation = fmt::format(message, args...); - return HandleEspluginError(returnCode, operation); -} - -void HandleEspluginError(unsigned int returnCode, - const std::function& getMessage) { - if (returnCode == ESP_OK) { - return; - } - - return HandleEspluginError(returnCode, getMessage()); -} - -std::string GetPluginName(GameType gameType, - const std::filesystem::path& pluginPath) { - return gameType == GameType::openmw - ? pluginPath.filename().u8string() - : loot::TrimDotGhostExtension(pluginPath.filename().u8string()); -} - -std::unique_ptr<::Plugin, decltype(&esp_plugin_free)> MakeEspluginPtr() { - return std::unique_ptr<::Plugin, decltype(&esp_plugin_free)>(nullptr, - esp_plugin_free); -} - -bool ShouldIgnoreMasterFlag(GameType gameType) { - return gameType == GameType::openmw || - gameType == GameType::oblivionRemastered; -} -} - -namespace loot { -Plugin::Plugin(const GameType gameType, - const GameCache& gameCache, - const std::filesystem::path& pluginPath, - const bool headerOnly) : - name_(GetPluginName(gameType, pluginPath)), - esPlugin(MakeEspluginPtr()), - ignoreMasterFlag_(ShouldIgnoreMasterFlag(gameType)), - isEmpty_(true) { - auto logger = getLogger(); - - try { - if (gameType != GameType::openmw || - pluginPath.extension() != ".omwscripts") { - Load(pluginPath, gameType, headerOnly); - - auto ret = esp_plugin_is_empty(esPlugin.get(), &isEmpty_); - HandleEspluginError(ret, "check if \"{}\" is empty", name_); - } - - archivePaths_ = FindAssociatedArchives(gameType, gameCache, pluginPath); - - if (!headerOnly) { - crc_ = GetCrc32(pluginPath); - - // Get the assets in the BSAs that this plugin loads. - auto assets = GetAssetsInBethesdaArchives(archivePaths_); - std::swap(archiveAssets_, assets); - - if (logger) { - logger->debug( - "Plugin file \"{}\" loads {} assets from Bethesda archives", - pluginPath.u8string(), - GetAssetCount()); - } - } - - const auto description = GetDescription(); - tags_ = ExtractBashTags(description); - version_ = ExtractVersion(description); - } catch (const std::system_error& e) { - if (logger) { - logger->error("Cannot read plugin file \"{}\". Details: {}", - pluginPath.u8string(), - e.what()); - } - throw std::runtime_error("Cannot read \"" + pluginPath.u8string() + - "\". Details: " + e.what()); - } catch (const std::exception& e) { - if (logger) { - logger->error("Cannot read plugin file \"{}\". Details: {}", - pluginPath.u8string(), - e.what()); - } - throw std::runtime_error("Cannot read \"" + pluginPath.u8string() + - "\". Details: " + e.what()); - } -} - -void Plugin::ResolveRecordIds(Vec_PluginMetadata* pluginsMetadata) const { - if (esPlugin == nullptr) { - return; - } - - auto ret = esp_plugin_resolve_record_ids(esPlugin.get(), pluginsMetadata); - HandleEspluginError(ret, "resolve the record IDs of \"{}\"", name_); -} - -std::string Plugin::GetName() const { return name_; } - -std::optional Plugin::GetHeaderVersion() const { - if (esPlugin == nullptr) { - return std::nullopt; - } - - float version = 0.0f; - - const auto ret = esp_plugin_header_version(esPlugin.get(), &version); - HandleEspluginError(ret, "get the header version of \"{}\"", name_); - - if (std::isnan(version)) { - return std::nullopt; - } - - return version; -} - -std::optional Plugin::GetVersion() const { return version_; } - -std::vector Plugin::GetMasters() const { - if (esPlugin == nullptr) { - return {}; - } - - char** masters = nullptr; - size_t numMasters = 0; - const auto ret = esp_plugin_masters(esPlugin.get(), &masters, &numMasters); - HandleEspluginError(ret, "get the masters of \"{}\"", name_); - - // NOLINTNEXTLINE(cppcoreguidelines-pro-bounds-pointer-arithmetic) - std::vector mastersVec(masters, masters + numMasters); - esp_string_array_free(masters, numMasters); - - return mastersVec; -} - -std::vector Plugin::GetBashTags() const { return tags_; } - -std::optional Plugin::GetCRC() const { return crc_; } - -bool Plugin::IsMaster() const { - if (ignoreMasterFlag_ || esPlugin == nullptr) { - return false; - } - - bool isMaster = false; - const auto ret = esp_plugin_is_master(esPlugin.get(), &isMaster); - HandleEspluginError(ret, "check if \"{}\" is a master", name_); - - return isMaster; -} - -bool Plugin::IsLightPlugin() const { - if (esPlugin == nullptr) { - return false; - } - - bool isLightPlugin = false; - const auto ret = esp_plugin_is_light_plugin(esPlugin.get(), &isLightPlugin); - HandleEspluginError(ret, "check if \"{}\" is a light plugin", name_); - - return isLightPlugin; -} - -bool Plugin::IsMediumPlugin() const { - if (esPlugin == nullptr) { - return false; - } - - bool isMediumPlugin = false; - const auto ret = esp_plugin_is_medium_plugin(esPlugin.get(), &isMediumPlugin); - HandleEspluginError(ret, "check if \"{}\" is a medium plugin", name_); - - return isMediumPlugin; -} - -bool Plugin::IsUpdatePlugin() const { - if (esPlugin == nullptr) { - return false; - } - - bool isUpdatePlugin = false; - const auto ret = esp_plugin_is_update_plugin(esPlugin.get(), &isUpdatePlugin); - HandleEspluginError(ret, "check if \"{}\" is an update plugin", name_); - - return isUpdatePlugin; -} - -bool Plugin::IsBlueprintPlugin() const { - if (esPlugin == nullptr) { - return false; - } - - bool isBlueprintPlugin = false; - const auto ret = - esp_plugin_is_blueprint_plugin(esPlugin.get(), &isBlueprintPlugin); - HandleEspluginError(ret, "check if \"{}\" is a blueprint plugin", name_); - - return isBlueprintPlugin; -} - -bool Plugin::IsValidAsLightPlugin() const { - if (esPlugin == nullptr) { - return false; - } - - bool isValid = false; - const auto ret = - esp_plugin_is_valid_as_light_plugin(esPlugin.get(), &isValid); - HandleEspluginError(ret, "check if \"{}\" is valid as a light plugin", name_); - - return isValid; -} - -bool Plugin::IsValidAsMediumPlugin() const { - if (esPlugin == nullptr) { - return false; - } - - bool isValid = false; - const auto ret = - esp_plugin_is_valid_as_medium_plugin(esPlugin.get(), &isValid); - HandleEspluginError( - ret, "check if \"{}\" is valid as a medium plugin", name_); - - return isValid; -} - -bool Plugin::IsValidAsUpdatePlugin() const { - if (esPlugin == nullptr) { - return false; - } - - bool isValid = false; - const auto ret = - esp_plugin_is_valid_as_update_plugin(esPlugin.get(), &isValid); - HandleEspluginError( - ret, "check if \"{}\" is valid as an update plugin", name_); - - return isValid; -} - -bool Plugin::IsEmpty() const { return isEmpty_; } - -bool Plugin::LoadsArchive() const { return !archivePaths_.empty(); } - -bool Plugin::DoRecordsOverlap(const PluginInterface& plugin) const { - if (esPlugin == nullptr) { - return false; - } - - try { - auto& otherPlugin = dynamic_cast(plugin); - - if (otherPlugin.esPlugin == nullptr) { - return false; - } - - bool doPluginsOverlap = false; - const auto ret = esp_plugin_do_records_overlap( - esPlugin.get(), otherPlugin.esPlugin.get(), &doPluginsOverlap); - HandleEspluginError(ret, [&]() { - return fmt::format( - "check if \"{}\" and \"{}\" overlap", name_, otherPlugin.GetName()); - }); - - return doPluginsOverlap; - } catch (std::bad_cast&) { - auto logger = getLogger(); - if (logger) { - logger->error( - "Tried to check if records overlapped with a non-Plugin " - "implementation of PluginInterface."); - } - } - - return false; -} - -size_t Plugin::GetOverrideRecordCount() const { - if (esPlugin == nullptr) { - return 0; - } - - size_t overrideRecordCount; - const auto ret = - esp_plugin_count_override_records(esPlugin.get(), &overrideRecordCount); - HandleEspluginError(ret, "count override records in \"{}\"", name_); - - return overrideRecordCount; -} - -size_t Plugin::GetAssetCount() const { - return std::accumulate( - archiveAssets_.begin(), - archiveAssets_.end(), - size_t{0}, - [](const size_t& a, const auto& b) { return a + b.second.size(); }); -} - -bool Plugin::DoAssetsOverlap(const PluginSortingInterface& plugin) const { - if (archiveAssets_.empty()) { - return false; - } - - try { - const auto& otherPlugin = dynamic_cast(plugin); - - return DoAssetsIntersect(archiveAssets_, otherPlugin.archiveAssets_); - } catch (std::bad_cast&) { - auto logger = getLogger(); - if (logger) { - logger->error( - "Tried to check how many FormIDs overlapped with a non-Plugin " - "implementation of PluginSortingInterface."); - } - throw std::invalid_argument( - "Tried to check how many FormIDs overlapped with a non-Plugin " - "implementation of PluginSortingInterface."); - } -} - -bool Plugin::IsValid(const GameType gameType, - const std::filesystem::path& pluginPath) { - // Check that the file has a valid extension. - if (hasPluginFileExtension(pluginPath.filename().u8string(), gameType)) { - if (gameType == GameType::openmw && - pluginPath.extension() == ".omwscripts") { - return true; - } - - bool isValid = false; - auto returnCode = esp_plugin_is_valid(GetEspluginGameId(gameType), - pluginPath.u8string().c_str(), - true, - &isValid); - - if (returnCode == ESP_OK && isValid) { - return true; - } - } - - auto logger = getLogger(); - if (logger) { - logger->debug("The file \"{}\" is not a valid plugin.", - pluginPath.u8string()); - } - - return false; -} - -void Plugin::Load(const std::filesystem::path& path, - GameType gameType, - bool headerOnly) { - ::Plugin* plugin = nullptr; - auto ret = esp_plugin_new( - &plugin, GetEspluginGameId(gameType), path.u8string().c_str()); - HandleEspluginError(ret, [&]() { - return fmt::format("load plugin \"{}\"", path.u8string()); - }); - - esPlugin = std::unique_ptr<::Plugin, decltype(&esp_plugin_free)>( - plugin, esp_plugin_free); - - ret = esp_plugin_parse(esPlugin.get(), headerOnly); - HandleEspluginError(ret, [&]() { - return fmt::format("parse plugin \"{}\"", path.u8string()); - }); -} - -std::string Plugin::GetDescription() const { - if (esPlugin == nullptr) { - return ""; - } - - char* description = nullptr; - const auto ret = esp_plugin_description(esPlugin.get(), &description); - HandleEspluginError(ret, "read the description of \"{}\"", name_); - - if (description == nullptr) { - return ""; - } - - std::string descriptionStr = description; - esp_string_free(description); - - return descriptionStr; -} - -std::unique_ptr -Plugin::GetPluginsMetadata(const std::vector& plugins) { - if (plugins.empty()) { - return std::unique_ptr( - nullptr, esp_plugins_metadata_free); - } - - std::vector esPlugins; - esPlugins.reserve(plugins.size()); - for (const auto& plugin : plugins) { - const auto esPlugin = plugin->esPlugin.get(); - if (esPlugin != nullptr) { - esPlugins.push_back(plugin->esPlugin.get()); - } - } - - Vec_PluginMetadata* pluginsMetadata = nullptr; - const auto ret = esp_get_plugins_metadata( - esPlugins.data(), esPlugins.size(), &pluginsMetadata); - HandleEspluginError(ret, [&]() { - return fmt::format("get metadata for {} plugins", plugins.size()); - }); - - return std::unique_ptr( - pluginsMetadata, esp_plugins_metadata_free); -} - -std::string GetArchiveFileExtension(const GameType gameType) { - if (gameType == GameType::fo4 || gameType == GameType::fo4vr || - gameType == GameType::starfield) - return std::string(BA2_FILE_EXTENSION); - else - return std::string(BSA_FILE_EXTENSION); -} - -unsigned int Plugin::GetEspluginGameId(GameType gameType) { - switch (gameType) { - case GameType::tes3: - case GameType::openmw: - return ESP_GAME_MORROWIND; - case GameType::tes4: - case GameType::oblivionRemastered: - return ESP_GAME_OBLIVION; - case GameType::tes5: - return ESP_GAME_SKYRIM; - case GameType::tes5se: - case GameType::tes5vr: - return ESP_GAME_SKYRIMSE; - case GameType::fo3: - return ESP_GAME_FALLOUT3; - case GameType::fonv: - return ESP_GAME_FALLOUTNV; - case GameType::fo4: - case GameType::fo4vr: - return ESP_GAME_FALLOUT4; - case GameType::starfield: - return ESP_GAME_STARFIELD; - default: - throw std::logic_error("Unrecognised game type"); - } -} - -bool hasPluginFileExtension(std::string_view filename, GameType gameType) { - if (gameType != GameType::openmw && - boost::iends_with(filename, GHOST_FILE_EXTENSION)) { - filename = - filename.substr(0, filename.length() - GHOST_FILE_EXTENSION.length()); - } - - if (boost::iends_with(filename, ".esp") || - boost::iends_with(filename, ".esm")) { - return true; - } - - if (gameType == GameType::openmw && - (boost::iends_with(filename, ".omwaddon") || - boost::iends_with(filename, ".omwgame") || - boost::iends_with(filename, ".omwscripts"))) { - return true; - } - - if ((gameType == GameType::fo4 || gameType == GameType::fo4vr || - gameType == GameType::tes5se || gameType == GameType::tes5vr || - gameType == GameType::starfield) && - boost::iends_with(filename, ".esl")) { - return true; - } - - return false; -} - -bool equivalent(const std::filesystem::path& path1, - const std::filesystem::path& path2) { - // If the paths are identical, they've got to be equivalent, - // it doesn't matter if the paths exist or not. - if (path1 == path2) { - return true; - } - // If the paths are not identical, the filesystem might be case-insensitive - // so check with the filesystem. - try { - return std::filesystem::equivalent(path1, path2); - } catch (const std::filesystem::filesystem_error&) { - // One of the paths checked for equivalence doesn't exist, - // so they can't be equivalent. - return false; - } catch (const std::system_error&) { - // This can be thrown if one or both of the paths contains a character - // that can't be represented in Windows' multi-byte code page (e.g. - // Windows-1252), even though Unicode paths shouldn't be a problem, - // and throwing system_error is undocumented. Seems like a bug in MSVC's - // implementation. - return false; - } -} -} diff --git a/src/api/plugin.h b/src/api/plugin.h deleted file mode 100644 index 522e2269..00000000 --- a/src/api/plugin.h +++ /dev/null @@ -1,126 +0,0 @@ -/* LOOT - - A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and - Fallout: New Vegas. - - Copyright (C) 2012-2016 WrinklyNinja - - This file is part of LOOT. - - LOOT is free software: you can redistribute - it and/or modify it under the terms of the GNU General Public License - as published by the Free Software Foundation, either version 3 of - the License, or (at your option) any later version. - - LOOT is distributed in the hope that it will - be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with LOOT. If not, see - . - */ -#ifndef LOOT_API_PLUGIN -#define LOOT_API_PLUGIN - -#include -#include -#include -#include -#include -#include -#include -#include - -#include "api/game/load_order_handler.h" -#include "loot/enum/game_type.h" -#include "loot/metadata/plugin_metadata.h" -#include "loot/plugin_interface.h" - -namespace loot { -class GameCache; - -// An interface containing member functions that are used when sorting plugins. -class PluginSortingInterface : public PluginInterface { -public: - virtual size_t GetOverrideRecordCount() const = 0; - - virtual size_t GetAssetCount() const = 0; - virtual bool DoAssetsOverlap(const PluginSortingInterface& plugin) const = 0; -}; - -class Plugin final : public PluginSortingInterface { -public: - explicit Plugin(const GameType gameType, - const GameCache& gameCache, - const std::filesystem::path& pluginPath, - const bool headerOnly); - - void ResolveRecordIds(Vec_PluginMetadata* pluginsMetadata) const; - - std::string GetName() const override; - std::optional GetHeaderVersion() const override; - std::optional GetVersion() const override; - std::vector GetMasters() const override; - std::vector GetBashTags() const override; - std::optional GetCRC() const override; - - bool IsMaster() const override; - - bool IsLightPlugin() const override; - bool IsMediumPlugin() const override; - bool IsUpdatePlugin() const override; - bool IsBlueprintPlugin() const override; - - bool IsValidAsLightPlugin() const override; - bool IsValidAsMediumPlugin() const override; - bool IsValidAsUpdatePlugin() const override; - bool IsEmpty() const override; - bool LoadsArchive() const override; - bool DoRecordsOverlap(const PluginInterface& plugin) const override; - - // Load ordering functions. - size_t GetOverrideRecordCount() const override; - - size_t GetAssetCount() const override; - bool DoAssetsOverlap(const PluginSortingInterface& plugin) const override; - - // Validity checks. - static bool IsValid(const GameType gameType, - const std::filesystem::path& pluginPath); - - static std::unique_ptr - GetPluginsMetadata(const std::vector& plugins); - -private: - void Load(const std::filesystem::path& path, - GameType gameType, - bool headerOnly); - std::string GetDescription() const; - - static unsigned int GetEspluginGameId(GameType gameType); - - std::string name_; - std::unique_ptr<::Plugin, decltype(&esp_plugin_free)> esPlugin; - bool ignoreMasterFlag_{false}; - bool isEmpty_{false}; // Does the plugin contain any records other than the - // TES4 - // header? - std::optional version_; // Obtained from description field. - std::optional crc_; - std::vector tags_; - std::vector archivePaths_; - std::map> archiveAssets_; -}; - -std::string GetArchiveFileExtension(const GameType gameType); - -bool hasPluginFileExtension(std::string_view filename, GameType gameType); - -bool equivalent(const std::filesystem::path& path1, - const std::filesystem::path& path2); -} - -#endif diff --git a/src/api/sorting/group_sort.cpp b/src/api/sorting/group_sort.cpp deleted file mode 100644 index eb4f58bc..00000000 --- a/src/api/sorting/group_sort.cpp +++ /dev/null @@ -1,252 +0,0 @@ -/* LOOT - - A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and - Fallout: New Vegas. - - Copyright (C) 2018 WrinklyNinja - - This file is part of LOOT. - - LOOT is free software: you can redistribute - it and/or modify it under the terms of the GNU General Public License - as published by the Free Software Foundation, either version 3 of - the License, or (at your option) any later version. - - LOOT is distributed in the hope that it will - be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with LOOT. If not, see - . - */ - -#include "group_sort.h" - -#include -#include -#include -#include - -#include "api/helpers/logging.h" -#include "loot/exception/cyclic_interaction_error.h" -#include "loot/exception/undefined_group_error.h" - -namespace loot { -typedef boost::graph_traits::vertex_descriptor vertex_t; -typedef boost::graph_traits::edge_descriptor edge_t; -typedef boost::associative_property_map> edge_map_t; - -class CycleDetector : public boost::dfs_visitor<> { -public: - void tree_edge(edge_t edge, const GroupGraph& graph) { - auto source = boost::source(edge, graph); - - auto vertex = Vertex(graph[source], graph[edge]); - - trail.push_back(vertex); - } - - void back_edge(edge_t edge, const GroupGraph& graph) { - auto source = boost::source(edge, graph); - const auto target = boost::target(edge, graph); - const auto targetGroupName = graph[target]; - - auto vertex = Vertex(graph[source], graph[edge]); - trail.push_back(vertex); - - auto it = find_if(begin(trail), end(trail), [&](const Vertex& v) { - return v.GetName() == graph[target]; - }); - - if (it == trail.end()) { - throw std::logic_error( - "The target of a back edge cannot be found in the current edge path. " - "The target group is \"" + - targetGroupName + "\""); - } - - throw CyclicInteractionError(std::vector(it, trail.end())); - } - - void finish_vertex(vertex_t, const GroupGraph&) { - if (!trail.empty()) { - trail.pop_back(); - } - } - -private: - std::vector trail; -}; - -std::vector SortByName(const std::vector& groups) { - auto copy = groups; - std::sort(copy.begin(), copy.end(), [](const auto& lhs, const auto& rhs) { - return lhs.GetName() < rhs.GetName(); - }); - - return copy; -} - -std::vector SortNames(std::vector&& groupNames) { - std::sort(groupNames.begin(), groupNames.end()); - - return groupNames; -} - -GroupGraph BuildGroupGraph(const std::vector& masterlistGroups, - const std::vector& userGroups) { - const auto logger = getLogger(); - - GroupGraph graph; - std::unordered_map groupVertices; - - const auto addGroups = [&](const std::vector& groups, - const EdgeType edgeType) { - for (const auto& group : groups) { - const auto groupName = group.GetName(); - - if (groupVertices.find(groupName) == groupVertices.end()) { - const auto vertex = boost::add_vertex(groupName, graph); - groupVertices.emplace(groupName, vertex); - } - } - - for (const auto& group : groups) { - const auto groupName = group.GetName(); - - if (logger) { - logger->trace("Group \"{}\" directly loads after groups \"{}\"", - groupName, - boost::join(group.GetAfterGroups(), ", ")); - } - - const auto vertex = groupVertices.at(groupName); - - // Similar to groups, after groups are sorted by name so that the order - // of a group vertex's in-edges is independent of the order they're - // listed in the group definition. The order of in-edges affects the - // result of calling GetGroupsPath(). - for (const auto& otherGroupName : SortNames(group.GetAfterGroups())) { - const auto otherVertex = groupVertices.find(otherGroupName); - if (otherVertex == groupVertices.end()) { - throw UndefinedGroupError(otherGroupName); - } - - boost::add_edge(otherVertex->second, vertex, edgeType, graph); - } - } - }; - - // Sort groups by name so that they get added to the graph in an order that - // is consistent and independent of the order in which they are defined. - // This is important because the order in which vertices are created affects - // the order in which edges are created and so can affect the outcome of - // sorting. - // It would be surprising if swapping the order in which two groups were - // defined in e.g. the masterlist had an impact on LOOT's sorting behaviour, - // but if a group's name changes that's effectively deleting one group and - // creating another. It would also be surprising that the groups' names can - // have an effect, but the effect is at least constant for a given set of - // groups. - // It might also be surprising that whether a group is defined in the - // masterlist or userlist can have an effect, but it's consistent with the - // handling of edges for all other masterlist and userlist metadata. - if (logger) { - logger->trace("Adding masterlist groups to groups graph..."); - } - addGroups(SortByName(masterlistGroups), EdgeType::masterlistLoadAfter); - - if (logger) { - logger->trace("Adding user groups to groups graph..."); - } - addGroups(SortByName(userGroups), EdgeType::userLoadAfter); - - if (logger) { - logger->trace("Checking for cycles in the group graph"); - } - boost::depth_first_search(graph, boost::visitor(CycleDetector())); - - return graph; -} - -vertex_t GetVertexByName(const GroupGraph& graph, std::string_view name) { - for (const auto& vertex : - boost::make_iterator_range(boost::vertices(graph))) { - if (graph[vertex] == name) { - return vertex; - } - } - - const auto logger = getLogger(); - if (logger) { - logger->error("Can't find group with name \"{}\"", name); - } - - throw std::invalid_argument("Can't find group with name \"" + std::string(name) + "\""); -} - -std::vector GetGroupsPath(const GroupGraph& graph, - std::string_view fromGroupName, - std::string_view toGroupName) { - auto logger = getLogger(); - - auto fromVertex = GetVertexByName(graph, fromGroupName); - auto toVertex = GetVertexByName(graph, toGroupName); - - std::map weightMap; - for (const auto& edge : boost::make_iterator_range(boost::edges(graph))) { - if (graph[edge] == EdgeType::userLoadAfter) { - // Magnitude is an arbitrarily large number. - static constexpr int USER_LOAD_AFTER_EDGE_WEIGHT = -1000000; - weightMap[edge] = USER_LOAD_AFTER_EDGE_WEIGHT; - } else { - weightMap[edge] = 1; - } - } - - std::vector predecessors(boost::num_vertices(graph)); - std::vector distance(predecessors.size(), - (std::numeric_limits::max)()); - distance.at(fromVertex) = 0; - - bellman_ford_shortest_paths( - graph, - boost::weight_map(edge_map_t(weightMap)) - .predecessor_map(boost::make_iterator_property_map( - predecessors.begin(), get(boost::vertex_index, graph))) - .distance_map(distance.data()) - .root_vertex(fromVertex)); - - std::vector path{Vertex(graph[toVertex])}; - vertex_t currentVertex = toVertex; - while (currentVertex != fromVertex) { - const auto precedingVertex = predecessors.at(currentVertex); - if (precedingVertex == currentVertex) { - if (logger) { - logger->error( - "Unreachable vertex {} encountered while looking for vertex {}", - graph[currentVertex], - graph[toVertex]); - } - return std::vector(); - } - - const auto pair = boost::edge(precedingVertex, currentVertex, graph); - if (!pair.second) { - throw std::runtime_error("Unexpectedly couldn't find edge between \"" + - graph[precedingVertex] + "\" and \"" + - graph[currentVertex] + "\""); - } - const auto vertex = Vertex(graph[precedingVertex], graph[pair.first]); - path.push_back(vertex); - - currentVertex = precedingVertex; - } - - std::reverse(path.begin(), path.end()); - - return path; -} - } diff --git a/src/api/sorting/group_sort.h b/src/api/sorting/group_sort.h deleted file mode 100644 index 776251ce..00000000 --- a/src/api/sorting/group_sort.h +++ /dev/null @@ -1,52 +0,0 @@ -/* LOOT - - A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and - Fallout: New Vegas. - - Copyright (C) 2018 WrinklyNinja - - This file is part of LOOT. - - LOOT is free software: you can redistribute - it and/or modify it under the terms of the GNU General Public License - as published by the Free Software Foundation, either version 3 of - the License, or (at your option) any later version. - - LOOT is distributed in the hope that it will - be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with LOOT. If not, see - . - */ - -#ifndef LOOT_API_SORTING_GROUP_SORT -#define LOOT_API_SORTING_GROUP_SORT - -#include -#include -#include -#include -#include - -#include "loot/metadata/group.h" -#include "loot/vertex.h" - -namespace loot { -typedef boost::adjacency_list - GroupGraph; - -GroupGraph BuildGroupGraph(const std::vector& masterlistGroups, - const std::vector& userGroups); - -std::vector GetGroupsPath(const GroupGraph& groupGraph, - std::string_view fromGroupName, - std::string_view toGroupName); -} -#endif diff --git a/src/api/sorting/plugin_graph.cpp b/src/api/sorting/plugin_graph.cpp deleted file mode 100644 index 97934436..00000000 --- a/src/api/sorting/plugin_graph.cpp +++ /dev/null @@ -1,1373 +0,0 @@ -/* LOOT - - A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and - Fallout: New Vegas. - - Copyright (C) 2012-2016 WrinklyNinja - - This file is part of LOOT. - - LOOT is free software: you can redistribute - it and/or modify it under the terms of the GNU General Public License - as published by the Free Software Foundation, either version 3 of - the License, or (at your option) any later version. - - LOOT is distributed in the hope that it will - be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with LOOT. If not, see - . - */ - -#include "plugin_graph.h" - -#include - -#include -#include -#include -#include -#include -#include -#include -#include - -#include "api/helpers/logging.h" -#include "api/helpers/text.h" -#include "api/sorting/group_sort.h" -#include "loot/exception/cyclic_interaction_error.h" -#include "loot/exception/undefined_group_error.h" - -namespace { -using loot::GroupGraph; -typedef boost::graph_traits::vertex_descriptor GroupGraphVertex; - -bool IsRootVertex(const GroupGraphVertex& vertex, const GroupGraph& graph) { - return boost::in_degree(vertex, graph) == 0; -} - -class GroupsPathLengthVisitor : public boost::dfs_visitor<> { -public: - explicit GroupsPathLengthVisitor(size_t& maxPathLength) : - maxPathLength_(&maxPathLength) {} - - typedef boost::graph_traits::edge_descriptor GroupGraphEdge; - - void discover_vertex(GroupGraphVertex, const GroupGraph&) { - currentPathLength_ += 1; - if (currentPathLength_ > *maxPathLength_) { - *maxPathLength_ = currentPathLength_; - } - } - void finish_vertex(GroupGraphVertex, const GroupGraph&) { - currentPathLength_ -= 1; - } - -private: - size_t currentPathLength_{0}; - size_t* maxPathLength_{nullptr}; -}; - -void DepthFirstVisit( - const GroupGraph& graph, - const boost::graph_traits::vertex_descriptor& startingVertex, - GroupsPathLengthVisitor& visitor) { - std::vector colorVec(boost::num_vertices(graph)); - const auto colorMap = boost::make_iterator_property_map( - colorVec.begin(), boost::get(boost::vertex_index, graph), colorVec.at(0)); - - boost::depth_first_visit(graph, startingVertex, visitor, colorMap); -} - -// Sort the group vertices so that root vertices come first, in order of -// decreasing path length, but otherwise preserving the existing -// (lexicographical) ordering. -std::vector GetSortedGroupVertices( - const GroupGraph& groupGraph) { - const auto [gvit, gvitend] = boost::vertices(groupGraph); - std::vector groupVertices{gvit, gvitend}; - - // Calculate the max path lengths for root vertices. - std::unordered_map maxPathLengths; - for (const auto& groupVertex : groupVertices) { - if (IsRootVertex(groupVertex, groupGraph)) { - size_t maxPathLength = 0; - GroupsPathLengthVisitor visitor(maxPathLength); - - DepthFirstVisit(groupGraph, groupVertex, visitor); - - maxPathLengths.emplace(groupVertex, maxPathLength); - } - } - - // Now sort the group vertices. - std::stable_sort( - groupVertices.begin(), - groupVertices.end(), - [&](const GroupGraphVertex& lhs, const GroupGraphVertex& rhs) { - return IsRootVertex(lhs, groupGraph) && - (!IsRootVertex(rhs, groupGraph) || - (maxPathLengths[lhs] > maxPathLengths[rhs])); - }); - - return groupVertices; -} -} - -namespace loot { -typedef boost::graph_traits::edge_descriptor edge_t; -typedef boost::graph_traits::edge_iterator edge_it; - -class CycleDetector : public boost::dfs_visitor<> { -public: - void tree_edge(edge_t edge, const RawPluginGraph& graph) { - const auto source = boost::source(edge, graph); - - const auto vertex = Vertex(graph[source].GetName(), graph[edge]); - - trail.push_back(vertex); - } - - void back_edge(edge_t edge, const RawPluginGraph& graph) { - const auto source = boost::source(edge, graph); - const auto target = boost::target(edge, graph); - - const auto vertex = Vertex(graph[source].GetName(), graph[edge]); - trail.push_back(vertex); - - const auto it = find_if(begin(trail), end(trail), [&](const Vertex& v) { - return v.GetName() == graph[target].GetName(); - }); - - if (it == trail.end()) { - throw std::logic_error( - "The target of a back edge cannot be found in the current edge path"); - } - - throw CyclicInteractionError(std::vector(it, trail.end())); - } - - void finish_vertex(vertex_t, const RawPluginGraph&) { - if (!trail.empty()) { - trail.pop_back(); - } - } - -private: - std::vector trail; -}; - -std::unordered_map> GetGroupsPlugins( - const PluginGraph& graph) { - std::unordered_map> groupsPlugins; - - for (const vertex_t& vertex : - boost::make_iterator_range(graph.GetVertices())) { - const auto groupName = graph.GetPlugin(vertex).GetGroup(); - - const auto groupIt = groupsPlugins.find(groupName); - - if (groupIt == groupsPlugins.end()) { - groupsPlugins.emplace(groupName, std::vector({vertex})); - } else { - groupIt->second.push_back(vertex); - } - } - - const auto logger = getLogger(); - if (logger && logger->should_log(spdlog::level::debug)) { - logger->debug("Found the following plugins in groups:"); - for (const auto& [key, value] : groupsPlugins) { - std::vector pluginNames; - for (const auto vertex : value) { - pluginNames.push_back("\"" + graph.GetPlugin(vertex).GetName() + "\""); - } - logger->debug("\t{}: {}", key, fmt::join(pluginNames, ", ")); - } - } - - return groupsPlugins; -} - -boost::graph_traits::vertex_descriptor GetDefaultVertex( - const GroupGraph& graph) { - for (const auto& vertex : - boost::make_iterator_range(boost::vertices(graph))) { - if (graph[vertex] == Group::DEFAULT_NAME) { - return vertex; - } - } - - throw std::logic_error("Could not find default group in group graph"); -} - -class GroupsPathVisitor : public boost::dfs_visitor<> { -public: - typedef boost::graph_traits::edge_descriptor GroupGraphEdge; - - explicit GroupsPathVisitor( - PluginGraph& pluginGraph, - std::unordered_set& finishedVertices, - const std::unordered_map>& - groupsPlugins) : - pluginGraph_(&pluginGraph), - groupsPlugins_(&groupsPlugins), - finishedVertices_(&finishedVertices), - logger_(getLogger()) {} - - explicit GroupsPathVisitor( - PluginGraph& pluginGraph, - std::unordered_set& finishedVertices, - const std::unordered_map>& - groupsPlugins, - const GroupGraphVertex vertexToIgnoreAsSource) : - pluginGraph_(&pluginGraph), - groupsPlugins_(&groupsPlugins), - finishedVertices_(&finishedVertices), - vertexToIgnoreAsSource_(vertexToIgnoreAsSource), - logger_(getLogger()) {} - - void tree_edge(GroupGraphEdge edge, const GroupGraph& graph) { - const auto source = boost::source(edge, graph); - const auto target = boost::target(edge, graph); - - // Add the edge to the stack so that its providence can be taken into - // account when adding edges from this source group and previous groups' - // plugins. - // Also record the plugins in the edge's source group, unless the source - // group should be ignored (e.g. because the visitor has been configured - // to ignore the default group's plugins as sources). - edgeStack_.push_back(std::make_pair( - edge, - ShouldIgnoreSourceVertex(source) ? std::vector() - : FindPluginsInGroup(source, graph))); - - // Find the plugins in the target group. - const auto targetPlugins = FindPluginsInGroup(target, graph); - - // Add edges going from all the plugins in the groups in the path being - // currently walked, to the plugins in the current target group's plugins. - for (size_t i = 0; i < edgeStack_.size(); i += 1) { - AddPluginGraphEdges(i, targetPlugins, graph); - } - } - - void forward_or_cross_edge(GroupGraphEdge edge, const GroupGraph& graph) { - // Mark the source vertex and all edges in the current stack as - // unfinishable, because none of the plugins in in the path so far can have - // edges added to plugins past the target vertex. - for (const auto& edgeInPath : edgeStack_) { - MarkSourceAsUnfinishable(edgeInPath.first, graph); - } - - MarkSourceAsUnfinishable(edge, graph); - } - - void finish_vertex(GroupGraphVertex vertex, const GroupGraph& graph) { - // Now that this vertex's DFS-tree has been fully explored, mark it as - // finished so that it won't have edges added from its plugins again in a - // different DFS that uses the same finished vertices set. - if (vertex != vertexToIgnoreAsSource_ && - unfinishableVertices_.count(vertex) == 0) { - const auto inserted = finishedVertices_->insert(vertex).second; - if (inserted && logger_) { - logger_->debug("Recorded groups graph vertex \"{}\" as finished", - graph[vertex]); - } - } - - // Since this vertex has been fully explored, pop the edge stack to remove - // the edge that has this vertex as its target. - PopEdgeStack(); - } - -private: - bool PathToGroupInvolvesUserMetadata(const size_t sourceGroupEdgeStackIndex, - const GroupGraph& graph) const { - if (sourceGroupEdgeStackIndex >= edgeStack_.size()) { - throw std::logic_error("Given index is past the end of the path stack"); - } - - // Check if any of the edges in the current stack are user edges, going - // from the given edge index to the end of the stack. - const auto begin = std::next(edgeStack_.begin(), sourceGroupEdgeStackIndex); - - return std::any_of(begin, edgeStack_.end(), [&](const auto& entry) { - return graph[entry.first] == EdgeType::userLoadAfter; - }); - } - - std::vector FindPluginsInGroup(const GroupGraphVertex vertex, - const GroupGraph& graph) { - const auto targetPluginsIt = groupsPlugins_->find(graph[vertex]); - return targetPluginsIt == groupsPlugins_->end() ? std::vector() - : targetPluginsIt->second; - } - - void AddPluginGraphEdges(const size_t sourceGroupEdgeStackIndex, - const std::vector& toPluginVertices, - const GroupGraph& graph) { - const auto& fromPluginVertices = - edgeStack_[sourceGroupEdgeStackIndex].second; - - const auto groupPathInvolvesUserMetadata = - PathToGroupInvolvesUserMetadata(sourceGroupEdgeStackIndex, graph); - - for (const auto& pluginVertex : fromPluginVertices) { - AddPluginGraphEdges( - pluginVertex, toPluginVertices, groupPathInvolvesUserMetadata); - } - } - - void AddPluginGraphEdges(const vertex_t& fromPluginVertex, - const std::vector& toPluginVertices, - const bool groupPathInvolvesUserMetadata) { - if (toPluginVertices.empty()) { - return; - } - - const auto& fromPlugin = pluginGraph_->GetPlugin(fromPluginVertex); - - for (const auto& toVertex : toPluginVertices) { - const auto& toPlugin = pluginGraph_->GetPlugin(toVertex); - - if (pluginGraph_->IsPathCached(fromPluginVertex, toVertex)) { - continue; - } - - const auto involvesUserMetadata = groupPathInvolvesUserMetadata || - fromPlugin.IsGroupUserMetadata() || - toPlugin.IsGroupUserMetadata(); - - const auto edgeType = involvesUserMetadata ? EdgeType::userGroup - : EdgeType::masterlistGroup; - - if (!pluginGraph_->PathExists(toVertex, fromPluginVertex)) { - pluginGraph_->AddEdge(fromPluginVertex, toVertex, edgeType); - } else if (logger_) { - logger_->debug( - "Skipping a \"{}\" edge from \"{}\" to \"{}\" as it would " - "create a cycle.", - describeEdgeType(edgeType), - fromPlugin.GetName(), - toPlugin.GetName()); - } - } - } - - bool ShouldIgnoreSourceVertex(const GroupGraphVertex& groupVertex) { - return groupVertex == vertexToIgnoreAsSource_ || - finishedVertices_->count(groupVertex) == 1; - } - - void PopEdgeStack() { - if (!edgeStack_.empty()) { - edgeStack_.pop_back(); - } - } - - void MarkSourceAsUnfinishable(const GroupGraphEdge edge, - const GroupGraph& graph) { - const auto source = boost::source(edge, graph); - const auto inserted = unfinishableVertices_.insert(source).second; - - if (logger_ && inserted) { - const auto target = boost::source(edge, graph); - - logger_->debug( - "Found groups graph forward or cross \"{}\" edge going from \"{}\" " - "to \"{}\", treating the source as unfinishable", - describeEdgeType(graph[edge]), - graph[source], - graph[target]); - } - } - - PluginGraph* pluginGraph_{nullptr}; - const std::unordered_map>* groupsPlugins_{ - nullptr}; - std::unordered_set* finishedVertices_{nullptr}; - std::optional vertexToIgnoreAsSource_; - std::shared_ptr logger_; - - // This represents the path to the current target vertex in the group graph, - // along with the plugins in each edge's source vertex (group). - std::vector>> edgeStack_; - - std::unordered_set unfinishableVertices_; -}; - -void DepthFirstVisit( - const GroupGraph& graph, - const boost::graph_traits::vertex_descriptor& startingVertex, - GroupsPathVisitor& visitor) { - const auto logger = getLogger(); - if (logger) { - logger->trace( - "Starting depth-first search of the groups graph starting from \"{}\"", - graph[startingVertex]); - } - - std::vector colorVec(boost::num_vertices(graph)); - const auto colorMap = boost::make_iterator_property_map( - colorVec.begin(), boost::get(boost::vertex_index, graph), colorVec.at(0)); - - boost::depth_first_visit(graph, startingVertex, visitor, colorMap); -} - -std::string describeEdgeType(EdgeType edgeType) { - switch (edgeType) { - case EdgeType::hardcoded: - return "Hardcoded"; - case EdgeType::masterFlag: - return "Master Flag"; - case EdgeType::master: - return "Master"; - case EdgeType::masterlistRequirement: - return "Masterlist Requirement"; - case EdgeType::userRequirement: - return "User Requirement"; - case EdgeType::masterlistLoadAfter: - return "Masterlist Load After"; - case EdgeType::userLoadAfter: - return "User Load After"; - case EdgeType::masterlistGroup: - return "Masterlist Group"; - case EdgeType::userGroup: - return "User Group"; - case EdgeType::recordOverlap: - return "Record Overlap"; - case EdgeType::assetOverlap: - return "Asset Overlap"; - case EdgeType::tieBreak: - return "Tie Break"; - default: - return "Unknown"; - } -} - -std::string PathToString(const RawPluginGraph& graph, - const std::vector& path) { - std::string pathString; - for (const auto& vertex : path) { - pathString += graph[vertex].GetName() + ", "; - } - - return pathString.substr(0, pathString.length() - 2); -} - -class BidirVisitor { -public: - BidirVisitor() = default; - BidirVisitor(const BidirVisitor&) = delete; - BidirVisitor(BidirVisitor&&) = delete; - virtual ~BidirVisitor() = default; - - BidirVisitor& operator=(const BidirVisitor&) = delete; - BidirVisitor& operator=(BidirVisitor&&) = delete; - - virtual void VisitForwardVertex(const vertex_t& sourceVertex, - const vertex_t& targetVertex) = 0; - - virtual void VisitReverseVertex(const vertex_t& sourceVertex, - const vertex_t& targetVertex) = 0; - - virtual void VisitIntersectionVertex(const vertex_t& intersectionVertex) = 0; -}; - -class PathCacher : public BidirVisitor { -public: - explicit PathCacher(PathsCache& pathsCache, - const vertex_t& fromVertex, - const vertex_t& toVertex) : - pathsCache_(&pathsCache), fromVertex_(fromVertex), toVertex_(toVertex) {} - - void VisitForwardVertex(const vertex_t&, - const vertex_t& targetVertex) override { - pathsCache_->CachePath(fromVertex_, targetVertex); - } - - void VisitReverseVertex(const vertex_t& sourceVertex, - const vertex_t&) override { - pathsCache_->CachePath(sourceVertex, toVertex_); - } - - void VisitIntersectionVertex(const vertex_t&) override {} - -protected: - vertex_t GetFromVertex() const { return fromVertex_; } - - vertex_t GetToVertex() const { return toVertex_; } - -private: - PathsCache* pathsCache_{nullptr}; - vertex_t fromVertex_{0}; - vertex_t toVertex_{0}; -}; - -class PathFinder : public PathCacher { -public: - explicit PathFinder(const RawPluginGraph& graph, - PathsCache& pathsCache, - const vertex_t& fromVertex, - const vertex_t& toVertex) : - PathCacher(pathsCache, fromVertex, toVertex), graph_(&graph) {} - - void VisitForwardVertex(const vertex_t& sourceVertex, - const vertex_t& targetVertex) override { - PathCacher::VisitForwardVertex(sourceVertex, targetVertex); - - forwardParents.insert_or_assign(targetVertex, sourceVertex); - } - - void VisitReverseVertex(const vertex_t& sourceVertex, - const vertex_t& targetVertex) override { - PathCacher::VisitReverseVertex(sourceVertex, targetVertex); - - reverseChildren.insert_or_assign(sourceVertex, targetVertex); - } - - void VisitIntersectionVertex(const vertex_t& intersectionVertex) override { - intersectionVertex_ = intersectionVertex; - } - - std::optional> GetPath() { - if (!intersectionVertex_.has_value()) { - return std::nullopt; - } - - std::vector path({intersectionVertex_.value()}); - auto currentVertex = intersectionVertex_.value(); - - const auto logger = getLogger(); - - while (currentVertex != GetFromVertex()) { - const auto it = forwardParents.find(currentVertex); - if (it == forwardParents.end()) { - const auto pluginName = (*graph_)[currentVertex].GetName(); - if (logger) { - logger->error("Could not find parent vertex of {}. Path so far is {}", - pluginName, - PathToString(*graph_, path)); - } - throw std::runtime_error( - "Unexpectedly could not find parent vertex of " + pluginName); - } - - path.push_back(it->second); - - currentVertex = it->second; - } - - // The path current runs backwards, so reverse it. - std::reverse(path.begin(), path.end()); - - currentVertex = intersectionVertex_.value(); - - while (currentVertex != GetToVertex()) { - const auto it = reverseChildren.find(currentVertex); - if (it == reverseChildren.end()) { - const auto pluginName = (*graph_)[currentVertex].GetName(); - if (logger) { - logger->error("Could not find child vertex of {}. Path so far is {}", - pluginName, - PathToString(*graph_, path)); - } - throw std::runtime_error( - "Unexpectedly could not find child vertex of " + pluginName); - } - - path.push_back(it->second); - - currentVertex = it->second; - } - - return path; - } - -private: - const RawPluginGraph* graph_{nullptr}; - - std::unordered_map forwardParents; - std::unordered_map reverseChildren; - std::optional intersectionVertex_; -}; - -bool FindPath(RawPluginGraph& graph, - const vertex_t& fromVertex, - const vertex_t& toVertex, - BidirVisitor& visitor) { - std::queue> forwardQueue; - std::queue> reverseQueue; - boost::unordered_flat_set forwardVisited; - boost::unordered_flat_set reverseVisited; - - forwardQueue.push(fromVertex); - forwardVisited.insert(fromVertex); - reverseQueue.push(toVertex); - reverseVisited.insert(toVertex); - - while (!forwardQueue.empty() && !reverseQueue.empty()) { - if (!forwardQueue.empty()) { - const auto v = forwardQueue.front(); - forwardQueue.pop(); - if (v == toVertex || reverseVisited.count(v) > 0) { - visitor.VisitIntersectionVertex(v); - return true; - } - for (const auto adjacentV : - boost::make_iterator_range(boost::adjacent_vertices(v, graph))) { - if (forwardVisited.count(adjacentV) == 0) { - visitor.VisitForwardVertex(v, adjacentV); - - forwardVisited.insert(adjacentV); - forwardQueue.push(adjacentV); - } - } - } - if (!reverseQueue.empty()) { - const auto v = reverseQueue.front(); - reverseQueue.pop(); - if (v == fromVertex || forwardVisited.count(v) > 0) { - visitor.VisitIntersectionVertex(v); - return true; - } - for (const auto adjacentV : - boost::make_iterator_range(boost::inv_adjacent_vertices(v, graph))) { - if (reverseVisited.count(adjacentV) == 0) { - visitor.VisitReverseVertex(adjacentV, v); - - reverseVisited.insert(adjacentV); - reverseQueue.push(adjacentV); - } - } - } - } - - return false; -} - -bool PathsCache::IsPathCached(const vertex_t& fromVertex, - const vertex_t& toVertex) const { - const auto descendants = pathsCache_.find(fromVertex); - - if (descendants == pathsCache_.end()) { - return false; - } - - return descendants->second.contains(toVertex); -} - -void PathsCache::CachePath(const vertex_t& fromVertex, - const vertex_t& toVertex) { - const auto descendants = pathsCache_.find(fromVertex); - - if (descendants == pathsCache_.end()) { - pathsCache_.emplace(fromVertex, boost::unordered_flat_set{toVertex}); - } else { - descendants->second.emplace(toVertex); - } -} - -const ComparableFilename& ComparableFilenamesCache::Get( - const std::string& narrowString) { - auto vertexNameIt = comparableFilenamesCache_.find(narrowString); - if (vertexNameIt == comparableFilenamesCache_.end()) { - throw std::invalid_argument("Given string was not already cached"); - } - - return vertexNameIt->second; -} - -const ComparableFilename& ComparableFilenamesCache::GetOrInsert( - const std::string& narrowString) { - auto vertexNameIt = comparableFilenamesCache_.find(narrowString); - if (vertexNameIt == comparableFilenamesCache_.end()) { - vertexNameIt = - comparableFilenamesCache_ - .emplace(narrowString, ToComparableFilename(narrowString)) - .first; - } - - return vertexNameIt->second; -} - -void ComparableFilenamesCache::Insert(const std::string& narrowString) { - if (!comparableFilenamesCache_.contains(narrowString)) { - comparableFilenamesCache_.emplace(narrowString, - ToComparableFilename(narrowString)); - } -} - -size_t PluginGraph::CountVertices() const { - return boost::num_vertices(graph_); -} - -std::pair PluginGraph::GetVertices() const { - return boost::vertices(graph_); -} - -std::optional PluginGraph::GetVertexByName(const std::string& name) { - for (const auto& vertex : boost::make_iterator_range(GetVertices())) { - const auto& vertexName = GetPlugin(vertex).GetName(); - comparableFilenamesCache_.Insert(vertexName); - const auto& comparableName = comparableFilenamesCache_.GetOrInsert(name); - const auto& comparableVertexName = - comparableFilenamesCache_.Get(vertexName); - - if (CompareFilenames(comparableVertexName, comparableName) == 0) { - return vertex; - } - } - - return std::nullopt; -} - -const PluginSortingData& PluginGraph::GetPlugin(const vertex_t& vertex) const { - return graph_[vertex]; -} - -void PluginGraph::CheckForCycles() const { - const auto logger = getLogger(); - if (logger) { - logger->trace("Checking plugin graph for cycles..."); - } - - boost::depth_first_search(graph_, visitor(CycleDetector())); -} - -std::vector PluginGraph::TopologicalSort() const { - std::vector sortedVertices; - const auto logger = getLogger(); - if (logger) { - logger->trace("Performing topological sort on plugin graph..."); - } - boost::topological_sort(graph_, std::back_inserter(sortedVertices)); - - std::reverse(sortedVertices.begin(), sortedVertices.end()); - - return sortedVertices; -} - -std::optional> PluginGraph::IsHamiltonianPath( - const std::vector& path) const { - const auto logger = getLogger(); - if (logger) { - logger->trace("Checking uniqueness of path through plugin graph..."); - } - - for (auto it = path.begin(); it != path.end(); ++it) { - if (next(it) != path.end() && !boost::edge(*it, *next(it), graph_).second) { - return std::make_pair(*it, *next(it)); - } - } - - return std::nullopt; -} - -std::vector PluginGraph::ToPluginNames( - const std::vector& path) const { - std::vector names; - for (const auto& vertex : path) { - names.push_back(GetPlugin(vertex).GetName()); - } - - return names; -} - -bool PluginGraph::EdgeExists(const vertex_t& fromVertex, - const vertex_t& toVertex) { - return boost::edge(fromVertex, toVertex, graph_).second; -} - -bool PluginGraph::PathExists(const vertex_t& fromVertex, - const vertex_t& toVertex) { - if (pathsCache_.IsPathCached(fromVertex, toVertex)) { - return true; - } - - PathCacher visitor(pathsCache_, fromVertex, toVertex); - - return loot::FindPath(graph_, fromVertex, toVertex, visitor); -} - -bool PluginGraph::IsPathCached(const vertex_t& fromVertex, - const vertex_t& toVertex) { - return pathsCache_.IsPathCached(fromVertex, toVertex); -} - -std::optional> PluginGraph::FindPath( - const vertex_t& fromVertex, - const vertex_t& toVertex) { - PathFinder visitor(graph_, pathsCache_, fromVertex, toVertex); - - loot::FindPath(graph_, fromVertex, toVertex, visitor); - - return visitor.GetPath(); -} - -std::optional PluginGraph::GetEdgeType(const vertex_t& fromVertex, - const vertex_t& toVertex) { - const auto edge = boost::edge(fromVertex, toVertex, graph_); - if (!edge.second) { - return std::nullopt; - } - - return graph_[edge.first]; -} - -void PluginGraph::AddEdge(const vertex_t& fromVertex, - const vertex_t& toVertex, - EdgeType edgeType) { - if (pathsCache_.IsPathCached(fromVertex, toVertex)) { - return; - } - - const auto logger = getLogger(); - if (logger) { - logger->debug("Adding {} edge from \"{}\" to \"{}\".", - describeEdgeType(edgeType), - GetPlugin(fromVertex).GetName(), - GetPlugin(toVertex).GetName()); - } - - boost::add_edge(fromVertex, toVertex, edgeType, graph_); - pathsCache_.CachePath(fromVertex, toVertex); -} - -vertex_t PluginGraph::AddVertex(const PluginSortingData& plugin) { - return boost::add_vertex(plugin, graph_); -} - -void PluginGraph::AddSpecificEdges() { - const auto logger = getLogger(); - if (logger) { - logger->trace( - "Adding edges based on plugin data and non-group metadata..."); - } - - // Add edges for all relationships that aren't overlaps. - for (auto [vit, vitend] = GetVertices(); vit != vitend; ++vit) { - const auto& vertex = *vit; - const auto& plugin = GetPlugin(vertex); - - // This loop should have no effect now that master-flagged and - // non-master-flagged plugins are sorted separately, but is kept - // as a safety net. - for (vertex_it vit2 = std::next(vit); vit2 != vitend; ++vit2) { - const auto& otherVertex = *vit2; - const auto& otherPlugin = GetPlugin(otherVertex); - - if (plugin.IsMaster() == otherPlugin.IsMaster()) { - continue; - } - - const auto isOtherPluginAMaster = otherPlugin.IsMaster(); - vertex_t childVertex = isOtherPluginAMaster ? vertex : otherVertex; - vertex_t parentVertex = isOtherPluginAMaster ? otherVertex : vertex; - - AddEdge(parentVertex, childVertex, EdgeType::masterFlag); - } - - for (const auto& master : plugin.GetMasters()) { - const auto parentVertex = GetVertexByName(master); - if (parentVertex.has_value()) { - AddEdge(parentVertex.value(), vertex, EdgeType::master); - } - } - - for (const auto& file : plugin.GetMasterlistRequirements()) { - const auto parentVertex = GetVertexByName(std::string(file.GetName())); - if (parentVertex.has_value()) { - AddEdge(parentVertex.value(), vertex, EdgeType::masterlistRequirement); - } - } - for (const auto& file : plugin.GetUserRequirements()) { - const auto parentVertex = GetVertexByName(std::string(file.GetName())); - if (parentVertex.has_value()) { - AddEdge(parentVertex.value(), vertex, EdgeType::userRequirement); - } - } - - for (const auto& file : plugin.GetMasterlistLoadAfterFiles()) { - const auto parentVertex = GetVertexByName(std::string(file.GetName())); - if (parentVertex.has_value()) { - AddEdge(parentVertex.value(), vertex, EdgeType::masterlistLoadAfter); - } - } - for (const auto& file : plugin.GetUserLoadAfterFiles()) { - const auto parentVertex = GetVertexByName(std::string(file.GetName())); - if (parentVertex.has_value()) { - AddEdge(parentVertex.value(), vertex, EdgeType::userLoadAfter); - } - } - } -} - -void PluginGraph::AddHardcodedPluginEdges( - const std::vector& hardcodedPlugins) { - const auto logger = getLogger(); - if (logger) { - logger->trace( - "Adding edges for implicitly active plugins and plugins with hardcoded " - "positions..."); - } - - if (hardcodedPlugins.empty()) { - return; - } - - std::map::const_iterator, vertex_t> - implicitlyActivePluginVertices; - std::vector otherPluginVertices; - - // Build the vertex map for implicitly active plugins and record the vertices - // for other plugins. - for (const auto& vertex : boost::make_iterator_range(GetVertices())) { - const auto& pluginName = GetPlugin(vertex).GetName(); - const auto it = - std::find_if(hardcodedPlugins.begin(), - hardcodedPlugins.end(), - [&](std::string_view name) { - return CompareFilenames(name, pluginName) == 0; - }); - - if (it != hardcodedPlugins.end()) { - implicitlyActivePluginVertices.emplace(it, vertex); - } else { - otherPluginVertices.push_back(vertex); - } - } - - if (implicitlyActivePluginVertices.empty()) { - return; - } - - // Now add edges between consecutive implicitly active plugins. - auto lastImplicitlyActiveVertexIt = implicitlyActivePluginVertices.end(); - for (auto it = hardcodedPlugins.begin(); it != hardcodedPlugins.end();) { - const auto fromVertexIt = implicitlyActivePluginVertices.find(it); - - // Find the next valid implicitly active plugin and its vertex. - auto toVertexIt = implicitlyActivePluginVertices.end(); - for (it = std::next(it); it != hardcodedPlugins.end(); ++it) { - toVertexIt = implicitlyActivePluginVertices.find(it); - if (toVertexIt != implicitlyActivePluginVertices.end()) { - break; - } - } - - if (fromVertexIt != implicitlyActivePluginVertices.end()) { - lastImplicitlyActiveVertexIt = fromVertexIt; - - if (toVertexIt != implicitlyActivePluginVertices.end()) { - AddEdge(fromVertexIt->second, toVertexIt->second, EdgeType::hardcoded); - } - } - } - - // Finally, add edges from the last implicitly active plugin to the other - // plugins. - if (lastImplicitlyActiveVertexIt != implicitlyActivePluginVertices.end()) { - for (const auto& vertex : otherPluginVertices) { - AddEdge( - lastImplicitlyActiveVertexIt->second, vertex, EdgeType::hardcoded); - } - } -} - -void PluginGraph::AddGroupEdges(const GroupGraph& groupGraph) { - typedef boost::graph_traits::vertex_descriptor GroupGraphVertex; - - const auto logger = getLogger(); - if (logger) { - logger->trace("Adding edges based on plugin group memberships..."); - } - - // First build a map from groups to the plugins in those groups. - const auto groupsPlugins = GetGroupsPlugins(*this); - - // Get the default group's vertex because it's needed for the DFSes. - const auto defaultVertex = GetDefaultVertex(groupGraph); - - // The vertex sort order prioritises resolving potential cycles in - // favour of earlier-loading groups. It does not guarantee that the - // longest paths will be walked first, because a root vertex may be in - // more than one path and the vertex sort order here does not influence - // which path the DFS takes. - const auto groupVertices = GetSortedGroupVertices(groupGraph); - - // Now loop over the vertices in the groups graph. - // Keep a record of which vertices have already been fully explored to avoid - // adding edges from their plugins more than once. - std::unordered_set finishedVertices; - for (const auto& groupVertex : groupVertices) { - // Run a DFS from each vertex in the group graph, adding edges except from - // plugins in the default group. This could be run only on the root - // vertices, except that the DFS only visits each vertex once, so a branch - // and merge inside a given root's DAG would result in plugins from one of - // the branches not being carried forwards past the point at which the - // branches merge. - GroupsPathVisitor visitor( - *this, finishedVertices, groupsPlugins, defaultVertex); - - DepthFirstVisit(groupGraph, groupVertex, visitor); - } - - // Now do one last DFS starting from the default group and not ignoring its - // plugins. - GroupsPathVisitor visitor(*this, finishedVertices, groupsPlugins); - - DepthFirstVisit(groupGraph, defaultVertex, visitor); -} - -void PluginGraph::AddOverlapEdges() { - const auto logger = getLogger(); - if (logger) { - logger->trace("Adding edges for overlapping plugins..."); - } - - for (auto [vit, vitend] = GetVertices(); vit != vitend; ++vit) { - const auto vertex = *vit; - const auto& plugin = GetPlugin(vertex); - const auto pluginRecordCount = plugin.GetOverrideRecordCount(); - const auto pluginAssetCount = plugin.GetAssetCount(); - - if (pluginRecordCount == 0 && pluginAssetCount == 0) { - if (logger) { - logger->debug( - "Skipping vertex for \"{}\": the plugin contains no override " - "records and loads no assets.", - plugin.GetName()); - } - continue; - } - - for (vertex_it vit2 = std::next(vit); vit2 != vitend; ++vit2) { - const auto otherVertex = *vit2; - const auto& otherPlugin = GetPlugin(otherVertex); - - // Don't add an edge between these two plugins if one already - // exists (only check direct edges and not paths for efficiency). - if (EdgeExists(vertex, otherVertex) || EdgeExists(otherVertex, vertex)) { - continue; - } - - // Two plugins can overlap due to overriding the same records, - // or by loading assets from BSAs/BA2s that have the same path. - // If records overlap, the plugin that overrides more records - // should load earlier. - // If assets overlap, the plugin that loads more assets should - // load earlier. - // If two plugins have overlapping records and assets and one - // overrides more records but loads fewer assets than the other, - // the fact it overrides more records should take precedence - // (records are more significant than assets). - // I.e. if two plugins don't have overlapping records, check their - // assets, otherwise only check their assets if their override - // record counts are equal. - - auto thisPluginLoadsFirst = false; - EdgeType edgeType = EdgeType::recordOverlap; - - const auto otherPluginRecordCount = otherPlugin.GetOverrideRecordCount(); - - if (pluginRecordCount == otherPluginRecordCount || - !plugin.DoRecordsOverlap(otherPlugin)) { - // Records don't overlap, or override the same number of records, - // check assets. - // No records overlap, check assets. - const auto otherPluginAssetCount = otherPlugin.GetAssetCount(); - if (pluginAssetCount == otherPluginAssetCount || - !plugin.DoAssetsOverlap(otherPlugin)) { - // Assets don't overlap or both plugins load the same number of - // assets, don't add an edge. - continue; - } else { - thisPluginLoadsFirst = pluginAssetCount > otherPluginAssetCount; - edgeType = EdgeType::assetOverlap; - } - } else { - // Records overlap and override different numbers of records. - // Load this plugin first if it overrides more records. - thisPluginLoadsFirst = pluginRecordCount > otherPluginRecordCount; - } - - const auto fromVertex = thisPluginLoadsFirst ? vertex : otherVertex; - const auto toVertex = thisPluginLoadsFirst ? otherVertex : vertex; - - if (!IsPathCached(fromVertex, toVertex)) { - if (!PathExists(toVertex, fromVertex)) { - AddEdge(fromVertex, toVertex, edgeType); - } else if (logger) { - logger->debug( - "Skipping \"{}\" edge from \"{}\" to \"{}\" as it would " - "create a cycle.", - describeEdgeType(edgeType), - GetPlugin(fromVertex).GetName(), - GetPlugin(toVertex).GetName()); - } - } - } - } -} - -void PluginGraph::AddTieBreakEdges() { - const auto logger = getLogger(); - if (logger) { - logger->trace("Adding edges to break ties between plugins..."); - } - - // In order for the sort to be performed stably, there must be only one - // possible result. This can be enforced by adding edges between all vertices - // that aren't already linked. Use existing load order to decide the direction - // of these edges, and only add an edge if it won't cause a cycle. - // - // Brute-forcing this by adding an edge between every pair of vertices - // (unless it would cause a cycle) works but scales terribly, as before each - // edge is added a bidirectional search needs to be done for a path in the - // other direction (to detect a potential cycle). This search takes more time - // as the number of edges involves increases, so adding tie breaks gets slower - // as they get added. - // - // The point of adding these tie breaks is to ensure that there's a - // Hamiltonian path through the graph and therefore only one possible - // topological sort result. - // - // Instead of trying to brute-force this, iterate over the graph's vertices in - // their existing load order (each vertex represents a plugin, so the two - // terms are used interchangeably), and add an edge going from the earlier to - // the later for each consecutive pair of plugins (e.g. for [A, B, C], add - // edges A->B, B->C), unless adding the edge would cause a cycle. If sorting - // has made no changes to the load order, then it'll be possible to add all - // those edges and only N - 1 bidirectional searches will be needed when there - // are N vertices. - // - // If it's not possible to add such an edge for a pair of plugins [A, B], that - // means that LOOT thinks A needs to load after B, i.e. the sorted load order - // will be different. If the existing path between A and B is B -> C -> D -> A - // then walk back through the load order to find a plugin that B will load - // after without causing a cycle, and add an edge going from that plugin to B. - // Then do the same for each subsequent plugin in the path between A and B so - // that every plugin in the existing load order until A has a path to each of - // the plugins in the path from B to A, and that there is only one path that - // will visit all plugins until A. Keep a record of this path, because that's - // the load order that needs to be walked back through whenever the existing - // relative positions of plugins can't be used (if the existing load order was - // used, the process would miss out on plugins introduced in previous backward - // walks, and so you'd end up with multiple paths that don't necessarily touch - // all plugins). - - // Storage for the load order as it evolves: a list is used because there may - // be a lot of inserts and it works out more efficient with large load orders - // and the efficiency difference doesn't matter for small load orders. - std::list newLoadOrder; - - // Holds vertices that have already been put into newLoadOrder. - std::unordered_set processedVertices; - - const auto pinVertexPosition = - [&](const vertex_t& vertex, - const std::list::const_reverse_iterator reverseEndIt) - -> std::list::const_reverse_iterator { - // It's possible that this vertex has already been pinned in place, - // e.g. because it was visited earlier in the old load order or - // as part of a path that was processed. In that case just skip it. - if (processedVertices.count(vertex) != 0) { - if (logger) { - logger->debug( - "The plugin \"{}\" has already been processed, skipping it.", - GetPlugin(vertex).GetName()); - } - return reverseEndIt; - } - - // Otherwise, this vertex needs to be inserted into the path that includes - // all other vertices that have been processed so far. This can be done by - // searching for the last vertex in the "new load order" path for which - // there is not a path going from this vertex to that vertex. I.e. find the - // last plugin that this one can load after. We could instead find the last - // plugin that this one *must* load after, but it turns out that's - // significantly slower because it generally involves going further back - // along the "new load order" path. - const auto previousVertexPosition = - std::find_if(newLoadOrder.crbegin(), - reverseEndIt, - [&](const vertex_t& loadOrderVertex) { - return !PathExists(vertex, loadOrderVertex); - }); - - // Add an edge going from the found vertex to this one, in case it - // doesn't exist (we only know there's not a path going the other way). - if (previousVertexPosition != reverseEndIt) { - const auto precedingVertex = *previousVertexPosition; - - AddEdge(precedingVertex, vertex, EdgeType::tieBreak); - } - - // Insert position is just after the found vertex, and a forward iterator - // points to the element one after the element pointed to by the - // corresponding reverse iterator. - const auto insertPosition = previousVertexPosition.base(); - - // Add an edge going from this vertex to the next one in the "new load - // order" path, in case there isn't already one. - if (insertPosition != newLoadOrder.end()) { - const auto followingVertex = *insertPosition; - - AddEdge(vertex, followingVertex, EdgeType::tieBreak); - } - - // Now update newLoadOrder with the vertex's new position. - const auto newLoadOrderIt = newLoadOrder.insert(insertPosition, vertex); - processedVertices.insert(vertex); - - if (logger) { - const auto nextLoadOrderIt = std::next(newLoadOrderIt); - - if (nextLoadOrderIt == newLoadOrder.end()) { - logger->debug( - "The plugin \"{}\" loads at the end of the new load order so " - "far.", - GetPlugin(vertex).GetName()); - } else { - logger->debug( - "The plugin \"{}\" loads before \"{}\" in the new load order.", - GetPlugin(vertex).GetName(), - GetPlugin(*nextLoadOrderIt).GetName()); - } - } - - // Return a new value for reverseEndIt, pointing to the newly - // inserted vertex, as if it was not the last vertex in a path - // being processed the next vertex in the path by definition - // cannot load before this one, so we can save an unnecessary - // check by using this new reverseEndIt value when pinning the - // next vertex. - return std::make_reverse_iterator(std::next(newLoadOrderIt)); - }; - - // First get the graph vertices and sort them into the current load order. - const auto [it, itend] = GetVertices(); - std::vector vertices(it, itend); - - std::sort(vertices.begin(), - vertices.end(), - [this](const vertex_t& lhs, const vertex_t& rhs) { - return GetPlugin(lhs).GetLoadOrderIndex() < - GetPlugin(rhs).GetLoadOrderIndex(); - }); - - // Now iterate over the vertices in their sorted order. - const auto vitstart = vertices.begin(); - const auto vitend = vertices.end(); - for (auto vit = vitstart; vit != vitend; ++vit) { - const auto currentVertex = *vit; - const auto nextVertexIt = std::next(vit); - - if (nextVertexIt == vitend) { - // Don't dereference the past-the-end iterator. - break; - } - - const auto nextVertex = *nextVertexIt; - - auto pathFromNextVertex = FindPath(nextVertex, currentVertex); - - if (!pathFromNextVertex.has_value()) { - // There's no path from nextVertex to currentVertex, so it's OK to add - // an edge going in the other direction, meaning that nextVertex can - // load after currentVertex. - AddEdge(currentVertex, nextVertex, EdgeType::tieBreak); - - // nextVertex now loads after currentVertex. If currentVertex hasn't - // already been added to the load order, append it. It might have already - // been added if it was part of a path going from nextVertex and - // currentVertex in a previous loop (i.e. for different values of - // nextVertex and currentVertex). - if (processedVertices.count(currentVertex) == 0) { - newLoadOrder.push_back(currentVertex); - processedVertices.insert(currentVertex); - - if (logger) { - logger->debug( - "The plugin \"{}\" loads at the end of the new load order so " - "far.", - GetPlugin(currentVertex).GetName()); - } - } else if (currentVertex != newLoadOrder.back()) { - if (logger) { - logger->trace( - "Plugin \"{}\" has already been processed and is not last in the " - "new load order, determining where to place \"{}\".", - GetPlugin(currentVertex).GetName(), - GetPlugin(nextVertex).GetName()); - } - - // If currentVertex was already processed and not the last vertex - // in newLoadOrder then nextVertex also needs to be pinned in place or - // it may not have a defined position relative to all the - // vertices following currentVertex in newLoadOrder undefined, so - // there wouldn't be a unique path through them. - // - // We're using newLoadOrder.rend() as the last iterator position because - // we don't know currentVertex's position. - pinVertexPosition(nextVertex, newLoadOrder.rend()); - } - } else { - // Each vertex in pathFromNextVertex (besides the last, which is - // currentVertex) needs to be positioned relative to a vertex that has - // already been iterated over (i.e. in what begins as the old load - // order) so that there is a single path between all vertices. - // - // If currentVertex is the first in the iteration order, then - // nextVertex is simply the earliest known plugin in the new load order - // so far. - if (vit == vitstart) { - // Record the path as the start of the new load order. - // Don't need to add any edges because there's nothing for nextVertex - // to load after at this point. - if (logger) { - logger->debug( - "The path ends with the first plugin checked, treating the " - "following path as the start of the load order: {}", - PathToString(graph_, pathFromNextVertex.value())); - } - for (const auto& pathVertex : pathFromNextVertex.value()) { - newLoadOrder.push_back(pathVertex); - processedVertices.insert(pathVertex); - } - continue; - } - - // Ignore the last vertex in the path because it's currentVertex and - // will just be appended to the load order so doesn't need special - // processing. - pathFromNextVertex.value().pop_back(); - - // This is used to keep track of when to stop searching for a - // vertex to load after, as a minor optimisation. - auto reverseEndIt = newLoadOrder.crend(); - - // Iterate over the path going from nextVertex towards currentVertex - // (which got chopped off the end of the path). - for (const auto& currentPathVertex : pathFromNextVertex.value()) { - // Update reverseEndIt to reduce the scope of the search in the - // next loop (if there is one). - reverseEndIt = pinVertexPosition(currentPathVertex, reverseEndIt); - } - - // Add currentVertex to the end of the newLoadOrder - do this after - // processing the other vertices in the path so that involves less - // work. - if (processedVertices.count(currentVertex) == 0) { - newLoadOrder.push_back(currentVertex); - processedVertices.insert(currentVertex); - } - } - } -} -} diff --git a/src/api/sorting/plugin_graph.h b/src/api/sorting/plugin_graph.h deleted file mode 100644 index 5a0e1206..00000000 --- a/src/api/sorting/plugin_graph.h +++ /dev/null @@ -1,119 +0,0 @@ -/* LOOT - - A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and - Fallout: New Vegas. - - Copyright (C) 2012-2016 WrinklyNinja - - This file is part of LOOT. - - LOOT is free software: you can redistribute - it and/or modify it under the terms of the GNU General Public License - as published by the Free Software Foundation, either version 3 of - the License, or (at your option) any later version. - - LOOT is distributed in the hope that it will - be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with LOOT. If not, see - . - */ - -#ifndef LOOT_API_SORTING_PLUGIN_SORTER -#define LOOT_API_SORTING_PLUGIN_SORTER - -#include -#include -#include -#include -#include - -#include "api/helpers/text.h" -#include "api/sorting/group_sort.h" -#include "api/sorting/plugin_sorting_data.h" -#include "loot/enum/edge_type.h" -#include "loot/metadata/group.h" - -namespace loot { -typedef boost::adjacency_list - RawPluginGraph; -typedef boost::graph_traits::vertex_descriptor vertex_t; -typedef boost::graph_traits::vertex_iterator vertex_it; - -std::string describeEdgeType(EdgeType edgeType); - -class PathsCache { -public: - bool IsPathCached(const vertex_t& fromVertex, const vertex_t& toVertex) const; - void CachePath(const vertex_t& fromVertex, const vertex_t& toVertex); - -private: - boost::unordered_flat_map> - pathsCache_; -}; - -class ComparableFilenamesCache { -public: - void Insert(const std::string& narrowString); - const ComparableFilename& Get(const std::string& narrowString); - const ComparableFilename& GetOrInsert(const std::string& narrowString); - -private: - boost::unordered_flat_map - comparableFilenamesCache_; -}; - -class PluginGraph { -public: - size_t CountVertices() const; - std::pair GetVertices() const; - std::optional GetVertexByName(const std::string& name); - - const PluginSortingData& GetPlugin(const vertex_t& vertex) const; - - void CheckForCycles() const; - std::vector TopologicalSort() const; - - // If the path is not Hamiltonian, returns the first pair of vertices - // in the path that do not have an edge between them. - std::optional> IsHamiltonianPath( - const std::vector& path) const; - std::vector ToPluginNames( - const std::vector& path) const; - - bool EdgeExists(const vertex_t& fromVertex, const vertex_t& toVertex); - bool PathExists(const vertex_t& fromVertex, const vertex_t& toVertex); - bool IsPathCached(const vertex_t& fromVertex, const vertex_t& toVertex); - - std::optional> FindPath(const vertex_t& fromVertex, - const vertex_t& toVertex); - std::optional GetEdgeType(const vertex_t& fromVertex, - const vertex_t& toVertex); - - void AddEdge(const vertex_t& fromVertex, - const vertex_t& toVertex, - EdgeType edgeType); - vertex_t AddVertex(const PluginSortingData& plugin); - - void AddSpecificEdges(); - void AddHardcodedPluginEdges( - const std::vector& hardcodedPlugins); - void AddGroupEdges(const GroupGraph& groupGraph); - void AddOverlapEdges(); - void AddTieBreakEdges(); - -private: - RawPluginGraph graph_; - PathsCache pathsCache_; - ComparableFilenamesCache comparableFilenamesCache_; -}; -} - -#endif diff --git a/src/api/sorting/plugin_sort.cpp b/src/api/sorting/plugin_sort.cpp deleted file mode 100644 index 4e1b84ea..00000000 --- a/src/api/sorting/plugin_sort.cpp +++ /dev/null @@ -1,399 +0,0 @@ -/* LOOT - - A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and - Fallout: New Vegas. - - Copyright (C) 2018 WrinklyNinja - - This file is part of LOOT. - - LOOT is free software: you can redistribute - it and/or modify it under the terms of the GNU General Public License - as published by the Free Software Foundation, either version 3 of - the License, or (at your option) any later version. - - LOOT is distributed in the hope that it will - be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with LOOT. If not, see - . - */ - -#include "plugin_sort.h" - -#include - -#include "api/helpers/logging.h" -#include "api/sorting/group_sort.h" -#include "api/sorting/plugin_graph.h" -#include "loot/exception/undefined_group_error.h" - -namespace { -std::vector FilterFilesByConstraint( - const loot::DatabaseInterface& db, - std::vector&& files) { - std::vector filtered; - for (auto&& file : files) { - if (db.Evaluate(file.GetConstraint())) { - filtered.push_back(std::move(file)); - } - } - - return filtered; -} - -void FilterByConstraint(const loot::DatabaseInterface& db, - loot::PluginMetadata& metadata) { - metadata.SetLoadAfterFiles( - FilterFilesByConstraint(db, metadata.GetLoadAfterFiles())); - metadata.SetRequirements( - FilterFilesByConstraint(db, metadata.GetRequirements())); -} -} - -namespace loot { -std::vector GetPluginsSortingData( - const DatabaseInterface& db, - const std::vector& loadOrder) { - std::vector pluginsSortingData; - pluginsSortingData.reserve(loadOrder.size()); - - size_t i = 0; - for (const auto& plugin : loadOrder) { - const auto pluginFilename = plugin->GetName(); - - auto masterlistMetadata = - db.GetPluginMetadata(pluginFilename, false, true) - .value_or(PluginMetadata(pluginFilename)); - auto userMetadata = db.GetPluginUserMetadata(pluginFilename, true) - .value_or(PluginMetadata(pluginFilename)); - - // Only use constained metadata when the constraints are true. - FilterByConstraint(db, masterlistMetadata); - FilterByConstraint(db, userMetadata); - - const auto pluginSortingData = - PluginSortingData(plugin, masterlistMetadata, userMetadata, i); - - pluginsSortingData.push_back(pluginSortingData); - i += 1; - } - - return pluginsSortingData; -} - -void ValidatePluginGroups(const std::vector& plugins, - const GroupGraph& graph) { - std::unordered_set groupNames; - - for (const vertex_t& vertex : - boost::make_iterator_range(boost::vertices(graph))) { - groupNames.insert(graph[vertex]); - } - - for (const auto& plugin : plugins) { - const auto pluginGroup = plugin.GetGroup(); - if (groupNames.count(pluginGroup) == 0) { - throw UndefinedGroupError(pluginGroup); - } - } -} - -bool IsInRange(const std::vector::const_iterator& begin, - const std::vector::const_iterator& end, - std::string_view name) { - return std::any_of(begin, end, [&](const PluginSortingData& plugin) { - return CompareFilenames(plugin.GetName(), name) == 0; - }); -} - -void ValidateSpecificAndHardcodedEdges( - const std::vector::const_iterator& begin, - const std::vector::const_iterator& firstBlueprintMaster, - const std::vector::const_iterator& firstNonMaster, - const std::vector::const_iterator& end, - const std::vector& hardcodedPlugins) { - const auto logger = getLogger(); - - const auto isNonMaster = [&](std::string_view name) { - return IsInRange(firstNonMaster, end, name); - }; - const auto isBlueprintMaster = [&](std::string_view name) { - return IsInRange(firstBlueprintMaster, firstNonMaster, name); - }; - - for (auto it = begin; it != firstBlueprintMaster; ++it) { - for (const auto& master : it->GetMasters()) { - if (isNonMaster(master)) { - throw CyclicInteractionError( - std::vector{Vertex(master, EdgeType::master), - Vertex(it->GetName(), EdgeType::masterFlag)}); - } - - if (isBlueprintMaster(master) && logger) { - // Log a warning instead of throwing an exception because the game will - // just ignore this master, and the issue can't be fixed without - // editing the plugin and the blueprint master may not actually have - // any of its records overridden. - logger->warn( - "The master plugin \"{}\" has the blueprint master \"{}\" as one " - "of its masters", - it->GetName(), - master); - } - } - - for (const auto& file : it->GetMasterlistRequirements()) { - const auto name = std::string(file.GetName()); - if (isNonMaster(name)) { - throw CyclicInteractionError( - std::vector{Vertex(name, EdgeType::masterlistRequirement), - Vertex(it->GetName(), EdgeType::masterFlag)}); - } - - if (isBlueprintMaster(name)) { - throw CyclicInteractionError(std::vector{ - Vertex(name, EdgeType::masterlistRequirement), - Vertex(it->GetName(), EdgeType::blueprintMaster)}); - } - } - - for (const auto& file : it->GetUserRequirements()) { - const auto name = std::string(file.GetName()); - if (isNonMaster(name)) { - throw CyclicInteractionError( - std::vector{Vertex(name, EdgeType::userRequirement), - Vertex(it->GetName(), EdgeType::masterFlag)}); - } - - if (isBlueprintMaster(name)) { - throw CyclicInteractionError(std::vector{ - Vertex(name, EdgeType::userRequirement), - Vertex(it->GetName(), EdgeType::blueprintMaster)}); - } - } - - for (const auto& file : it->GetMasterlistLoadAfterFiles()) { - const auto name = std::string(file.GetName()); - if (isNonMaster(name)) { - throw CyclicInteractionError( - std::vector{Vertex(name, EdgeType::masterlistLoadAfter), - Vertex(it->GetName(), EdgeType::masterFlag)}); - } - - if (isBlueprintMaster(name)) { - throw CyclicInteractionError(std::vector{ - Vertex(name, EdgeType::masterlistLoadAfter), - Vertex(it->GetName(), EdgeType::blueprintMaster)}); - } - } - - for (const auto& file : it->GetUserLoadAfterFiles()) { - const auto name = std::string(file.GetName()); - if (isNonMaster(name)) { - throw CyclicInteractionError( - std::vector{Vertex(name, EdgeType::userLoadAfter), - Vertex(it->GetName(), EdgeType::masterFlag)}); - } - - if (isBlueprintMaster(name)) { - throw CyclicInteractionError(std::vector{ - Vertex(name, EdgeType::userLoadAfter), - Vertex(it->GetName(), EdgeType::blueprintMaster)}); - } - } - } - - for (auto it = firstNonMaster; it != end; ++it) { - for (const auto& master : it->GetMasters()) { - if (isBlueprintMaster(master) && logger) { - // Log a warning instead of throwing an exception because the game will - // just ignore this master, and the issue can't be fixed without - // editing the plugin and the blueprint master may not actually have - // any of its records overridden. - logger->warn( - "The non-master plugin \"{}\" has the blueprint master \"{}\" as " - "one of its masters", - it->GetName(), - master); - } - } - - for (const auto& file : it->GetMasterlistRequirements()) { - const auto name = std::string(file.GetName()); - if (isBlueprintMaster(name)) { - throw CyclicInteractionError(std::vector{ - Vertex(name, EdgeType::masterlistRequirement), - Vertex(it->GetName(), EdgeType::blueprintMaster)}); - } - } - - for (const auto& file : it->GetUserRequirements()) { - const auto name = std::string(file.GetName()); - if (isBlueprintMaster(name)) { - throw CyclicInteractionError(std::vector{ - Vertex(name, EdgeType::userRequirement), - Vertex(it->GetName(), EdgeType::blueprintMaster)}); - } - } - - for (const auto& file : it->GetMasterlistLoadAfterFiles()) { - const auto name = std::string(file.GetName()); - if (isBlueprintMaster(name)) { - throw CyclicInteractionError(std::vector{ - Vertex(name, EdgeType::masterlistLoadAfter), - Vertex(it->GetName(), EdgeType::blueprintMaster)}); - } - } - - for (const auto& file : it->GetUserLoadAfterFiles()) { - const auto name = std::string(file.GetName()); - if (isBlueprintMaster(name)) { - throw CyclicInteractionError(std::vector{ - Vertex(name, EdgeType::userLoadAfter), - Vertex(it->GetName(), EdgeType::blueprintMaster)}); - } - } - } - - if (begin != firstNonMaster) { - // There's at least one master, check that there are no hardcoded - // non-masters. - for (const auto& plugin : hardcodedPlugins) { - if (isNonMaster(plugin)) { - // Just report the cycle to the first master. - throw CyclicInteractionError(std::vector{ - Vertex(plugin, EdgeType::hardcoded), - Vertex(begin->GetName(), EdgeType::masterFlag)}); - } - } - } -} - -std::vector SortPlugins( - const std::vector::const_iterator& begin, - const std::vector::const_iterator& end, - const std::vector& hardcodedPlugins, - const GroupGraph& groupGraph) { - PluginGraph graph; - - for (auto it = begin; it != end; ++it) { - graph.AddVertex(*it); - } - - // Now add the interactions between plugins to the graph as edges. - graph.AddSpecificEdges(); - graph.AddHardcodedPluginEdges(hardcodedPlugins); - - // Check for cycles now because from this point on edges are only added if - // they don't cause cycles, and adding overlap and tie-break edges is - // relatively slow, so checking now provides quicker feedback if there is an - // issue. - graph.CheckForCycles(); - - graph.AddGroupEdges(groupGraph); - graph.AddOverlapEdges(); - graph.AddTieBreakEdges(); - - // Check for cycles again, just in case there's a bug that lets some occur. - // The check doesn't take a significant amount of time. - graph.CheckForCycles(); - - const auto path = graph.TopologicalSort(); - - const auto result = graph.IsHamiltonianPath(path); - const auto logger = getLogger(); - if (result.has_value() && logger) { - logger->error("The path is not unique. No edge exists between {} and {}.", - graph.GetPlugin(result.value().first).GetName(), - graph.GetPlugin(result.value().second).GetName()); - } - - // Output a plugin list using the sorted vertices. - return graph.ToPluginNames(path); -} - -std::vector SortPlugins( - std::vector&& pluginsSortingData, - const std::vector& masterlistGroups, - const std::vector& userGroups, - const std::vector& earlyLoadingPlugins) { - // If there aren't any plugins, exit early, because sorting assumes - // there is at least one plugin. - if (pluginsSortingData.empty()) { - return {}; - } - - // Sort the plugins according to the lexicographical order of their names. - // This ensures a consistent iteration order for vertices given the same input - // data. The vertex iteration order can affect what edges get added and so - // the final sorting result, so consistency is important. - // This order needs to be independent of any state (e.g. the current load - // order) so that sorting and applying the result doesn't then produce a - // different result if you then sort again. - std::sort(pluginsSortingData.begin(), - pluginsSortingData.end(), - [](const auto& lhs, const auto& rhs) { - return lhs.GetName() < rhs.GetName(); - }); - - const auto groupGraph = BuildGroupGraph(masterlistGroups, userGroups); - ValidatePluginGroups(pluginsSortingData, groupGraph); - - // Some parts of sorting are O(N^2) for N plugins, and master flags cause - // O(M*N) edges to be added for M masters and N non-masters, which can be - // two thirds of all edges added. The cost of each bidirectional search - // scales with the number of edges, so reducing edges makes searches - // faster. - // Similarly, blueprint plugins load after all others. - // As such, sort plugins using three separate graphs for masters, - // non-masters and blueprint plugins. This means that any edges that go from a - // non-master to a master are effectively ignored, so won't cause cyclic - // interaction errors. Edges going the other way will also effectively be - // ignored, but that shouldn't have a noticeable impact. - const auto firstNonMasterIt = std::stable_partition( - pluginsSortingData.begin(), - pluginsSortingData.end(), - [](const PluginSortingData& plugin) { return plugin.IsMaster(); }); - - const auto firstBlueprintPluginIt = - std::stable_partition(pluginsSortingData.begin(), - firstNonMasterIt, - [](const PluginSortingData& plugin) { - return !plugin.IsBlueprintMaster(); - }); - - ValidateSpecificAndHardcodedEdges(pluginsSortingData.begin(), - firstBlueprintPluginIt, - firstNonMasterIt, - pluginsSortingData.end(), - earlyLoadingPlugins); - - auto newMastersLoadOrder = SortPlugins(pluginsSortingData.begin(), - firstBlueprintPluginIt, - earlyLoadingPlugins, - groupGraph); - - const auto newBlueprintMastersLoadOrder = SortPlugins(firstBlueprintPluginIt, - firstNonMasterIt, - earlyLoadingPlugins, - groupGraph); - - const auto newNonMastersLoadOrder = SortPlugins(firstNonMasterIt, - pluginsSortingData.end(), - earlyLoadingPlugins, - groupGraph); - - newMastersLoadOrder.insert(newMastersLoadOrder.end(), - newNonMastersLoadOrder.begin(), - newNonMastersLoadOrder.end()); - newMastersLoadOrder.insert(newMastersLoadOrder.end(), - newBlueprintMastersLoadOrder.begin(), - newBlueprintMastersLoadOrder.end()); - - return newMastersLoadOrder; -} -} diff --git a/src/api/sorting/plugin_sort.h b/src/api/sorting/plugin_sort.h deleted file mode 100644 index 56850643..00000000 --- a/src/api/sorting/plugin_sort.h +++ /dev/null @@ -1,47 +0,0 @@ -/* LOOT - - A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and - Fallout: New Vegas. - - Copyright (C) 2012-2016 WrinklyNinja - - This file is part of LOOT. - - LOOT is free software: you can redistribute - it and/or modify it under the terms of the GNU General Public License - as published by the Free Software Foundation, either version 3 of - the License, or (at your option) any later version. - - LOOT is distributed in the hope that it will - be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with LOOT. If not, see - . - */ - -#ifndef LOOT_API_SORTING_PLUGIN_SORT -#define LOOT_API_SORTING_PLUGIN_SORT - -#include -#include -#include - -#include "api/game/game.h" -#include "api/sorting/plugin_sorting_data.h" - -namespace loot { -std::vector GetPluginsSortingData( - const DatabaseInterface& db, - const std::vector& loadOrder); - - std::vector SortPlugins( - std::vector&& pluginsSortingData, - const std::vector& masterlistGroups, - const std::vector& userGroups, - const std::vector& earlyLoadingPlugins); -} - -#endif diff --git a/src/api/sorting/plugin_sorting_data.cpp b/src/api/sorting/plugin_sorting_data.cpp deleted file mode 100644 index ff35a966..00000000 --- a/src/api/sorting/plugin_sorting_data.cpp +++ /dev/null @@ -1,111 +0,0 @@ -/* LOOT - - A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and - Fallout: New Vegas. - - Copyright (C) 2018 WrinklyNinja - - This file is part of LOOT. - - LOOT is free software: you can redistribute - it and/or modify it under the terms of the GNU General Public License - as published by the Free Software Foundation, either version 3 of - the License, or (at your option) any later version. - - LOOT is distributed in the hope that it will - be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with LOOT. If not, see - . - */ - -#include "plugin_sorting_data.h" - -#include - -#include - -#include "api/helpers/text.h" - -namespace loot { -PluginSortingData::PluginSortingData(const PluginSortingInterface* plugin, - const PluginMetadata& masterlistMetadata, - const PluginMetadata& userMetadata, - const size_t loadOrderIndex) : - plugin_(plugin), - name_(plugin == nullptr ? std::string() : plugin->GetName()), - isMaster_(plugin != nullptr && plugin->IsMaster()), - group_(userMetadata.GetGroup().value_or( - masterlistMetadata.GetGroup().value_or(std::string(Group::DEFAULT_NAME)))), - masterlistLoadAfter_(masterlistMetadata.GetLoadAfterFiles()), - userLoadAfter_(userMetadata.GetLoadAfterFiles()), - masterlistReq_(masterlistMetadata.GetRequirements()), - userReq_(userMetadata.GetRequirements()), - loadOrderIndex_(loadOrderIndex), - overrideRecordCount_(plugin == nullptr ? 0 - : plugin->GetOverrideRecordCount()), - groupIsUserMetadata_(userMetadata.GetGroup().has_value()) {} - -const std::string& PluginSortingData::GetName() const { return name_; } - -bool PluginSortingData::IsMaster() const { return isMaster_; } - -bool PluginSortingData::IsBlueprintMaster() const { - return isMaster_ && plugin_->IsBlueprintPlugin(); -} - -std::vector PluginSortingData::GetMasters() const { - if (plugin_ == nullptr) { - return {}; - } - - return plugin_->GetMasters(); -} - -size_t PluginSortingData::GetOverrideRecordCount() const { - return overrideRecordCount_; -} - -bool PluginSortingData::DoRecordsOverlap( - const PluginSortingData& plugin) const { - return plugin_ != nullptr && plugin.plugin_ != nullptr && - plugin_->DoRecordsOverlap(*plugin.plugin_); -} - -size_t PluginSortingData::GetAssetCount() const { - return plugin_ == nullptr ? 0 : plugin_->GetAssetCount(); -} - -bool PluginSortingData::DoAssetsOverlap(const PluginSortingData& plugin) const { - return plugin_ != nullptr && plugin.plugin_ != nullptr && - plugin_->DoAssetsOverlap(*plugin.plugin_); -} - -std::string PluginSortingData::GetGroup() const { return group_; } - -bool PluginSortingData::IsGroupUserMetadata() const { - return groupIsUserMetadata_; -} - -const std::vector& PluginSortingData::GetMasterlistLoadAfterFiles() - const { - return masterlistLoadAfter_; -} - -const std::vector& PluginSortingData::GetUserLoadAfterFiles() const { - return userLoadAfter_; -} - -const std::vector& PluginSortingData::GetMasterlistRequirements() const { - return masterlistReq_; -} - -const std::vector& PluginSortingData::GetUserRequirements() const { - return userReq_; -} - -size_t PluginSortingData::GetLoadOrderIndex() const { return loadOrderIndex_; } -} diff --git a/src/api/sorting/plugin_sorting_data.h b/src/api/sorting/plugin_sorting_data.h deleted file mode 100644 index d7c0afa8..00000000 --- a/src/api/sorting/plugin_sorting_data.h +++ /dev/null @@ -1,86 +0,0 @@ -/* LOOT - - A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and - Fallout: New Vegas. - - Copyright (C) 2018 WrinklyNinja - - This file is part of LOOT. - - LOOT is free software: you can redistribute - it and/or modify it under the terms of the GNU General Public License - as published by the Free Software Foundation, either version 3 of - the License, or (at your option) any later version. - - LOOT is distributed in the hope that it will - be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with LOOT. If not, see - . - */ - -#ifndef LOOT_API_SORTING_PLUGIN_SORTING_DATA -#define LOOT_API_SORTING_PLUGIN_SORTING_DATA - -#include - -#include "api/plugin.h" -#include "api/helpers/text.h" -#include "loot/metadata/plugin_metadata.h" - -namespace loot { -class PluginSortingData { -public: - explicit PluginSortingData() = default; - - /** - * This stores a copy of the plugin pointer that is passed to it, so - * PluginSortingData objects must not live longer than the Plugin objects - * that they are constructed from. - */ - explicit PluginSortingData(const PluginSortingInterface* plugin, - const PluginMetadata& masterlistMetadata, - const PluginMetadata& userMetadata, - const size_t loadOrderIndex); - - const std::string& GetName() const; - bool IsMaster() const; - bool IsBlueprintMaster() const; - std::vector GetMasters() const; - size_t GetOverrideRecordCount() const; - bool DoRecordsOverlap(const PluginSortingData& plugin) const; - - size_t GetAssetCount() const; - bool DoAssetsOverlap(const PluginSortingData& plugin) const; - - std::string GetGroup() const; - bool IsGroupUserMetadata() const; - - const std::vector& GetMasterlistLoadAfterFiles() const; - const std::vector& GetUserLoadAfterFiles() const; - const std::vector& GetMasterlistRequirements() const; - const std::vector& GetUserRequirements() const; - - size_t GetLoadOrderIndex() const; - -private: - const PluginSortingInterface* plugin_{nullptr}; - std::string name_; - bool isMaster_{false}; - std::string group_; - - std::vector masterlistLoadAfter_; - std::vector userLoadAfter_; - std::vector masterlistReq_; - std::vector userReq_; - - size_t loadOrderIndex_{0}; - size_t overrideRecordCount_{0}; - bool groupIsUserMetadata_{0}; -}; -} - -#endif diff --git a/src/archive/ba2.rs b/src/archive/ba2.rs new file mode 100644 index 00000000..e9990802 --- /dev/null +++ b/src/archive/ba2.rs @@ -0,0 +1,154 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + hash::{DefaultHasher, Hash, Hasher}, + io::{BufRead, Seek}, +}; + +use super::error::{ArchiveParsingError, slice_too_small}; + +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 = ArchiveParsingError; + + fn try_from(value: [u8; HEADER_SIZE - TYPE_ID.len()]) -> Result { + let header = Self { + type_id: TYPE_ID, + version: to_u32(&value, 0)?, + archive_type: to_archive_type(&value)?, + file_count: to_u32(&value, 8)?, + file_paths_offset: to_u64(&value, 12)?, + }; + + // 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(ArchiveParsingError::UnsupportedHeaderVersion( + header.version, + )); + } + + if !matches!(header.archive_type, BA2_GENERAL_TYPE | BA2_TEXTURE_TYPE) { + return Err(ArchiveParsingError::UnsupportedHeaderArchiveType( + header.archive_type, + )); + } + + Ok(header) + } +} + +fn to_archive_type( + array: &[u8; HEADER_SIZE - TYPE_ID.len()], +) -> Result<[u8; 4], ArchiveParsingError> { + let slice = &array[4..8]; + + slice + .try_into() + // This should be impossible, but it can't be asserted at compile time. + .map_err(|_e| slice_too_small(slice, 4)) +} + +pub(super) fn read_assets( + mut reader: T, +) -> Result>, ArchiveParsingError> { + 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_or_else( + || (0, hash(&file_path_bytes)), + |(folder_path, file_path)| (hash(&folder_path), hash(&file_path)), + ); + + let file_hashes: &mut BTreeSet = assets.entry(folder_hash).or_default(); + + if !file_hashes.insert(file_hash) { + return Err(ArchiveParsingError::HashCollision { + folder_hash, + file_hash, + }); + } + } + + 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 mut iter = slice.rsplitn(2, |b| *b == needle); + let second = iter.next()?; + let first = iter.next()?; + + Some((first, second)) +} + +fn hash(value: &T) -> u64 { + let mut hasher = DefaultHasher::new(); + value.hash(&mut hasher); + hasher.finish() +} diff --git a/src/archive/bsa.rs b/src/archive/bsa.rs new file mode 100644 index 00000000..04c7d07e --- /dev/null +++ b/src/archive/bsa.rs @@ -0,0 +1,212 @@ +use std::{ + collections::{BTreeMap, BTreeSet, btree_map::Entry}, + io::BufRead, +}; + +use super::error::ArchiveParsingError; + +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 = ArchiveParsingError; + + fn try_from(value: [u8; HEADER_SIZE - TYPE_ID.len()]) -> Result { + let header = Self { + type_id: TYPE_ID, + version: to_u32(&value, 0)?, + 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 to_usize(header.records_offset) != HEADER_SIZE { + return Err(ArchiveParsingError::InvalidRecordsOffset( + header.records_offset, + )); + } + + if (header.archive_flags & 0x40) != 0 { + return Err(ArchiveParsingError::UsesBigEndianNumbers); + } + + Ok(header) + } +} + +struct FolderRecord { + name_hash: u64, + file_count: u32, + file_records_offset: u32, +} + +// Also used for v104 BSAs. +mod v103 { + use crate::archive::{ + error::ArchiveParsingError, + parse::{to_u32, to_u64}, + }; + + use super::FolderRecord; + + pub(super) const FOLDER_RECORD_SIZE: usize = 16; + + pub(super) fn read_folder_record(value: &[u8]) -> Result { + if value.len() < FOLDER_RECORD_SIZE { + return Err(ArchiveParsingError::SliceTooSmall { + expected: FOLDER_RECORD_SIZE, + actual: value.len(), + }); + } + + Ok(FolderRecord { + name_hash: to_u64(value, 0)?, + file_count: to_u32(value, 8)?, + file_records_offset: to_u32(value, 12)?, + }) + } +} + +mod v105 { + use crate::archive::{ + error::ArchiveParsingError, + parse::{to_u32, to_u64}, + }; + + use super::FolderRecord; + + pub(super) const FOLDER_RECORD_SIZE: usize = 24; + + pub(super) fn read_folder_record(value: &[u8]) -> Result { + if value.len() < FOLDER_RECORD_SIZE { + return Err(ArchiveParsingError::SliceTooSmall { + expected: FOLDER_RECORD_SIZE, + actual: value.len(), + }); + } + + Ok(FolderRecord { + name_hash: to_u64(value, 0)?, + file_count: to_u32(value, 8)?, + file_records_offset: to_u32(value, 16)?, + }) + } +} + +pub(super) fn read_assets( + mut reader: T, +) -> Result>, ArchiveParsingError> { + 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::( + reader, + &header, + v103::read_folder_record, + ), + 105 => read_assets_with_header::( + reader, + &header, + v105::read_folder_record, + ), + _ => Err(ArchiveParsingError::UnsupportedHeaderVersion( + header.version, + )), + } +} + +fn read_assets_with_header( + mut reader: T, + header: &Header, + read_folder_record: impl Fn(&[u8]) -> Result, +) -> Result>, ArchiveParsingError> { + let mut folders_buffer: Vec = 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 = 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(ArchiveParsingError::FolderHashCollision( + folder_record.name_hash, + )); + } + + 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(ArchiveParsingError::InvalidFolderNameLengthOffset( + folder_name_length_offset, + )); + } + }; + + let Some(file_records_buffer) = file_records_buffer.get(file_records_offset..) else { + return Err(ArchiveParsingError::InvalidFileRecordsOffset( + file_records_offset, + )); + }; + + let file_hashes: &mut BTreeSet = 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, 0)?; + + if !file_hashes.insert(file_hash) { + return Err(ArchiveParsingError::HashCollision { + folder_hash: folder_record.name_hash, + file_hash, + }); + } + } + } + + Ok(assets) +} diff --git a/src/archive/error.rs b/src/archive/error.rs new file mode 100644 index 00000000..ca5caa05 --- /dev/null +++ b/src/archive/error.rs @@ -0,0 +1,112 @@ +use std::path::PathBuf; + +use crate::escape_ascii; + +#[derive(Debug)] +pub(crate) struct ArchivePathParsingError { + path: PathBuf, + error: ArchiveParsingError, +} + +impl ArchivePathParsingError { + pub(crate) fn new(path: PathBuf, error: ArchiveParsingError) -> Self { + Self { path, error } + } + + pub(crate) fn from_io_error(path: PathBuf, error: std::io::Error) -> Self { + Self { + path, + error: ArchiveParsingError::IoError(error), + } + } +} + +impl std::fmt::Display for ArchivePathParsingError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "failed to parse the archive at \"{}\"", + escape_ascii(&self.path) + ) + } +} + +impl std::error::Error for ArchivePathParsingError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(&self.error) + } +} + +#[derive(Debug)] +pub(crate) enum ArchiveParsingError { + IoError(std::io::Error), + UnsupportedHeaderVersion(u32), + UnsupportedHeaderArchiveType([u8; 4]), + UnsupportedArchiveTypeId([u8; 4]), + InvalidRecordsOffset(u32), + InvalidFolderNameLengthOffset(usize), + InvalidFileRecordsOffset(usize), + UsesBigEndianNumbers, + FolderHashCollision(u64), + HashCollision { folder_hash: u64, file_hash: u64 }, + SliceTooSmall { expected: usize, actual: usize }, +} + +impl std::fmt::Display for ArchiveParsingError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::IoError(_) => write!(f, "an I/O error occurred"), + Self::UnsupportedHeaderVersion(v) => write!(f, "unsupported archive version {v}"), + Self::UnsupportedHeaderArchiveType(a) => { + write!(f, "unsupported archive type {}", a.escape_ascii()) + } + Self::UnsupportedArchiveTypeId(t) => { + write!(f, "unsupported archive type ID {}", t.escape_ascii()) + } + Self::InvalidRecordsOffset(o) => write!(f, "invalid records offset {o}"), + Self::InvalidFolderNameLengthOffset(o) => { + write!(f, "invalid folder name length offset {o}") + } + Self::InvalidFileRecordsOffset(o) => write!(f, "invalid file records offset {o}"), + Self::UsesBigEndianNumbers => { + write!(f, "archive uses big-endian numbers, which is unsupported") + } + Self::FolderHashCollision(h) => { + write!(f, "unexpected collision for folder name hash {h:x}") + } + Self::HashCollision { + folder_hash, + file_hash, + } => write!( + f, + "unexpected collision for file name hash {file_hash:x} in set for folder name hash {folder_hash:x}" + ), + Self::SliceTooSmall { expected, actual } => write!( + f, + "byte slice was unexpectedly too small: expected {expected} bytes, got {actual} bytes" + ), + } + } +} + +impl std::error::Error for ArchiveParsingError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::IoError(e) => Some(e), + _ => None, + } + } +} + +impl From for ArchiveParsingError { + fn from(value: std::io::Error) -> Self { + ArchiveParsingError::IoError(value) + } +} + +pub(super) fn slice_too_small(slice: &[u8], expected_size: usize) -> ArchiveParsingError { + ArchiveParsingError::SliceTooSmall { + expected: expected_size, + actual: slice.len(), + } +} diff --git a/src/archive/find.rs b/src/archive/find.rs new file mode 100644 index 00000000..700a7c42 --- /dev/null +++ b/src/archive/find.rs @@ -0,0 +1,497 @@ +use std::path::{Path, PathBuf}; + +#[cfg(windows)] +use windows::Win32::Storage::FileSystem::BY_HANDLE_FILE_INFORMATION; + +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 { + match game_type { + GameType::Morrowind | GameType::OpenMW => Vec::new(), + + // Skyrim (non-SE) plugins can only load BSAs that have exactly the same + // basename, ignoring file extensions. + GameType::Skyrim => 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 " + // - Textures.bsa" (case-insensitively). This assumes that Skyrim VR + // works the same way as Skyrim SE. + GameType::SkyrimSE | GameType::SkyrimVR => find_associated_archives_with_suffixes(plugin_path, BSA_FILE_EXTENSION, &["", " - Textures"]), + + // Oblivion .esp files can load archives which begin with the plugin + // basename. + GameType::Oblivion | GameType::OblivionRemastered => { + if has_ascii_extension(plugin_path, "esp") { + find_associated_archives_with_arbitrary_suffixes(plugin_path, game_cache) + } else { + Vec::new() + } + }, + + // 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::Fallout3 | GameType::FalloutNV | GameType::Fallout4 | GameType::Fallout4VR => + find_associated_archives_with_arbitrary_suffixes(plugin_path, game_cache) + , + + // The game will load a BA2 that's suffixed with " - Voices_" + // where 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 { + 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 { + let Some(file_stem) = plugin_path.file_stem() else { + 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 { + let plugin_stem_len = match plugin_path.file_stem().and_then(|s| s.to_str()) { + Some(s) => s.len(), + None => return Vec::new(), + }; + let Some(plugin_extension) = plugin_path.extension() else { + return Vec::new(); + }; + + game_cache + .archives_iter() + .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 Some(archive_filename) = path.file_name().and_then(|s| s.to_str()) else { + 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. + if archive_filename.len() < plugin_stem_len { + return false; + } + + let Some(filename) = archive_filename.get(..plugin_stem_len) else { + return false; + }; + + let archive_plugin_path = plugin_path + .with_file_name(filename) + .with_extension(plugin_extension); + + are_file_paths_equivalent(&archive_plugin_path, plugin_path) + }) + .cloned() + .collect() +} + +#[cfg(windows)] +fn are_file_paths_equivalent(lhs: &Path, rhs: &Path) -> bool { + if lhs == rhs { + return true; + } + + let Some(lhs_info) = get_file_info(lhs) else { + return false; + }; + + let Some(rhs_info) = get_file_info(rhs) else { + return false; + }; + + lhs_info.dwVolumeSerialNumber == rhs_info.dwVolumeSerialNumber + && lhs_info.nFileIndexHigh == rhs_info.nFileIndexHigh + && lhs_info.nFileIndexLow == rhs_info.nFileIndexLow +} + +#[cfg(windows)] +fn get_file_info(file_path: &Path) -> Option { + use std::os::windows::io::AsRawHandle; + use windows::Win32::{Foundation::HANDLE, Storage::FileSystem::GetFileInformationByHandle}; + + let Ok(file) = std::fs::File::open(file_path) else { + return None; + }; + + let mut info = BY_HANDLE_FILE_INFORMATION::default(); + + // SAFETY: This is safe because the file handles and the info struct pointers are all valid until this function exits. + #[expect( + unsafe_code, + reason = "There is currently no way to get this data safely" + )] + unsafe { + GetFileInformationByHandle(HANDLE(file.as_raw_handle()), &mut info) + .is_ok() + .then_some(info) + } +} + +#[cfg(not(windows))] +fn are_file_paths_equivalent(lhs: &Path, rhs: &Path) -> bool { + use std::os::unix::fs::MetadataExt; + + if lhs == rhs { + return true; + } + + let Ok(lhs_metadata) = lhs.metadata() else { + return false; + }; + + let Ok(rhs_metadata) = rhs.metadata() else { + return false; + }; + + lhs_metadata.dev() == rhs_metadata.dev() && lhs_metadata.ino() == rhs_metadata.ino() +} + +#[cfg(test)] +mod tests { + use tempfile::tempdir; + + use super::*; + + mod find_associated_archives { + use std::path::absolute; + + use parameterized_test::parameterized_test; + use tempfile::TempDir; + + use super::*; + + use crate::tests::{ + ALL_GAME_TYPES, BLANK_DIFFERENT_ESM, BLANK_DIFFERENT_ESP, BLANK_ESM, BLANK_ESP, + BLANK_MASTER_DEPENDENT_ESM, copy_file, source_plugins_path, + }; + + const NON_ASCII_ESP: &str = "non\u{00C1}scii.esp"; + + struct Fixture { + _temp_dir: TempDir, + cache: GameCache, + data_path: PathBuf, + } + + impl Fixture { + pub fn new(game_type: GameType) -> Self { + let tmp_dir = tempdir().unwrap(); + + let mut cache = GameCache::default(); + + let data_path = tmp_dir.path().to_path_buf(); + + match game_type { + GameType::Morrowind | GameType::OpenMW => {} + GameType::Fallout4 | GameType::Fallout4VR | GameType::Starfield => { + let source = absolute("./testing-plugins/Fallout 4/Data").unwrap(); + copy_file(&source, &data_path, "Blank - Main.ba2"); + copy_file(&source, &data_path, "Blank - Textures.ba2"); + std::fs::copy( + source.join("Blank - Main.ba2"), + data_path.join("non\u{00C1}scii.ba2"), + ) + .unwrap(); + std::fs::copy( + source.join("Blank - Main.ba2"), + data_path.join("Blank - Different - Suffix.ba2"), + ) + .unwrap(); + + cache.set_archive_paths(vec![ + data_path.join("Blank - Main.ba2"), + data_path.join("Blank - Textures.ba2"), + data_path.join("non\u{00C1}scii.ba2"), + data_path.join("Blank - Different - Main.ba2"), + ]); + } + _ => { + let source = source_plugins_path(game_type); + copy_file(&source, &data_path, "Blank.bsa"); + std::fs::copy( + source.join("Blank.bsa"), + data_path.join("non\u{00C1}scii.bsa"), + ) + .unwrap(); + std::fs::copy( + source.join("Blank.bsa"), + data_path.join("Blank - Different - Main.bsa"), + ) + .unwrap(); + + cache.set_archive_paths(vec![ + data_path.join("Blank.bsa"), + data_path.join("non\u{00C1}scii.bsa"), + data_path.join("Blank - Different - Suffix.bsa"), + ]); + } + } + + Self { + _temp_dir: tmp_dir, + data_path, + cache, + } + } + } + + #[parameterized_test(ALL_GAME_TYPES)] + fn should_return_empty_vec_if_no_matching_archives_are_found(game_type: GameType) { + let fixture = Fixture::new(game_type); + + let archives = find_associated_archives( + game_type, + &fixture.cache, + &fixture.data_path.join(BLANK_MASTER_DEPENDENT_ESM), + ); + + assert!(archives.is_empty()); + } + + #[parameterized_test(ALL_GAME_TYPES)] + fn should_find_an_archive_that_exactly_matches_an_esm_file_basename_except_for_morrowind_and_oblivion( + game_type: GameType, + ) { + let fixture = Fixture::new(game_type); + + let archives = find_associated_archives( + game_type, + &fixture.cache, + &fixture.data_path.join(BLANK_ESM), + ); + + if matches!( + game_type, + GameType::Morrowind + | GameType::OpenMW + | GameType::Oblivion + | GameType::OblivionRemastered + ) { + assert!(archives.is_empty()); + } else { + assert!(!archives.is_empty()); + } + } + + #[parameterized_test(ALL_GAME_TYPES)] + fn should_find_an_archive_that_exactly_matches_a_non_ascii_esp_file_basename_except_for_morrowind_and_starfield( + game_type: GameType, + ) { + let fixture = Fixture::new(game_type); + + let archives = find_associated_archives( + game_type, + &fixture.cache, + &fixture.data_path.join(NON_ASCII_ESP), + ); + + if matches!( + game_type, + GameType::Morrowind | GameType::OpenMW | GameType::Starfield + ) { + assert!(archives.is_empty()); + } else { + assert!(!archives.is_empty()); + } + } + + #[parameterized_test(ALL_GAME_TYPES)] + fn should_find_an_archive_that_starts_with_an_esp_file_basename_except_for_morrowind_and( + game_type: GameType, + ) { + let fixture = Fixture::new(game_type); + + let archives = find_associated_archives( + game_type, + &fixture.cache, + &fixture.data_path.join(BLANK_ESP), + ); + + if matches!(game_type, GameType::Morrowind | GameType::OpenMW) { + assert!(archives.is_empty()); + } else { + assert!(!archives.is_empty()); + } + } + + #[parameterized_test(ALL_GAME_TYPES)] + fn should_find_an_archive_that_starts_with_an_esm_file_basename_only_for_fallout( + game_type: GameType, + ) { + let fixture = Fixture::new(game_type); + + let archives = find_associated_archives( + game_type, + &fixture.cache, + &fixture.data_path.join(BLANK_DIFFERENT_ESM), + ); + + if matches!( + game_type, + GameType::Fallout3 + | GameType::FalloutNV + | GameType::Fallout4 + | GameType::Fallout4VR + ) { + assert!(!archives.is_empty()); + } else { + assert!(archives.is_empty()); + } + } + + #[parameterized_test(ALL_GAME_TYPES)] + fn should_find_an_archive_that_starts_with_an_esp_file_basename_only_for_oblivion_and_fallout( + game_type: GameType, + ) { + let fixture = Fixture::new(game_type); + + let archives = find_associated_archives( + game_type, + &fixture.cache, + &fixture.data_path.join(BLANK_DIFFERENT_ESP), + ); + + if matches!( + game_type, + GameType::Oblivion + | GameType::OblivionRemastered + | GameType::Fallout3 + | GameType::FalloutNV + | GameType::Fallout4 + | GameType::Fallout4VR + ) { + assert!(!archives.is_empty()); + } else { + assert!(archives.is_empty()); + } + } + } + + mod are_file_paths_equivalent { + use super::*; + + #[test] + fn should_be_true_if_given_equal_paths_that_exist() { + let file_path = Path::new("README.md"); + + assert!(file_path.exists()); + assert!(are_file_paths_equivalent(file_path, file_path)); + } + + #[test] + fn should_be_true_if_given_equal_paths_that_do_not_exist() { + let file_path = Path::new("missing"); + + assert!(!file_path.exists()); + assert!(are_file_paths_equivalent(file_path, file_path)); + } + + #[test] + fn should_be_false_if_given_case_insensitively_equal_paths_that_do_not_exist() { + let file_path1 = Path::new("missing"); + let file_path2 = Path::new("MISSING"); + + assert!(!file_path1.exists()); + assert!(!file_path2.exists()); + assert!(!are_file_paths_equivalent(file_path1, file_path2)); + } + + #[test] + fn should_be_false_if_given_case_insensitively_unequal_paths_that_exist() { + let file_path1 = Path::new("README.md"); + let file_path2 = Path::new("LICENSE"); + + assert!(file_path1.exists()); + assert!(file_path2.exists()); + assert!(!are_file_paths_equivalent(file_path1, file_path2)); + } + + #[test] + #[cfg(windows)] + fn should_be_true_if_given_case_insensitively_equal_paths_that_exist() { + let file_path1 = Path::new("README.md"); + let file_path2 = Path::new("readme.md"); + + assert!(file_path1.exists()); + assert!(file_path2.exists()); + assert!(are_file_paths_equivalent(file_path1, file_path2)); + } + + #[test] + #[cfg(windows)] + fn should_be_true_if_equal_paths_have_characters_that_are_unrepresentable_in_the_system_multi_byte_code_page() + { + let file_path = + Path::new("\u{2551}\u{00BB}\u{00C1}\u{2510}\u{2557}\u{00FE}\u{00C3}\u{00CE}.txt"); + + assert!(are_file_paths_equivalent(file_path, file_path)); + } + + #[test] + #[cfg(windows)] + fn should_be_false_if_case_insensitively_equal_paths_have_characters_that_are_unrepresentable_in_the_system_multi_byte_code_page_and_do_not_exist() + { + let file_path1 = + Path::new("\u{2551}\u{00BB}\u{00C1}\u{2510}\u{2557}\u{00FE}\u{00E3}\u{00CE}.txt"); + let file_path2 = + Path::new("\u{2551}\u{00BB}\u{00C1}\u{2510}\u{2557}\u{00FE}\u{00C3}\u{00CE}.txt"); + + assert!(!are_file_paths_equivalent(file_path1, file_path2)); + } + + #[test] + #[cfg(not(windows))] + fn should_be_false_if_given_case_insensitively_equal_paths_that_exist() { + let tmp_dir = tempdir().unwrap(); + let file_path1 = tmp_dir.path().join("test"); + let file_path2 = tmp_dir.path().join("TEST"); + + std::fs::File::create(&file_path1).unwrap(); + std::fs::File::create(&file_path2).unwrap(); + + assert!(file_path1.exists()); + assert!(file_path2.exists()); + assert!(!are_file_paths_equivalent(&file_path1, &file_path2)); + } + } +} diff --git a/src/archive/mod.rs b/src/archive/mod.rs new file mode 100644 index 00000000..936a3ae9 --- /dev/null +++ b/src/archive/mod.rs @@ -0,0 +1,69 @@ +mod ba2; +mod bsa; +mod error; +mod find; +mod parse; + +use std::collections::{BTreeMap, BTreeSet}; + +pub use find::find_associated_archives; +pub use parse::assets_in_archives; + +pub fn do_assets_overlap( + assets: &BTreeMap>, + other_assets: &BTreeMap>, +) -> bool { + let mut assets_iter = assets.iter(); + let mut other_assets_iter = other_assets.iter(); + + let mut assets = assets_iter.next(); + let mut other_assets = other_assets_iter.next(); + while let (Some((folder, files)), Some((other_folder, other_files))) = (assets, other_assets) { + if folder < other_folder { + assets = assets_iter.next(); + } else if folder > other_folder { + other_assets = other_assets_iter.next(); + } else if files.intersection(other_files).next().is_some() { + return true; + } else { + // The folder hashes are equal but they don't contain any of the same + // file hashes, move on to the next folder. It doesn't matter which + // iterator gets incremented. + assets = assets_iter.next(); + } + } + + false +} + +#[cfg(test)] +mod tests { + use super::*; + + mod do_assets_overlap { + use std::path::PathBuf; + + use super::*; + + #[test] + fn should_return_true_if_the_same_file_exists_in_the_same_folder() { + let path = PathBuf::from("./testing-plugins/Oblivion/Data/Blank.bsa"); + let assets = assets_in_archives(&[path]); + + assert!(do_assets_overlap(&assets, &assets)); + } + + #[test] + fn should_return_false_if_the_same_file_exists_in_different_folders() { + let path = PathBuf::from("./testing-plugins/Oblivion/Data/Blank.bsa"); + let assets1 = assets_in_archives(&[path]); + + let path = PathBuf::from("./testing-plugins/Skyrim/Data/Blank.bsa"); + let assets2 = assets_in_archives(&[path]); + + assert_eq!(assets1.get(&0), assets2.get(&0x2E01_002E)); + + assert!(!do_assets_overlap(&assets1, &assets2)); + } + } +} diff --git a/src/archive/parse.rs b/src/archive/parse.rs new file mode 100644 index 00000000..49e4538a --- /dev/null +++ b/src/archive/parse.rs @@ -0,0 +1,324 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + fs::File, + io::{BufReader, Read}, + path::{Path, PathBuf}, +}; + +use super::error::{ArchiveParsingError, ArchivePathParsingError}; +use crate::{ + archive::error::slice_too_small, + escape_ascii, + logging::{self, format_details}, + plugin::has_ascii_extension, +}; + +use super::{ba2, bsa}; + +pub fn assets_in_archives(archive_paths: &[PathBuf]) -> BTreeMap> { + let mut archive_assets: BTreeMap> = BTreeMap::new(); + + for archive_path in archive_paths { + logging::trace!( + "Getting assets loaded from the Bethesda archive at \"{}\"", + escape_ascii(archive_path) + ); + + let assets = match get_assets_in_archive(archive_path) { + Ok(a) => a, + Err(e) => { + logging::error!( + "Encountered an error while trying to read the Bethesda archive at \"{}\": {}", + escape_ascii(archive_path), + format_details(&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 { + logging::warn!( + "The folder and file with hashes {:x} and {:x} in \"{}\" are present in another Bethesda archive.", + folder_hash, + file_hash, + escape_ascii(archive_path) + ); + } + } + } + } + + 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>, ArchivePathParsingError> { + let file = File::open(archive_path) + .map_err(|e| ArchivePathParsingError::from_io_error(archive_path.into(), e))?; + let mut reader = BufReader::new(file); + + let mut type_id: [u8; 4] = [0; 4]; + reader + .read_exact(&mut type_id) + .map_err(|e| ArchivePathParsingError::from_io_error(archive_path.into(), e))?; + + match type_id { + bsa::TYPE_ID => bsa::read_assets(reader) + .map_err(|e| ArchivePathParsingError::new(archive_path.into(), e)), + ba2::TYPE_ID => ba2::read_assets(reader) + .map_err(|e| ArchivePathParsingError::new(archive_path.into(), e)), + _ => Err(ArchivePathParsingError::new( + archive_path.into(), + ArchiveParsingError::UnsupportedArchiveTypeId(type_id), + )), + } +} + +pub(super) fn to_u32(bytes: &[u8], start_index: usize) -> Result { + const ARRAY_SIZE: usize = to_usize(u32::BITS >> 3); + subarray::(bytes, start_index).map(u32::from_le_bytes) +} + +pub(super) fn to_u64(bytes: &[u8], start_index: usize) -> Result { + const ARRAY_SIZE: usize = to_usize(u64::BITS >> 3); + subarray::(bytes, start_index).map(u64::from_le_bytes) +} + +fn subarray( + bytes: &[u8], + start_index: usize, +) -> Result<[u8; SIZE], ArchiveParsingError> { + let stop_index = start_index + SIZE; + + let bytes = bytes + .get(start_index..stop_index) + .ok_or_else(|| slice_too_small(bytes, stop_index))?; + + <[u8; SIZE]>::try_from(bytes).map_err(|_e| slice_too_small(bytes, SIZE)) +} + +#[expect( + clippy::as_conversions, + reason = "A compile-time assertion ensures that this conversion will be lossless on all relevant target platforms" +)] +pub(super) const fn to_usize(value: u32) -> usize { + // Error at compile time if this conversion isn't lossless. + const _: () = assert!(u32::BITS <= usize::BITS, "cannot fit a u32 into a usize!"); + value as usize +} + +#[cfg(test)] +mod tests { + use super::*; + + mod get_assets_in_archive { + use std::{ + hash::{DefaultHasher, Hash, Hasher}, + io::SeekFrom, + }; + + use parameterized_test::{parameterized_test, test_parameter}; + use tempfile::tempdir; + + use super::*; + + fn hash(value: T) -> u64 { + let mut hasher = DefaultHasher::new(); + value.hash(&mut hasher); + hasher.finish() + } + + #[test] + fn should_error_if_file_cannot_be_opened() { + let path = Path::new("./invalid.bsa"); + assert!(get_assets_in_archive(path).is_err()); + } + + #[test] + fn should_support_v103_bsas() { + let path = Path::new("./testing-plugins/Oblivion/Data/Blank.bsa"); + let assets = get_assets_in_archive(path).unwrap(); + + let files_count: usize = assets.values().map(BTreeSet::len).sum(); + + let expected_key = 0; + assert_eq!(1, assets.len()); + assert_eq!(1, files_count); + assert_eq!(expected_key, *assets.first_key_value().unwrap().0); + assert_eq!(1, assets[&expected_key].len()); + assert_eq!( + 0x4670_B683_6C07_7365, + *assets[&expected_key].first().unwrap() + ); + } + + #[test] + fn should_support_v104_bsas() { + let path = Path::new("./testing-plugins/Skyrim/Data/Blank.bsa"); + let assets = get_assets_in_archive(path).unwrap(); + + let files_count: usize = assets.values().map(BTreeSet::len).sum(); + + let expected_key = 0x2E01_002E; + assert_eq!(1, assets.len()); + assert_eq!(1, files_count); + assert_eq!(expected_key, *assets.first_key_value().unwrap().0); + assert_eq!(1, assets[&expected_key].len()); + assert_eq!( + 0x4670_B683_6C07_7365, + *assets[&expected_key].first().unwrap() + ); + } + + #[test] + fn should_support_v105_bsas() { + let path = Path::new("./testing-plugins/SkyrimSE/Data/Blank.bsa"); + let assets = get_assets_in_archive(path).unwrap(); + + let files_count: usize = assets.values().map(BTreeSet::len).sum(); + + let expected_key = 0xB681_02C9_6417_6E73; + assert_eq!(1, assets.len()); + assert_eq!(1, files_count); + assert_eq!(expected_key, *assets.first_key_value().unwrap().0); + assert_eq!(1, assets[&expected_key].len()); + assert_eq!( + 0x4670_B683_6C07_7365, + *assets[&expected_key].first().unwrap() + ); + } + + #[test] + fn should_support_general_ba2s() { + let path = Path::new("./testing-plugins/Fallout 4/Data/Blank - Main.ba2"); + let assets = get_assets_in_archive(path).unwrap(); + + let files_count: usize = assets.values().map(BTreeSet::len).sum(); + + let expected_key = hash("dev\\git\\testing-plugins".as_bytes()); + let expected_file_hash = hash("license.txt".as_bytes()); + + assert_eq!(1, assets.len()); + assert_eq!(1, files_count); + + let (key, value) = assets.first_key_value().unwrap(); + assert_eq!(expected_key, *key); + assert_eq!(1, value.len()); + assert_eq!(expected_file_hash, *value.first().unwrap()); + } + + #[test] + fn should_support_texture_ba2s() { + let path = Path::new("./testing-plugins/Fallout 4/Data/Blank - Textures.ba2"); + let assets = get_assets_in_archive(path).unwrap(); + + let files_count: usize = assets.values().map(BTreeSet::len).sum(); + + let expected_key = hash("dev\\git\\testing-plugins".as_bytes()); + let expected_file_hash = hash("blank.dds".as_bytes()); + + assert_eq!(1, assets.len()); + assert_eq!(1, files_count); + + let (key, value) = assets.first_key_value().unwrap(); + assert_eq!(expected_key, *key); + assert_eq!(1, value.len()); + assert_eq!(expected_file_hash, *value.first().unwrap()); + } + + #[test_parameter] + const BA2_VERSIONS: [u32; 5] = [1, 2, 3, 7, 8]; + + #[parameterized_test(BA2_VERSIONS)] + fn should_support_ba2_versions(version: u32) { + use std::io::{Seek, Write}; + + let tmp_dir = tempdir().unwrap(); + let path = tmp_dir.path().join("test.ba2"); + + std::fs::copy("./testing-plugins/Fallout 4/Data/Blank - Main.ba2", &path).unwrap(); + + { + let mut file = File::options().write(true).open(&path).unwrap(); + file.seek(SeekFrom::Start(4)).unwrap(); + file.write_all(&version.to_le_bytes()).unwrap(); + } + + let assets = get_assets_in_archive(&path).unwrap(); + assert!(!assets.is_empty()); + } + } + + mod assets_in_archives { + use super::*; + + #[test] + fn should_skip_files_that_cannot_be_read() { + let paths = [ + PathBuf::from("invalid.bsa"), + PathBuf::from("./testing-plugins/Skyrim/Data/Blank.bsa"), + ]; + + let assets = assets_in_archives(&paths); + + let files_count: usize = assets.values().map(BTreeSet::len).sum(); + + assert_eq!(1, assets.len()); + assert_eq!(1, files_count); + + let (key, value) = assets.first_key_value().unwrap(); + assert_eq!(0x2E01_002E, *key); + assert_eq!(1, value.len()); + assert_eq!(0x4670_B683_6C07_7365, *value.first().unwrap()); + } + + #[test] + fn should_combine_assets_from_each_loaded_archive() { + let paths = [ + PathBuf::from("./testing-plugins/Oblivion/Data/Blank.bsa"), + PathBuf::from("./testing-plugins/Skyrim/Data/Blank.bsa"), + PathBuf::from("./testing-plugins/SkyrimSE/Data/Blank.bsa"), + ]; + + let assets = assets_in_archives(&paths); + + let files_count: usize = assets.values().map(BTreeSet::len).sum(); + + assert_eq!(3, assets.len()); + assert_eq!(3, files_count); + + let value = &assets[&0]; + assert_eq!(1, value.len()); + assert_eq!(0x4670_B683_6C07_7365, *value.first().unwrap()); + + let value = &assets[&0x2E01_002E]; + assert_eq!(1, value.len()); + assert_eq!(0x4670_B683_6C07_7365, *value.first().unwrap()); + + let value = &assets[&0xB681_02C9_6417_6E73]; + assert_eq!(1, value.len()); + assert_eq!(0x4670_B683_6C07_7365, *value.first().unwrap()); + } + } +} diff --git a/src/database/conditions.rs b/src/database/conditions.rs new file mode 100644 index 00000000..0c86d7df --- /dev/null +++ b/src/database/conditions.rs @@ -0,0 +1,191 @@ +use std::str::FromStr; + +use loot_condition_interpreter::Expression; + +use crate::metadata::{File, PluginCleaningData, PluginMetadata}; + +pub fn evaluate_all_conditions( + mut metadata: PluginMetadata, + state: &loot_condition_interpreter::State, +) -> Result, 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::, _>>()?, + ); + + metadata.set_tags( + metadata + .tags() + .iter() + .filter_map(|t| filter_map_on_condition(t, t.condition(), state)) + .collect::, _>>()?, + ); + + 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)) + } +} + +pub fn evaluate_condition( + condition: &str, + state: &loot_condition_interpreter::State, +) -> Result { + Expression::from_str(condition).and_then(|e| e.eval(state)) +} + +fn evaluate_condition_option( + condition: Option<&str>, + state: &loot_condition_interpreter::State, +) -> Result { + if let Some(condition) = condition { + evaluate_condition(condition, state) + } else { + Ok(true) + } +} + +pub fn filter_map_on_condition( + item: &T, + condition: Option<&str>, + state: &loot_condition_interpreter::State, +) -> Option> { + evaluate_condition_option(condition, state) + .map(|r| r.then(|| item.clone())) + .transpose() +} + +fn filter_files_on_conditions( + files: &[File], + state: &loot_condition_interpreter::State, +) -> Result, loot_condition_interpreter::Error> { + files + .iter() + .filter_map(|file| filter_map_on_condition(file, file.condition(), state)) + .collect() +} + +fn filter_cleaning_data_on_conditions( + plugin_name: &str, + cleaning_info: &[PluginCleaningData], + state: &loot_condition_interpreter::State, +) -> Result, 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() +} + +#[cfg(test)] +mod tests { + use super::*; + + mod evaluate_all_conditions { + use crate::{ + metadata::{Message, MessageType, Tag, TagSuggestion}, + tests::{BLANK_DIFFERENT_ESM, BLANK_ESM, BLANK_ESP, source_plugins_path}, + }; + + use super::*; + + #[test] + fn should_evaluate_all_conditions_on_metadata_in_plugin_metadata_object() { + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin.set_group("group1".into()); + + let condition = "file(\"missing.esp\")".to_owned(); + let files = vec![ + File::new(BLANK_ESP.into()), + File::new(BLANK_DIFFERENT_ESM.into()).with_condition(condition.clone()), + ]; + plugin.set_load_after_files(files.clone()); + plugin.set_requirements(files.clone()); + plugin.set_incompatibilities(files.clone()); + + let message1 = Message::new(MessageType::Say, "content1".into()); + let message2 = + Message::new(MessageType::Say, "content2".into()).with_condition(condition.clone()); + plugin.set_messages(vec![message1.clone(), message2]); + + let tag1 = Tag::new("Delev".into(), TagSuggestion::Addition); + let tag2 = + Tag::new("Relev".into(), TagSuggestion::Addition).with_condition(condition.clone()); + plugin.set_tags(vec![tag1.clone(), tag2]); + + let info1 = PluginCleaningData::new(0x374E_2A6F, "utility1".into()); + let info2 = PluginCleaningData::new(0xDEAD_BEEF, "utility2".into()); + plugin.set_dirty_info(vec![info1.clone(), info2.clone()]); + plugin.set_clean_info(vec![info1.clone(), info2.clone()]); + + let state = loot_condition_interpreter::State::new( + loot_condition_interpreter::GameType::Oblivion, + source_plugins_path(crate::GameType::Oblivion), + ); + let result = evaluate_all_conditions(plugin, &state).unwrap().unwrap(); + + let expected_files = &[files[0].clone()]; + let expected_info = &[info1]; + assert_eq!("group1", result.group().unwrap()); + assert_eq!(expected_files, result.load_after_files()); + assert_eq!(expected_files, result.requirements()); + assert_eq!(expected_files, result.incompatibilities()); + assert_eq!(&[message1], result.messages()); + assert_eq!(&[tag1], result.tags()); + assert_eq!(expected_info, result.dirty_info()); + assert_eq!(expected_info, result.clean_info()); + } + + #[test] + fn should_return_none_if_evaluated_plugin_metadata_has_name_only() { + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + + let file = File::new(BLANK_DIFFERENT_ESM.into()) + .with_condition("file(\"missing.esp\")".into()); + plugin.set_load_after_files(vec![file]); + + let state = loot_condition_interpreter::State::new( + loot_condition_interpreter::GameType::Oblivion, + source_plugins_path(crate::GameType::Oblivion), + ); + assert!(evaluate_all_conditions(plugin, &state).unwrap().is_none()); + } + } +} diff --git a/src/database/error.rs b/src/database/error.rs new file mode 100644 index 00000000..7e688687 --- /dev/null +++ b/src/database/error.rs @@ -0,0 +1,58 @@ +use crate::metadata::error::RegexError; + +/// Represents an error that occurred while evaluating a metadata condition. +#[derive(Debug)] +pub struct ConditionEvaluationError(Box); + +impl std::fmt::Display for ConditionEvaluationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "failed to evaluate condition") + } +} + +impl std::error::Error for ConditionEvaluationError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(&self.0) + } +} + +impl From for ConditionEvaluationError { + fn from(value: loot_condition_interpreter::Error) -> Self { + ConditionEvaluationError(Box::new(value)) + } +} + +/// Represents an error that occurred while retrieving metadata for a plugin. +#[derive(Debug)] +#[non_exhaustive] +pub enum MetadataRetrievalError { + ConditionEvaluationError(ConditionEvaluationError), + RegexError(RegexError), +} + +impl std::fmt::Display for MetadataRetrievalError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "failed to retrieve metadata") + } +} + +impl std::error::Error for MetadataRetrievalError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::ConditionEvaluationError(e) => Some(e), + Self::RegexError(e) => Some(e), + } + } +} + +impl From for MetadataRetrievalError { + fn from(value: loot_condition_interpreter::Error) -> Self { + MetadataRetrievalError::ConditionEvaluationError(value.into()) + } +} + +impl From for MetadataRetrievalError { + fn from(value: RegexError) -> Self { + MetadataRetrievalError::RegexError(value) + } +} diff --git a/src/database/mod.rs b/src/database/mod.rs new file mode 100644 index 00000000..8e6ca4b2 --- /dev/null +++ b/src/database/mod.rs @@ -0,0 +1,1288 @@ +mod conditions; +mod error; + +use std::{collections::HashMap, path::Path}; + +use conditions::{evaluate_all_conditions, evaluate_condition, filter_map_on_condition}; + +use crate::{ + logging, + metadata::{ + Group, Message, PluginMetadata, + error::{LoadMetadataError, WriteMetadataError, WriteMetadataErrorReason}, + metadata_document::MetadataDocument, + }, + sorting::{ + error::GroupsPathError, + groups::{build_groups_graph, find_path}, + vertex::Vertex, + }, +}; +pub use error::{ConditionEvaluationError, MetadataRetrievalError}; + +/// Control behaviour when writing to files. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub enum WriteMode { + /// Create the file if it does not exist, otherwise error. + Create, + /// Create the file if it does not exist, otherwise replace its contents. + CreateOrTruncate, +} + +/// 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 + } + + pub(crate) fn clear_condition_cache(&mut self) { + if let Err(e) = self.condition_evaluator_state.clear_condition_cache() { + logging::error!("The condition cache's lock is poisoned, assigning a new cache"); + *e.into_inner() = HashMap::new(); + } + } + + /// 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<(), LoadMetadataError> { + self.masterlist.load(path) + } + + /// 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 + /// and prelude. + pub fn load_masterlist_with_prelude( + &mut self, + masterlist_path: &Path, + prelude_path: &Path, + ) -> Result<(), LoadMetadataError> { + 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<(), LoadMetadataError> { + self.userlist.load(path) + } + + /// 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, + mode: WriteMode, + ) -> Result<(), WriteMetadataError> { + validate_write_path(output_path, mode)?; + + 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, + mode: WriteMode, + ) -> Result<(), WriteMetadataError> { + validate_write_path(output_path, mode)?; + + let mut doc = MetadataDocument::default(); + + for plugin in self.masterlist.plugins_iter() { + let Ok(mut minimal_plugin) = PluginMetadata::new(plugin.name()) else { + // This should never happen because the regex plugin name from + // an existing PluginMetadata object should be valid. + logging::error!( + "Unexpectedly encountered a regex error trying to create a PluginMetadata object with the name {}", + plugin.name() + ); + continue; + }; + 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) + } + + /// Evaluate the given condition string. + pub fn evaluate(&self, condition: &str) -> Result { + evaluate_condition(condition, &self.condition_evaluator_state).map_err(Into::into) + } + + /// 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 { + 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, ConditionEvaluationError> { + if evaluate_conditions { + self.clear_condition_cache(); + } + + let messages_iter = self + .masterlist + .messages() + .iter() + .chain(self.userlist.messages()); + + if evaluate_conditions { + let messages = messages_iter + .filter_map(|m| { + filter_map_on_condition(m, m.condition(), &self.condition_evaluator_state) + }) + .collect::, _>>()?; + + 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 { + 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) { + 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, GroupsPathError> { + let graph = build_groups_graph(self.masterlist.groups(), self.userlist.groups())?; + + let path = find_path(&graph, from_group_name, to_group_name)?; + + Ok(path) + } + + /// 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, MetadataRetrievalError> { + 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, MetadataRetrievalError> { + let metadata = self.userlist.find_plugin(plugin_name)?; + + if evaluate_conditions { + if let Some(metadata) = metadata { + return evaluate_all_conditions(metadata, &self.condition_evaluator_state) + .map_err(Into::into); + } + } + + Ok(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, mode: WriteMode) -> Result<(), WriteMetadataError> { + if !output_path.parent().is_some_and(Path::exists) { + Err(WriteMetadataError::new( + output_path.into(), + WriteMetadataErrorReason::ParentDirectoryNotFound, + )) + } else if mode == WriteMode::Create && output_path.exists() { + Err(WriteMetadataError::new( + output_path.into(), + WriteMetadataErrorReason::PathAlreadyExists, + )) + } else { + Ok(()) + } +} + +fn merge_groups(lhs: &[Group], rhs: &[Group]) -> Vec { + 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_owned()); + } + + 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 +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use crate::{ + EdgeType, GameType, + metadata::{File, MessageType}, + tests::{BLANK_DIFFERENT_ESM, BLANK_ESM, BLANK_MASTER_DEPENDENT_ESM}, + }; + + use super::*; + + struct Fixture { + inner: crate::tests::Fixture, + prelude_path: PathBuf, + metadata_path: PathBuf, + } + + impl Fixture { + fn new(game_type: GameType) -> Self { + let inner = crate::tests::Fixture::new(game_type); + + let prelude = "- &preludeBashTag Actors.ACBS"; + let prelude_path = inner.local_path.join("prelude.yaml"); + std::fs::write(&prelude_path, prelude).unwrap(); + + let metadata = " +prelude: + - &preludeBashTag C.Climate +bash_tags: + - *preludeBashTag +globals: + - type: say + content: 'A general message' + condition: 'file(\"missing.esp\")' +groups: + - name: group1 + - name: group2 + after: + - group1 +plugins: + - name: Blank.esm + after: + - Oblivion.esm + msg: + - type: say + content: 'A note message' + condition: 'file(\"missing.esp\")' + tag: + - Actors.ACBS + - Actors.AIData + - '-C.Water' + - name: Blank - Different.esm + after: + - Blank - Master Dependent.esm + msg: + - type: warn + content: 'A warning message' + dirty: + - crc: 0x7d22f9df + util: TES4Edit + udr: 4 + - name: Blank - Different.esp + after: + - Blank - Plugin Dependent.esp + msg: + - type: error + content: 'An error message' + - name: Blank.esp + after: + - Blank - Different Master Dependent.esp + - name: Blank - Different Master Dependent.esp + after: + - Blank - Master Dependent.esp + msg: + - type: say + content: 'A note message' + - type: warn + content: 'A warning message' + - type: error + content: 'An error message'"; + let metadata_path = inner.local_path.join("metadata.yaml"); + std::fs::write(&metadata_path, metadata).unwrap(); + + Self { + inner, + prelude_path, + metadata_path, + } + } + + fn database(&self) -> Database { + Database::new(loot_condition_interpreter::State::new( + self.inner.game_type.into(), + self.inner.data_path(), + )) + } + } + + #[test] + fn load_masterlist_should_succeed_if_given_a_valid_path() { + let fixture = Fixture::new(GameType::Oblivion); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + assert_eq!(&["C.Climate"], database.known_bash_tags().as_slice()); + } + + #[test] + fn load_masterlist_with_prelude_should_succeed_if_given_valid_paths() { + let fixture = Fixture::new(GameType::Oblivion); + let mut database = fixture.database(); + + database + .load_masterlist_with_prelude(&fixture.metadata_path, &fixture.prelude_path) + .unwrap(); + + assert_eq!(&["Actors.ACBS"], database.known_bash_tags().as_slice()); + } + + #[test] + fn load_userlist_should_succeed_if_given_a_valid_path() { + let fixture = Fixture::new(GameType::Oblivion); + let mut database = fixture.database(); + + database.load_userlist(&fixture.metadata_path).unwrap(); + + assert_eq!(&["C.Climate"], database.known_bash_tags().as_slice()); + assert_eq!( + &[ + Group::default(), + Group::new("group1".into()), + Group::new("group2".into()).with_after_groups(vec!["group1".into()]) + ], + database.user_groups() + ); + } + + mod write_user_metadata { + use super::*; + + #[test] + fn should_write_only_user_metadata() { + let fixture = Fixture::new(GameType::Oblivion); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + database.set_user_groups(vec![Group::new("group3".into())]); + + let output_path = fixture.inner.local_path.join("userlist.yaml"); + database + .write_user_metadata(&output_path, WriteMode::Create) + .unwrap(); + + let content = std::fs::read_to_string(output_path).unwrap(); + + assert_eq!("groups:\n - name: 'default'\n - name: 'group3'", content); + } + + #[test] + fn should_succeed_if_the_path_does_not_exist() { + let fixture = Fixture::new(GameType::Oblivion); + let database = fixture.database(); + let output_path = fixture.inner.local_path.join("userlist.yaml"); + + assert!( + database + .write_user_metadata(&output_path, WriteMode::Create) + .is_ok() + ); + } + + #[test] + fn should_succeed_if_the_path_does_not_exist_and_truncation_is_allowed() { + let fixture = Fixture::new(GameType::Oblivion); + let database = fixture.database(); + let output_path = fixture.inner.local_path.join("userlist.yaml"); + + assert!( + database + .write_user_metadata(&output_path, WriteMode::CreateOrTruncate) + .is_ok() + ); + } + + #[test] + fn should_succeed_if_the_path_exists_and_truncation_is_allowed() { + let fixture = Fixture::new(GameType::Oblivion); + let database = fixture.database(); + let output_path = fixture.inner.local_path.join("userlist.yaml"); + + std::fs::File::create(&output_path).unwrap(); + + assert!( + database + .write_user_metadata(&output_path, WriteMode::CreateOrTruncate) + .is_ok() + ); + } + + #[test] + fn should_error_if_the_parent_path_does_not_exist() { + let fixture = Fixture::new(GameType::Oblivion); + let database = fixture.database(); + let output_path = fixture.inner.local_path.join("missing/userlist.yaml"); + + assert!( + database + .write_user_metadata(&output_path, WriteMode::Create) + .is_err() + ); + } + + #[test] + fn should_error_if_the_path_is_read_only() { + let fixture = Fixture::new(GameType::Oblivion); + let database = fixture.database(); + let output_path = fixture.inner.local_path.join("userlist.yaml"); + + std::fs::File::create(&output_path).unwrap(); + + let mut permissions = output_path.metadata().unwrap().permissions(); + permissions.set_readonly(true); + std::fs::set_permissions(&output_path, permissions).unwrap(); + + assert!( + database + .write_user_metadata(&output_path, WriteMode::CreateOrTruncate) + .is_err() + ); + } + + #[test] + fn should_error_if_the_path_exists_and_truncation_is_not_allowed() { + let fixture = Fixture::new(GameType::Oblivion); + let database = fixture.database(); + let output_path = fixture.inner.local_path.join("userlist.yaml"); + + std::fs::File::create(&output_path).unwrap(); + + assert!( + database + .write_user_metadata(&output_path, WriteMode::Create) + .is_err() + ); + } + } + + mod write_minimal_list { + use super::*; + + #[test] + fn should_only_write_plugin_bash_tags_and_dirty_info() { + let fixture = Fixture::new(GameType::Oblivion); + let mut database = fixture.database(); + let output_path = fixture.inner.local_path.join("minimal.yaml"); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + assert!( + database + .write_minimal_list(&output_path, WriteMode::Create) + .is_ok() + ); + + let content = std::fs::read_to_string(output_path).unwrap(); + + // Plugin entries are unordered. + let expected_content = if content.find(BLANK_DIFFERENT_ESM) < content.find(BLANK_ESM) { + "plugins: + - name: 'Blank - Different.esm' + dirty: + - crc: 0x7D22F9DF + util: 'TES4Edit' + udr: 4 + - name: 'Blank.esm' + tag: + - Actors.ACBS + - Actors.AIData + - -C.Water" + } else { + "plugins: + - name: 'Blank.esm' + tag: + - Actors.ACBS + - Actors.AIData + - -C.Water + - name: 'Blank - Different.esm' + dirty: + - crc: 0x7D22F9DF + util: 'TES4Edit' + udr: 4" + }; + + assert_eq!(expected_content, content); + } + + #[test] + fn should_succeed_if_the_path_does_not_exist() { + let fixture = Fixture::new(GameType::Oblivion); + let database = fixture.database(); + let output_path = fixture.inner.local_path.join("minimal.yaml"); + + assert!( + database + .write_minimal_list(&output_path, WriteMode::Create) + .is_ok() + ); + } + + #[test] + fn should_succeed_if_the_path_does_not_exist_and_truncation_is_allowed() { + let fixture = Fixture::new(GameType::Oblivion); + let database = fixture.database(); + let output_path = fixture.inner.local_path.join("minimal.yaml"); + + assert!( + database + .write_minimal_list(&output_path, WriteMode::CreateOrTruncate) + .is_ok() + ); + } + + #[test] + fn should_succeed_if_the_path_exists_and_truncation_is_allowed() { + let fixture = Fixture::new(GameType::Oblivion); + let database = fixture.database(); + let output_path = fixture.inner.local_path.join("minimal.yaml"); + + std::fs::File::create(&output_path).unwrap(); + + assert!( + database + .write_minimal_list(&output_path, WriteMode::CreateOrTruncate) + .is_ok() + ); + } + + #[test] + fn should_error_if_the_parent_path_does_not_exist() { + let fixture = Fixture::new(GameType::Oblivion); + let database = fixture.database(); + let output_path = fixture.inner.local_path.join("missing/minimal.yaml"); + + assert!( + database + .write_minimal_list(&output_path, WriteMode::Create) + .is_err() + ); + } + + #[test] + fn should_error_if_the_path_is_read_only() { + let fixture = Fixture::new(GameType::Oblivion); + let database = fixture.database(); + let output_path = fixture.inner.local_path.join("minimal.yaml"); + + std::fs::File::create(&output_path).unwrap(); + + let mut permissions = output_path.metadata().unwrap().permissions(); + permissions.set_readonly(true); + std::fs::set_permissions(&output_path, permissions).unwrap(); + + assert!( + database + .write_minimal_list(&output_path, WriteMode::CreateOrTruncate) + .is_err() + ); + } + + #[test] + fn should_error_if_the_path_exists_and_truncation_is_not_allowed() { + let fixture = Fixture::new(GameType::Oblivion); + let database = fixture.database(); + let output_path = fixture.inner.local_path.join("minimal.yaml"); + + std::fs::File::create(&output_path).unwrap(); + + assert!( + database + .write_minimal_list(&output_path, WriteMode::Create) + .is_err() + ); + } + } + + #[test] + fn known_bash_tags_should_append_userlist_tags_to_masterlist_tags() { + let fixture = Fixture::new(GameType::Oblivion); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + let userlist_path = fixture.inner.local_path.join("userlist.yaml"); + std::fs::write(&userlist_path, "bash_tags: [Relev, Delev]").unwrap(); + + database.load_userlist(&userlist_path).unwrap(); + + assert_eq!( + vec!["C.Climate", "Relev", "Delev"], + database.known_bash_tags() + ); + } + + mod general_messages { + use super::*; + + #[test] + fn should_append_userlist_messages_to_masterlist_messages() { + let fixture = Fixture::new(GameType::Oblivion); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + let userlist_path = fixture.inner.local_path.join("userlist.yaml"); + std::fs::write( + &userlist_path, + "globals: [{type: say, content: 'A user message'}]", + ) + .unwrap(); + + database.load_userlist(&userlist_path).unwrap(); + + assert_eq!( + &[ + Message::new(MessageType::Say, "A general message".into()) + .with_condition("file(\"missing.esp\")".into()), + Message::new(MessageType::Say, "A user message".into()) + ], + database.general_messages(false).unwrap().as_slice() + ); + } + + #[test] + fn should_filter_out_messages_with_false_conditions_when_evaluating_conditions() { + let fixture = Fixture::new(GameType::Oblivion); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + let userlist_path = fixture.inner.local_path.join("userlist.yaml"); + std::fs::write( + &userlist_path, + "globals: [{type: say, content: 'A user message'}]", + ) + .unwrap(); + + database.load_userlist(&userlist_path).unwrap(); + + assert_eq!( + &[Message::new(MessageType::Say, "A user message".into())], + database.general_messages(true).unwrap().as_slice() + ); + } + } + + mod evaluate { + use super::*; + + #[test] + fn should_return_true_if_the_condition_is_true() { + let fixture = Fixture::new(GameType::Oblivion); + let database = fixture.database(); + + assert!(database.evaluate("file(\"Blank.esp\")").unwrap()); + } + + #[test] + fn should_return_false_if_the_condition_is_false() { + let fixture = Fixture::new(GameType::Oblivion); + let database = fixture.database(); + + assert!(!database.evaluate("file(\"missing.esp\")").unwrap()); + } + } + + mod groups { + use super::*; + + #[test] + fn should_return_default_group_before_metadata_has_been_loaded() { + let fixture = Fixture::new(GameType::Oblivion); + let database = fixture.database(); + + assert_eq!(&[Group::default(),], database.groups(true).as_slice()); + } + + #[test] + fn should_not_include_user_groups_if_param_is_false() { + let fixture = Fixture::new(GameType::Oblivion); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + let userlist_path = fixture.inner.local_path.join("userlist.yaml"); + std::fs::write( + &userlist_path, + "groups: [{name: group2, after: [default]}, {name: group3, after: [group1]}]", + ) + .unwrap(); + + database.load_userlist(&userlist_path).unwrap(); + + assert_eq!( + &[ + Group::default(), + Group::new("group1".into()), + Group::new("group2".into()).with_after_groups(vec!["group1".into()]) + ], + database.groups(false).as_slice() + ); + } + + #[test] + fn should_merge_masterlist_and_userlist_groups() { + let fixture = Fixture::new(GameType::Oblivion); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + let userlist_path = fixture.inner.local_path.join("userlist.yaml"); + std::fs::write( + &userlist_path, + "groups: [{name: group2, after: [default]}, {name: group3, after: [group1]}]", + ) + .unwrap(); + + database.load_userlist(&userlist_path).unwrap(); + + assert_eq!( + &[ + Group::default(), + Group::new("group1".into()), + Group::new("group2".into()) + .with_after_groups(vec!["group1".into(), "default".into()]), + Group::new("group3".into()).with_after_groups(vec!["group1".into()]) + ], + database.groups(true).as_slice() + ); + } + } + + #[test] + fn user_groups_should_not_include_masterlist_groups() { + let fixture = Fixture::new(GameType::Oblivion); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + assert_eq!(&[Group::default(),], database.user_groups()); + } + + #[test] + fn set_user_groups_should_replace_existing_user_groups() { + let fixture = Fixture::new(GameType::Oblivion); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + let userlist_path = fixture.inner.local_path.join("userlist.yaml"); + std::fs::write( + &userlist_path, + "groups: [{name: group2, after: [default]}, {name: group3, after: [group1]}]", + ) + .unwrap(); + + database.load_userlist(&userlist_path).unwrap(); + + database.set_user_groups(vec![Group::new("group4".into())]); + + assert_eq!( + &[ + Group::default(), + Group::new("group1".into()), + Group::new("group2".into()).with_after_groups(vec!["group1".into()]) + ], + database.groups(false).as_slice() + ); + + assert_eq!( + &[Group::default(), Group::new("group4".into())], + database.user_groups() + ); + } + + #[test] + fn groups_path_should_find_path_using_masterlist_and_user_metadata() { + let fixture = Fixture::new(GameType::Oblivion); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + database.set_user_groups(vec![ + Group::new("group3".into()).with_after_groups(vec!["group2".into()]), + ]); + + let path = database.groups_path("group1", "group3").unwrap(); + + assert_eq!( + vec![ + Vertex::new("group1".into()).with_out_edge_type(EdgeType::MasterlistLoadAfter), + Vertex::new("group2".into()).with_out_edge_type(EdgeType::UserLoadAfter), + Vertex::new("group3".into()), + ], + path + ); + } + + mod plugin_metadata { + use super::*; + + #[test] + fn should_return_none_if_plugin_has_no_metadata_set() { + let fixture = Fixture::new(GameType::Oblivion); + let database = fixture.database(); + + assert!( + database + .plugin_metadata(BLANK_ESM, true, false) + .unwrap() + .is_none() + ); + } + + #[test] + fn should_return_none_if_plugin_metadata_has_only_name() { + let fixture = Fixture::new(GameType::Oblivion); + let mut database = fixture.database(); + + database.set_plugin_user_metadata(PluginMetadata::new(BLANK_ESM).unwrap()); + + assert!( + database + .plugin_metadata(BLANK_ESM, true, false) + .unwrap() + .is_none() + ); + } + + #[test] + fn should_prefer_user_metadata_when_merging_metadata() { + let fixture = Fixture::new(GameType::Oblivion); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin.set_load_after_files(vec![File::new(BLANK_DIFFERENT_ESM.into())]); + + database.set_plugin_user_metadata(plugin); + + assert_eq!( + &[ + File::new(BLANK_DIFFERENT_ESM.into()), + File::new("Oblivion.esm".into()) + ], + database + .plugin_metadata(BLANK_ESM, true, false) + .unwrap() + .unwrap() + .load_after_files() + ); + } + + #[test] + fn should_return_only_masterlist_metadata_if_include_user_metadata_is_false() { + let fixture = Fixture::new(GameType::Oblivion); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin.set_load_after_files(vec![File::new(BLANK_DIFFERENT_ESM.into())]); + + database.set_plugin_user_metadata(plugin); + + assert_eq!( + &[File::new("Oblivion.esm".into())], + database + .plugin_metadata(BLANK_ESM, false, false) + .unwrap() + .unwrap() + .load_after_files() + ); + } + + #[test] + fn should_filter_out_metadata_with_false_conditions_when_evaluating_conditions() { + let fixture = Fixture::new(GameType::Oblivion); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin.set_messages(vec![ + Message::new(MessageType::Say, "content".into()) + .with_condition("file(\"missing.esp\")".into()), + ]); + + database.set_plugin_user_metadata(plugin); + + assert!( + database + .plugin_metadata(BLANK_ESM, true, true) + .unwrap() + .unwrap() + .messages() + .is_empty() + ); + } + } + + mod plugin_user_metadata { + use super::*; + + #[test] + fn should_return_none_if_plugin_has_no_user_metadata_set() { + let fixture = Fixture::new(GameType::Oblivion); + let database = fixture.database(); + + assert!( + database + .plugin_user_metadata(BLANK_ESM, false) + .unwrap() + .is_none() + ); + } + + #[test] + fn should_return_none_if_plugin_user_metadata_has_only_name() { + let fixture = Fixture::new(GameType::Oblivion); + let mut database = fixture.database(); + + database.set_plugin_user_metadata(PluginMetadata::new(BLANK_ESM).unwrap()); + + assert!( + database + .plugin_user_metadata(BLANK_ESM, false) + .unwrap() + .is_none() + ); + } + + #[test] + fn should_return_only_user_metadata() { + let fixture = Fixture::new(GameType::Oblivion); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin.set_load_after_files(vec![File::new(BLANK_DIFFERENT_ESM.into())]); + + database.set_plugin_user_metadata(plugin); + + assert_eq!( + &[File::new(BLANK_DIFFERENT_ESM.into())], + database + .plugin_user_metadata(BLANK_ESM, false) + .unwrap() + .unwrap() + .load_after_files() + ); + } + + #[test] + fn should_filter_out_metadata_with_false_conditions_when_evaluating_conditions() { + let fixture = Fixture::new(GameType::Oblivion); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin.set_load_after_files(vec![ + File::new(BLANK_DIFFERENT_ESM.into()) + .with_condition("file(\"missing.esp\")".into()), + ]); + + database.set_plugin_user_metadata(plugin); + + assert!( + database + .plugin_user_metadata(BLANK_ESM, true) + .unwrap() + .is_none() + ); + } + } + + mod set_plugin_user_metadata { + use super::*; + + #[test] + fn should_replace_existing_user_metadata_for_the_plugin() { + let fixture = Fixture::new(GameType::Oblivion); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin.set_load_after_files(vec![File::new(BLANK_DIFFERENT_ESM.into())]); + + database.set_plugin_user_metadata(plugin.clone()); + + plugin.set_load_after_files(vec![File::new(BLANK_MASTER_DEPENDENT_ESM.into())]); + + database.set_plugin_user_metadata(plugin); + + assert_eq!( + &[File::new(BLANK_MASTER_DEPENDENT_ESM.into())], + database + .plugin_user_metadata(BLANK_ESM, false) + .unwrap() + .unwrap() + .load_after_files() + ); + } + + #[test] + fn should_not_modify_masterlist_metadata_for_the_plugin() { + let fixture = Fixture::new(GameType::Oblivion); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin.set_load_after_files(vec![File::new(BLANK_DIFFERENT_ESM.into())]); + + database.set_plugin_user_metadata(plugin); + + assert_eq!( + &[ + File::new(BLANK_DIFFERENT_ESM.into()), + File::new("Oblivion.esm".into()), + ], + database + .plugin_metadata(BLANK_ESM, true, false) + .unwrap() + .unwrap() + .load_after_files() + ); + } + } + + #[test] + fn discard_plugin_user_metadata_should_discard_only_user_metadata_for_only_the_given_plugin() { + let fixture = Fixture::new(GameType::Oblivion); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + let mut plugin1 = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin1.set_load_after_files(vec![File::new(BLANK_DIFFERENT_ESM.into())]); + + let mut plugin2 = PluginMetadata::new(BLANK_DIFFERENT_ESM).unwrap(); + plugin2.set_load_after_files(vec![File::new(BLANK_ESM.into())]); + + database.set_plugin_user_metadata(plugin1); + database.set_plugin_user_metadata(plugin2); + + database.discard_plugin_user_metadata(BLANK_ESM); + + assert_eq!( + &[File::new("Oblivion.esm".into())], + database + .plugin_metadata(BLANK_ESM, true, false) + .unwrap() + .unwrap() + .load_after_files() + ); + assert_eq!( + &[ + File::new(BLANK_ESM.into()), + File::new(BLANK_MASTER_DEPENDENT_ESM.into()), + ], + database + .plugin_metadata(BLANK_DIFFERENT_ESM, true, false) + .unwrap() + .unwrap() + .load_after_files() + ); + } + + #[test] + fn discard_all_user_metadata_should_not_remove_masterlist_metadata() { + let fixture = Fixture::new(GameType::Oblivion); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + let mut plugin1 = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin1.set_load_after_files(vec![File::new(BLANK_DIFFERENT_ESM.into())]); + + let mut plugin2 = PluginMetadata::new(BLANK_DIFFERENT_ESM).unwrap(); + plugin2.set_load_after_files(vec![File::new(BLANK_ESM.into())]); + + database.set_user_groups(vec![Group::new("group4".into())]); + database.set_plugin_user_metadata(plugin1); + database.set_plugin_user_metadata(plugin2); + + database.discard_all_user_metadata(); + + assert_eq!( + &[ + Group::default(), + Group::new("group1".into()), + Group::new("group2".into()).with_after_groups(vec!["group1".into()]) + ], + database.groups(true).as_slice() + ); + assert_eq!( + &[File::new("Oblivion.esm".into())], + database + .plugin_metadata(BLANK_ESM, true, false) + .unwrap() + .unwrap() + .load_after_files() + ); + assert_eq!( + &[File::new(BLANK_MASTER_DEPENDENT_ESM.into()),], + database + .plugin_metadata(BLANK_DIFFERENT_ESM, true, false) + .unwrap() + .unwrap() + .load_after_files() + ); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 00000000..e25a8580 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,293 @@ +//! Holds all error types aside from those related to LOOT metadata. +use std::path::PathBuf; + +pub use crate::database::{ConditionEvaluationError, MetadataRetrievalError}; +pub use crate::plugin::error::PluginDataError; +use crate::plugin::error::PluginValidationError; +pub use crate::sorting::error::GroupsPathError; + +use crate::sorting::error::{ + BuildGroupsGraphError, PluginGraphValidationError, SortingError, display_cycle, +}; +use crate::{Vertex, escape_ascii}; + +/// Represents an error that occurred while trying to create a [Game][crate::Game]. +#[derive(Debug)] +#[non_exhaustive] +pub enum GameHandleCreationError { + NotADirectory(PathBuf), + LoadOrderError(LoadOrderError), +} + +impl std::fmt::Display for GameHandleCreationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NotADirectory(p) => write!( + f, + "the path \"{}\" does not resolve to a directory", + escape_ascii(p) + ), + Self::LoadOrderError(_) => { + write!(f, "failed to initialise the load order game settings") + } + } + } +} + +impl std::error::Error for GameHandleCreationError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::NotADirectory(_) => None, + Self::LoadOrderError(e) => Some(e), + } + } +} + +impl From for GameHandleCreationError { + fn from(value: loadorder::Error) -> Self { + GameHandleCreationError::LoadOrderError(value.into()) + } +} + +/// Represents an error that occurred while trying to interact with the load order. +#[derive(Debug)] +pub struct LoadOrderError(Box); + +impl std::fmt::Display for LoadOrderError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "load order interaction failed") + } +} + +impl std::error::Error for LoadOrderError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(&self.0) + } +} + +impl From for LoadOrderError { + fn from(value: loadorder::Error) -> Self { + LoadOrderError(Box::new(value)) + } +} + +/// Indicates that the Database's RwLock wrapper has been poisoned and as such +/// the Database may be in an invalid state. +#[derive(Clone, Copy, Default, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct DatabaseLockPoisonError; + +impl std::fmt::Display for DatabaseLockPoisonError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "the database's lock has been poisoned") + } +} + +impl std::error::Error for DatabaseLockPoisonError {} + +impl From> for DatabaseLockPoisonError { + fn from(_: std::sync::PoisonError) -> Self { + DatabaseLockPoisonError + } +} + +/// Represents an error that occurred while loading plugins. +#[derive(Debug)] +#[non_exhaustive] +pub enum LoadPluginsError { + DatabaseLockPoisoned, + IoError(Box), + PluginValidationError(Box), + PluginDataError(PluginDataError), + PluginNotLoaded(String), +} + +impl std::fmt::Display for LoadPluginsError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::DatabaseLockPoisoned => DatabaseLockPoisonError.fmt(f), + Self::IoError(_) => write!(f, "an I/O error occurred"), + Self::PluginValidationError(_) => write!(f, "failed validation of input plugin paths"), + Self::PluginDataError(_) => write!(f, "failed to read loaded plugin data"), + Self::PluginNotLoaded(n) => write!(f, "the plugin \"{n}\" has not been loaded"), + } + } +} + +impl std::error::Error for LoadPluginsError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::DatabaseLockPoisoned | Self::PluginNotLoaded(_) => None, + Self::IoError(e) => Some(e), + Self::PluginValidationError(e) => Some(e.as_ref()), + Self::PluginDataError(e) => Some(e), + } + } +} + +impl From> for LoadPluginsError { + fn from(_: std::sync::PoisonError) -> Self { + LoadPluginsError::DatabaseLockPoisoned + } +} + +impl From for LoadPluginsError { + fn from(_: DatabaseLockPoisonError) -> Self { + LoadPluginsError::DatabaseLockPoisoned + } +} + +impl From for LoadPluginsError { + fn from(value: std::io::Error) -> Self { + LoadPluginsError::IoError(Box::new(value)) + } +} + +impl From for LoadPluginsError { + fn from(value: PluginValidationError) -> Self { + LoadPluginsError::PluginValidationError(Box::new(value)) + } +} + +impl From for LoadPluginsError { + fn from(value: PluginDataError) -> Self { + if let Some(plugin) = value.plugin_not_loaded() { + Self::PluginNotLoaded(plugin.to_owned()) + } else { + Self::PluginDataError(value) + } + } +} + +/// Represents an error that occurred while trying to load the current load +/// order state. +#[derive(Debug)] +#[non_exhaustive] +pub enum LoadOrderStateError { + DatabaseLockPoisoned, + LoadOrderError(LoadOrderError), +} + +impl std::fmt::Display for LoadOrderStateError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::DatabaseLockPoisoned => DatabaseLockPoisonError.fmt(f), + Self::LoadOrderError(_) => write!(f, "failed to load the current load order state"), + } + } +} + +impl std::error::Error for LoadOrderStateError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::DatabaseLockPoisoned => None, + Self::LoadOrderError(e) => Some(e), + } + } +} + +impl From> for LoadOrderStateError { + fn from(_: std::sync::PoisonError) -> Self { + LoadOrderStateError::DatabaseLockPoisoned + } +} + +impl From for LoadOrderStateError { + fn from(value: loadorder::Error) -> Self { + LoadOrderStateError::LoadOrderError(value.into()) + } +} + +/// Represents an error that occurred during sorting. +#[derive(Debug)] +#[non_exhaustive] +pub enum SortPluginsError { + DatabaseLockPoisoned, + PluginNotLoaded(String), + MetadataRetrievalError(MetadataRetrievalError), + UndefinedGroup(String), + CycleFound(Vec), + CycleFoundInvolving(String), + PluginDataError(PluginDataError), + PathfindingError(Box), +} + +impl std::fmt::Display for SortPluginsError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::DatabaseLockPoisoned => DatabaseLockPoisonError.fmt(f), + Self::PluginNotLoaded(n) => write!(f, "the plugin \"{n}\" has not been loaded"), + Self::UndefinedGroup(g) => write!(f, "the group \"{g}\" does not exist"), + Self::CycleFound(c) => write!(f, "found a cycle: {}", display_cycle(c)), + Self::CycleFoundInvolving(n) => write!(f, "found a cycle involving \"{n}\""), + Self::PluginDataError(_) => write!(f, "failed to read loaded plugin data"), + Self::MetadataRetrievalError(_) => write!(f, "failed to retrieve plugin metadata"), + Self::PathfindingError(_) => write!(f, "failed to find a path in the plugins graph"), + } + } +} + +impl std::error::Error for SortPluginsError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::MetadataRetrievalError(e) => Some(e), + Self::PluginDataError(e) => Some(e), + Self::PathfindingError(e) => Some(e.as_ref()), + _ => None, + } + } +} + +impl From> for SortPluginsError { + fn from(_: std::sync::PoisonError) -> Self { + SortPluginsError::DatabaseLockPoisoned + } +} + +impl From for SortPluginsError { + fn from(value: SortingError) -> Self { + match value { + SortingError::ValidationError(e) => match e { + PluginGraphValidationError::CycleFound(c) => Self::CycleFound(c.into_cycle()), + PluginGraphValidationError::PluginDataError(e) => Self::PluginDataError(e), + }, + SortingError::UndefinedGroup(g) => Self::UndefinedGroup(g.into_group_name()), + SortingError::CycleFound(c) => Self::CycleFound(c.into_cycle()), + SortingError::CycleInvolving(n) => Self::CycleFoundInvolving(n), + SortingError::PluginDataError(e) => Self::PluginDataError(e), + SortingError::PathfindingError(e) => Self::PathfindingError(Box::new(e)), + } + } +} + +impl From for SortPluginsError { + fn from(value: BuildGroupsGraphError) -> Self { + match value { + BuildGroupsGraphError::UndefinedGroup(g) => Self::UndefinedGroup(g.into_group_name()), + BuildGroupsGraphError::CycleFound(c) => Self::CycleFound(c.into_cycle()), + } + } +} + +impl From for SortPluginsError { + fn from(value: PluginDataError) -> Self { + if let Some(plugin) = value.plugin_not_loaded() { + Self::PluginNotLoaded(plugin.to_owned()) + } else { + Self::PluginDataError(value) + } + } +} + +impl From for SortPluginsError { + fn from(value: MetadataRetrievalError) -> Self { + SortPluginsError::MetadataRetrievalError(value) + } +} + +impl From for SortPluginsError { + fn from(value: ConditionEvaluationError) -> Self { + SortPluginsError::MetadataRetrievalError(MetadataRetrievalError::ConditionEvaluationError( + value, + )) + } +} diff --git a/src/game.rs b/src/game.rs new file mode 100644 index 00000000..f3adf326 --- /dev/null +++ b/src/game.rs @@ -0,0 +1,2201 @@ +use std::{ + collections::{HashMap, HashSet}, + fmt::Display, + path::{Path, PathBuf}, + sync::{Arc, RwLock}, +}; + +use loadorder::WritableLoadOrder; +use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; + +use crate::{ + LogLevel, + database::Database, + error::{ + DatabaseLockPoisonError, GameHandleCreationError, LoadOrderError, LoadOrderStateError, + LoadPluginsError, SortPluginsError, + }, + escape_ascii, + logging::{self, format_details, is_log_enabled}, + metadata::{ + Filename, + plugin_metadata::{GHOST_FILE_EXTENSION, iends_with_ascii}, + }, + plugin::{ + LoadScope, Plugin, + error::{InvalidFilenameReason, PluginValidationError}, + plugins_metadata, validate_plugin_path_and_header, + }, + sorting::{ + groups::build_groups_graph, + plugins::{PluginSortingData, sort_plugins}, + }, +}; + +/// Codes used to create database handles for specific games. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[non_exhaustive] +pub enum GameType { + /// The Elder Scrolls IV: Oblivion + Oblivion, + /// The Elder Scrolls V: Skyrim + Skyrim, + /// Fallout 3 + Fallout3, + /// Fallout: New Vegas + FalloutNV, + /// Fallout 4 + Fallout4, + /// The Elder Scrolls V: Skyrim Special Edition + SkyrimSE, + /// Fallout 4 VR + Fallout4VR, + /// Skyrim VR + SkyrimVR, + /// The Elder Scrolls III: Morrowind + Morrowind, + /// Starfield + Starfield, + /// OpenMW + OpenMW, + /// The Elder Scrolls IV: Oblivion Remastered + OblivionRemastered, +} + +impl Display for GameType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + GameType::Oblivion => write!(f, "The Elder Scrolls IV: Oblivion"), + GameType::Skyrim => write!(f, "The Elder Scrolls V: Skyrim"), + GameType::Fallout3 => write!(f, "Fallout 3"), + GameType::FalloutNV => write!(f, "Fallout: New Vegas"), + GameType::Fallout4 => write!(f, "Fallout 4"), + GameType::SkyrimSE => write!(f, "The Elder Scrolls V: Skyrim Special Edition"), + GameType::Fallout4VR => write!(f, "Fallout 4 VR"), + GameType::SkyrimVR => write!(f, "The Elder Scrolls V: Skyrim VR"), + GameType::Morrowind => write!(f, "The Elder Scrolls III: Morrowind"), + GameType::Starfield => write!(f, "Starfield"), + GameType::OpenMW => write!(f, "OpenMW"), + GameType::OblivionRemastered => write!(f, "The Elder Scrolls IV: Oblivion Remastered"), + } + } +} + +impl From for loadorder::GameId { + fn from(value: GameType) -> Self { + match value { + GameType::Oblivion => loadorder::GameId::Oblivion, + GameType::Skyrim => loadorder::GameId::Skyrim, + GameType::Fallout3 => loadorder::GameId::Fallout3, + GameType::FalloutNV => loadorder::GameId::FalloutNV, + GameType::Fallout4 => loadorder::GameId::Fallout4, + GameType::SkyrimSE => loadorder::GameId::SkyrimSE, + GameType::Fallout4VR => loadorder::GameId::Fallout4VR, + GameType::SkyrimVR => loadorder::GameId::SkyrimVR, + GameType::Morrowind => loadorder::GameId::Morrowind, + GameType::Starfield => loadorder::GameId::Starfield, + GameType::OpenMW => loadorder::GameId::OpenMW, + GameType::OblivionRemastered => loadorder::GameId::OblivionRemastered, + } + } +} + +impl From for loot_condition_interpreter::GameType { + fn from(value: GameType) -> Self { + match value { + GameType::Oblivion | GameType::OblivionRemastered => { + loot_condition_interpreter::GameType::Oblivion + } + GameType::Skyrim => loot_condition_interpreter::GameType::Skyrim, + GameType::Fallout3 => loot_condition_interpreter::GameType::Fallout3, + GameType::FalloutNV => loot_condition_interpreter::GameType::FalloutNV, + GameType::Fallout4 => loot_condition_interpreter::GameType::Fallout4, + GameType::SkyrimSE => loot_condition_interpreter::GameType::SkyrimSE, + GameType::Fallout4VR => loot_condition_interpreter::GameType::Fallout4VR, + GameType::SkyrimVR => loot_condition_interpreter::GameType::SkyrimVR, + GameType::Morrowind => loot_condition_interpreter::GameType::Morrowind, + GameType::Starfield => loot_condition_interpreter::GameType::Starfield, + GameType::OpenMW => loot_condition_interpreter::GameType::OpenMW, + } + } +} + +impl From for esplugin::GameId { + fn from(value: GameType) -> Self { + match value { + GameType::Oblivion | GameType::OblivionRemastered => esplugin::GameId::Oblivion, + GameType::Skyrim => esplugin::GameId::Skyrim, + GameType::Fallout3 => esplugin::GameId::Fallout3, + GameType::FalloutNV => esplugin::GameId::FalloutNV, + GameType::Fallout4 | GameType::Fallout4VR => esplugin::GameId::Fallout4, + GameType::SkyrimSE | GameType::SkyrimVR => esplugin::GameId::SkyrimSE, + GameType::Morrowind | GameType::OpenMW => esplugin::GameId::Morrowind, + GameType::Starfield => esplugin::GameId::Starfield, + } + } +} + +/// The interface through which game-specific functionality is provided. +#[derive(Debug)] +pub struct Game { + base_type: GameType, + install_path: PathBuf, + load_order: Box<(dyn WritableLoadOrder + Send + Sync + 'static)>, + // Stored in an Arc> to support loading metadata in parallel with + // loading plugins. + database: Arc>, + cache: GameCache, +} + +impl Game { + /// Initialise a new game handle, which is then used by all game-specific + /// functions. + /// + /// - `game_type` is a value representing which game to create the handle + /// for, + /// - `game_path` is the relative or absolute path to the directory + /// containing the game's executable. + /// + /// This function will attempt to look up the game's local data path, which + /// may fail in some situations (e.g. when running libloot natively on Linux + /// for a game other than Morrowind or OpenMW). [Game::with_local_path] + /// can be used to provide the local path instead. + pub fn new(game_type: GameType, game_path: &Path) -> Result { + logging::info!( + "Attempting to create a game handle for game type \"{}\" with game path \"{}\"", + game_type, + escape_ascii(game_path) + ); + + let resolved_game_path = resolve_path(game_path); + if !resolved_game_path.is_dir() { + return Err(GameHandleCreationError::NotADirectory(game_path.into())); + } + + let load_order = + loadorder::GameSettings::new(game_type.into(), &resolved_game_path)?.into_load_order(); + + let condition_evaluator_state = + new_condition_evaluator_state(game_type, &resolved_game_path, load_order.as_ref()); + + Ok(Game { + base_type: game_type, + install_path: resolved_game_path, + load_order, + database: Arc::new(RwLock::new(Database::new(condition_evaluator_state))), + cache: GameCache::default(), + }) + } + + /// Initialise a new game handle, which is then used by all game-specific + /// functions. + /// + /// - `game_type` is a value representing which game to create the handle + /// for, + /// - `game_path` is the relative or absolute path to the directory + /// containing the game's executable. + /// - `game_local_path` is the relative or absolute path to the game's local + /// data path. The local data folder is usually in `%%LOCALAPPDATA%`, but + /// Morrowind has no local data folder and OpenMW's is in the user's My + /// Games folder on Windows and in `$HOME/.config` on Linux. + pub fn with_local_path( + game_type: GameType, + game_path: &Path, + game_local_path: &Path, + ) -> Result { + logging::info!( + "Attempting to create a game handle for game type \"{}\" with game path \"{}\" and game local path \"{}\"", + game_type, + escape_ascii(game_path), + escape_ascii(game_local_path) + ); + + let resolved_game_path = resolve_path(game_path); + if !resolved_game_path.is_dir() { + return Err(GameHandleCreationError::NotADirectory(game_path.into())); + } + + let resolved_game_local_path = resolve_path(game_local_path); + if resolved_game_local_path.exists() && !resolved_game_local_path.is_dir() { + return Err(GameHandleCreationError::NotADirectory( + game_local_path.into(), + )); + } + + let load_order = loadorder::GameSettings::with_local_path( + game_type.into(), + &resolved_game_path, + &resolved_game_local_path, + )? + .into_load_order(); + + let condition_evaluator_state = + new_condition_evaluator_state(game_type, &resolved_game_path, load_order.as_ref()); + + Ok(Game { + base_type: game_type, + install_path: resolved_game_path, + load_order, + database: Arc::new(RwLock::new(Database::new(condition_evaluator_state))), + cache: GameCache::default(), + }) + } + + /// Get the game's type. + pub fn game_type(&self) -> GameType { + self.base_type + } + + /// Gets the currently-set additional data paths. + /// + /// The following games are configured with additional data paths by + /// default: + /// + /// - Fallout 4, when installed from the Microsoft Store + /// - Starfield + /// - OpenMW + pub fn additional_data_paths(&self) -> &[PathBuf] { + self.load_order + .game_settings() + .additional_plugins_directories() + } + + /// Set additional data paths. + /// + /// The additional data paths are used when interacting with the load order, + /// evaluating conditions and scanning for archives (BSA/BA2 depending on + /// the game). Additional data paths are used in the order they are given + /// (except with OpenMW, which checks them in reverse order), and take + /// precedence over the game's main data path. + /// + /// Setting additional data paths clears the condition cache in this game's + /// database object. + pub fn set_additional_data_paths( + &mut self, + additional_data_paths: &[&Path], + ) -> Result<(), DatabaseLockPoisonError> { + let paths: Vec<_> = additional_data_paths + .iter() + .map(|p| p.to_path_buf()) + .collect(); + + let mut database = self.database.write()?; + database.clear_condition_cache(); + + self.load_order + .game_settings_mut() + .set_additional_plugins_directories(paths.clone()); + + database + .condition_evaluator_state_mut() + .set_additional_data_paths(paths); + + Ok(()) + } + + /// Get the object used for accessing metadata-related functionality. + pub fn database(&self) -> Arc> { + Arc::clone(&self.database) + } + + /// Check if a file is a valid plugin. + /// + /// The validity check is not exhaustive: it generally checks that the + /// file is a valid plugin file extension for the game and that its header + /// (if applicable) can be parsed. + /// + /// `plugin_path` can be absolute or relative: relative paths are resolved + /// relative to the game's plugins directory, while absolute paths are used + /// as given. + pub fn is_valid_plugin(&self, plugin_path: &Path) -> bool { + let resolved_path = resolve_plugin_path( + self.base_type, + &data_path(self.base_type, &self.install_path), + plugin_path, + ); + validate_plugin_path_and_header(self.base_type, &resolved_path).is_ok() + } + + /// Fully parses plugins and loads their data. + /// + /// If a given plugin filename (or one that is case-insensitively equal) has + /// already been loaded, its previously-loaded data data is discarded. + /// + /// If the game is Morrowind, OpenMW or Starfield, it's only valid to fully + /// load a plugin if its masters are already loaded or included in the same + /// input slice. + /// + /// Relative paths in `plugin_paths` are resolved relative to the game's + /// plugins directory, while absolute paths are used as given. Each plugin + /// filename must be unique within the vector. + /// + /// Loading plugins clears the condition cache in this game's database + /// object. + pub fn load_plugins(&mut self, plugin_paths: &[&Path]) -> Result<(), LoadPluginsError> { + let mut plugins = self.load_plugins_common(plugin_paths, LoadScope::WholePlugin)?; + + if matches!( + self.base_type, + GameType::Morrowind | GameType::OpenMW | GameType::Starfield + ) { + let mut loaded_plugins: HashMap = self + .cache + .plugins() + .iter() + .map(|(k, v)| (k.clone(), v.as_ref())) + .collect(); + + for plugin in &plugins { + loaded_plugins.insert(Filename::new(plugin.name().to_owned()), plugin); + } + + let loaded_plugins: Vec<_> = loaded_plugins.into_values().collect(); + + let plugins_metadata = plugins_metadata(&loaded_plugins)?; + + for plugin in &mut plugins { + plugin.resolve_record_ids(&plugins_metadata)?; + } + } + + self.store_plugins(plugins)?; + + Ok(()) + } + + /// Parses plugin headers and loads their data. + /// + /// If a given plugin filename (or one that is case-insensitively equal) has + /// already been loaded, its previously-loaded data data is discarded. + /// + /// Relative paths in `plugin_paths` are resolved relative to the game's + /// plugins directory, while absolute paths are used as given. Each plugin + /// filename must be unique within the vector. + /// + /// Loading plugins clears the condition cache in this game's database + /// object. + pub fn load_plugin_headers(&mut self, plugin_paths: &[&Path]) -> Result<(), LoadPluginsError> { + let plugins = self.load_plugins_common(plugin_paths, LoadScope::HeaderOnly)?; + + self.store_plugins(plugins)?; + + Ok(()) + } + + fn load_plugins_common( + &mut self, + plugin_paths: &[&Path], + load_scope: LoadScope, + ) -> Result, LoadPluginsError> { + let data_path = data_path(self.base_type, &self.install_path); + + validate_plugin_paths(self.base_type, &data_path, plugin_paths)?; + + let archive_paths = + find_archives(self.base_type, self.additional_data_paths(), &data_path)?; + + self.cache.set_archive_paths(archive_paths); + + logging::trace!("Starting loading {load_scope}s."); + + let plugins: Vec<_> = plugin_paths + .par_iter() + .filter_map(|path| { + try_load_plugin(&data_path, path, self.base_type, &self.cache, load_scope) + }) + .collect(); + + Ok(plugins) + } + + fn store_plugins(&mut self, plugins: Vec) -> Result<(), DatabaseLockPoisonError> { + self.cache.insert_plugins(plugins); + + let mut database = self.database.write()?; + update_loaded_plugin_state( + database.condition_evaluator_state_mut(), + self.cache.plugins_iter(), + ); + + Ok(()) + } + + /// Clears the plugins loaded by previous calls to [Game::load_plugins] or + /// [Game::load_plugin_headers]. + pub fn clear_loaded_plugins(&mut self) { + self.cache.clear_plugins(); + } + + /// Get data for a loaded plugin. + pub fn plugin(&self, plugin_name: &str) -> Option> { + self.cache.plugin(plugin_name).cloned() + } + + /// Get data for all loaded plugins. + pub fn loaded_plugins(&self) -> Vec> { + self.cache.plugins_iter().cloned().collect() + } + + /// Calculates a new load order for the game's installed plugins (including + /// inactive plugins) and returns the sorted order. + /// + /// This pulls metadata from the masterlist and userlist if they are loaded, + /// and uses the loaded data of each plugin. No changes are applied to the + /// load order used by the game. This function does not load or evaluate the + /// masterlist or userlist. + /// + /// The order in which plugins are listed in `plugin_filenames` is used as + /// their current load order. All given plugins must have been already been + /// loaded using [Game::load_plugins] or [Game::load_plugin_headers]. + pub fn sort_plugins(&self, plugin_names: &[&str]) -> Result, SortPluginsError> { + let plugins = plugin_names + .iter() + .map(|n| { + self.cache + .plugin(n) + .ok_or_else(|| SortPluginsError::PluginNotLoaded((*n).to_owned())) + }) + .collect::, _>>()?; + + let database = self.database.read()?; + + let plugins_sorting_data = plugins + .into_iter() + .enumerate() + .map(|(i, p)| to_plugin_sorting_data(&database, p, i)) + .collect::, _>>()?; + + if is_log_enabled(LogLevel::Debug) { + logging::debug!("Current load order:"); + for plugin_name in plugin_names { + logging::debug!("\t{plugin_name}"); + } + } + + let groups_graph = build_groups_graph(&database.groups(false), database.user_groups())?; + + let new_load_order = sort_plugins( + plugins_sorting_data, + &groups_graph, + self.load_order.game_settings().early_loading_plugins(), + )?; + + if is_log_enabled(LogLevel::Debug) { + logging::debug!("Sorted load order:"); + for plugin_name in &new_load_order { + logging::debug!("\t{plugin_name}"); + } + } + + Ok(new_load_order) + } + + /// Load the current load order state, discarding any previously held state. + /// + /// This function should be called whenever the load order or active state + /// of plugins "on disk" changes, so that the cached state is updated to + /// reflect the changes. + /// + /// Loading the current load order state clears the condition cache in this + /// game's database object. + pub fn load_current_load_order_state(&mut self) -> Result<(), LoadOrderStateError> { + self.load_order.load()?; + + let mut database = self.database.write()?; + database.clear_condition_cache(); + database + .condition_evaluator_state_mut() + .set_active_plugins(&self.load_order.active_plugin_names()); + Ok(()) + } + + /// Check if the load order is ambiguous. + /// + /// This checks that all plugins in the current load order state have a + /// well-defined position in the "on disk" state, and that all data sources + /// are consistent. If the load order is ambiguous, different applications + /// may read different load orders from the same source data. + pub fn is_load_order_ambiguous(&self) -> Result { + Ok(self.load_order.is_ambiguous()?) + } + + /// Gets the path to the file that holds the list of active plugins. + /// The active plugins file path is often within the game's local path, but + /// its name and location varies by game and game configuration, so this + /// function exposes the path that libloot uses. + pub fn active_plugins_file_path(&self) -> &PathBuf { + self.load_order.game_settings().active_plugins_file() + } + + /// Check if the given plugin is active. + pub fn is_plugin_active(&self, plugin_name: &str) -> bool { + self.load_order.is_active(plugin_name) + } + + /// Get the current load order. + pub fn load_order(&self) -> Vec<&str> { + self.load_order.plugin_names() + } + + /// Set the game's load order. + /// + /// There is no way to persist the load order of inactive OpenMW plugins, so + /// setting an OpenMW load order will have no effect if the relative order + /// of active plugins is unchanged. + pub fn set_load_order(&mut self, load_order: &[&str]) -> Result<(), LoadOrderError> { + self.load_order.set_load_order(load_order)?; + self.load_order.save()?; + Ok(()) + } +} + +fn resolve_path(path: &Path) -> PathBuf { + if path.is_symlink() { + path.read_link().unwrap_or_else(|_| path.to_path_buf()) + } else { + path.to_path_buf() + } +} + +fn data_path(game_type: GameType, game_path: &Path) -> PathBuf { + match game_type { + GameType::Morrowind => game_path.join("Data Files"), + GameType::OpenMW => game_path.join("resources/vfs"), + GameType::OblivionRemastered => { + game_path.join("OblivionRemastered/Content/Dev/ObvData/Data") + } + _ => game_path.join("Data"), + } +} + +fn new_condition_evaluator_state( + game_type: GameType, + game_path: &Path, + load_order: &(dyn WritableLoadOrder + Send + Sync + 'static), +) -> loot_condition_interpreter::State { + let data_path = data_path(game_type, game_path); + + let mut condition_evaluator_state = + loot_condition_interpreter::State::new(game_type.into(), data_path); + condition_evaluator_state.set_additional_data_paths( + load_order + .game_settings() + .additional_plugins_directories() + .to_vec(), + ); + + condition_evaluator_state +} + +fn validate_plugin_paths( + game_type: GameType, + data_path: &Path, + plugin_paths: &[&Path], +) -> Result<(), PluginValidationError> { + // Check that all plugin filenames are unique. + let mut set = HashSet::new(); + for path in plugin_paths { + let filename = match path.file_name() { + Some(f) => f.to_string_lossy(), + None => { + return Err(PluginValidationError::invalid( + path.into(), + InvalidFilenameReason::Empty, + )); + } + }; + if !set.insert(Filename::new(filename.to_string())) { + return Err(PluginValidationError::invalid( + path.into(), + InvalidFilenameReason::NonUnique, + )); + } + } + + plugin_paths + .par_iter() + .map(|path| { + let resolved_path = resolve_plugin_path(game_type, data_path, path); + validate_plugin_path_and_header(game_type, &resolved_path) + }) + .collect() +} + +fn find_archives( + game_type: GameType, + additional_data_paths: &[PathBuf], + data_path: &Path, +) -> std::io::Result> { + let extension = archive_file_extension(game_type); + + let mut archive_paths = Vec::new(); + for path in additional_data_paths { + let paths = find_archives_in_path(path, extension)?; + archive_paths.extend(paths); + } + + let paths = find_archives_in_path(data_path, extension)?; + archive_paths.extend(paths); + + Ok(archive_paths) +} + +fn archive_file_extension(game_type: GameType) -> &'static str { + match game_type { + GameType::Fallout4 | GameType::Fallout4VR | GameType::Starfield => ".ba2", + _ => ".bsa", + } +} + +fn find_archives_in_path( + parent_path: &Path, + archive_file_extension: &str, +) -> std::io::Result> { + if !parent_path.exists() { + return Ok(Vec::new()); + } + + let paths = std::fs::read_dir(parent_path)? + .filter_map(Result::ok) + .filter(|e| { + e.file_type().map(|f| f.is_file()).unwrap_or(false) + && iends_with_ascii(&e.file_name().to_string_lossy(), archive_file_extension) + }) + .map(|e| e.path()) + .collect(); + + Ok(paths) +} + +fn try_load_plugin( + data_path: &Path, + plugin_path: &Path, + game_type: GameType, + game_cache: &GameCache, + load_scope: LoadScope, +) -> Option { + let resolved_path = resolve_plugin_path(game_type, data_path, plugin_path); + + match Plugin::new(game_type, game_cache, &resolved_path, load_scope) { + Ok(p) => Some(p), + Err(e) => { + logging::error!( + "Caught error while trying to load \"{}\": {}", + escape_ascii(plugin_path), + format_details(&e) + ); + None + } + } +} + +fn resolve_plugin_path(game_type: GameType, data_path: &Path, plugin_path: &Path) -> PathBuf { + let plugin_path = data_path.join(plugin_path); + + if game_type != GameType::OpenMW && !plugin_path.exists() { + if let Some(filename) = plugin_path.file_name() { + logging::debug!( + "Could not find plugin at \"{}\", adding {} file extension", + escape_ascii(&plugin_path), + GHOST_FILE_EXTENSION + ); + let mut filename = filename.to_os_string(); + filename.push(GHOST_FILE_EXTENSION); + plugin_path.with_file_name(filename) + } else { + plugin_path + } + } else { + plugin_path + } +} + +fn update_loaded_plugin_state<'a>( + state: &mut loot_condition_interpreter::State, + plugins: impl Iterator>, +) { + let mut plugin_versions = Vec::new(); + let mut plugin_crcs = Vec::new(); + + for plugin in plugins { + if let Some(version) = plugin.version() { + plugin_versions.push((plugin.name(), version)); + } + + if let Some(crc) = plugin.crc() { + plugin_crcs.push((plugin.name(), crc)); + } + } + + if let Err(e) = state.clear_condition_cache() { + logging::error!("The condition cache's lock is poisoned, assigning a new cache"); + *e.into_inner() = HashMap::new(); + } + + state.set_plugin_versions(&plugin_versions); + + if let Err(e) = state.set_cached_crcs(&plugin_crcs) { + logging::error!( + "The condition interpreter's CRC cache's lock is poisoned, clearing the cache and assigning a new value" + ); + let mut cache = e.into_inner(); + cache.clear(); + *cache = plugin_crcs + .into_iter() + .map(|(n, c)| (n.to_lowercase(), c)) + .collect(); + } +} + +fn to_plugin_sorting_data<'a>( + database: &Database, + plugin: &'a Arc, + load_order_index: usize, +) -> Result, SortPluginsError> { + let masterlist_metadata = database + .plugin_metadata(plugin.name(), false, true)? + .map(|m| m.filter_by_constraints(database)) + .transpose()?; + + let user_metadata = database + .plugin_user_metadata(plugin.name(), true)? + .map(|m| m.filter_by_constraints(database)) + .transpose()?; + + PluginSortingData::new( + plugin.as_ref(), + masterlist_metadata.as_ref(), + user_metadata.as_ref(), + load_order_index, + ) + .map_err(Into::into) +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub(crate) struct GameCache { + plugins: HashMap>, + archive_paths: HashSet, +} + +impl GameCache { + pub fn set_archive_paths(&mut self, archive_paths: Vec) { + self.archive_paths.clear(); + self.archive_paths.extend(archive_paths); + } + + fn insert_plugins(&mut self, plugins: Vec) { + for plugin in plugins { + self.plugins + .insert(Filename::new(plugin.name().to_owned()), Arc::new(plugin)); + } + } + + fn clear_plugins(&mut self) { + self.plugins.clear(); + } + + fn plugins(&self) -> &HashMap> { + &self.plugins + } + + fn plugins_iter(&self) -> impl Iterator> { + self.plugins.values() + } + + fn plugin(&self, plugin_name: &str) -> Option<&Arc> { + self.plugins.get(&Filename::new(plugin_name.to_owned())) + } + + pub fn archives_iter(&self) -> impl Iterator { + self.archive_paths.iter() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use parameterized_test::parameterized_test; + + use crate::{ + metadata::{File, PluginMetadata}, + tests::{ + ALL_GAME_TYPES, BLANK_DIFFERENT_ESM, BLANK_DIFFERENT_ESP, BLANK_ESM, BLANK_ESP, + BLANK_MASTER_DEPENDENT_ESM, Fixture, + }, + }; + + mod game { + use std::path::Component; + + use crate::tests::BLANK_ESM; + + use super::*; + + #[cfg(windows)] + fn symlink_dir(original: &Path, link: &Path) { + std::os::windows::fs::symlink_dir(original, link).unwrap(); + } + + #[cfg(unix)] + fn symlink_dir(original: &Path, link: &Path) { + std::os::unix::fs::symlink(original, link).unwrap(); + } + + #[cfg(windows)] + fn junction_link(original: &Path, link: &Path) { + use std::ffi::{OsStr, OsString}; + + // The paths may contain forward slashes, which cmd doesn't accept, + // so replace them. + let original = OsString::from(original.to_str().unwrap().replace('/', "\\")); + let link = OsString::from(link.to_str().unwrap().replace('/', "\\")); + + let status = std::process::Command::new("cmd") + .args([ + OsStr::new("/C"), + OsStr::new("mklink"), + OsStr::new("/J"), + link.as_os_str(), + original.as_os_str(), + ]) + .status() + .unwrap(); + + assert!(status.success()); + } + + fn make_relative(path: &Path) -> PathBuf { + if path.is_relative() { + return path.to_path_buf(); + } + + let base = std::env::current_dir().unwrap(); + assert!(base.is_absolute()); + + let mut path_iter = path.components(); + let mut base_iter = base.components(); + + let mut relative_components = Vec::new(); + loop { + match (path_iter.next(), base_iter.next()) { + (None, None) => break, + (None, _) => relative_components.push(Component::ParentDir), + (Some(p), None) => { + relative_components.push(p); + relative_components.extend(path_iter); + break; + } + (Some(p), Some(b)) => { + if relative_components.is_empty() && p == b { + continue; + } + + relative_components.push(Component::ParentDir); + for _ in base_iter { + relative_components.push(Component::ParentDir); + } + + relative_components.push(p); + relative_components.extend(path_iter); + break; + } + } + } + + relative_components + .into_iter() + .map(Component::as_os_str) + .collect() + } + + mod new { + use super::*; + + #[cfg(windows)] + #[parameterized_test(ALL_GAME_TYPES)] + fn should_succeed_if_given_valid_game_path(game_type: GameType) { + let fixture = Fixture::new(game_type); + + assert!(Game::new(fixture.game_type, &fixture.game_path).is_ok()); + } + + #[cfg(not(windows))] + #[parameterized_test(ALL_GAME_TYPES)] + fn should_succeed_for_morrowind_if_given_valid_game_path(game_type: GameType) { + let fixture = Fixture::new(game_type); + + if matches!( + game_type, + GameType::Morrowind | GameType::OpenMW | GameType::OblivionRemastered + ) { + assert!(Game::new(fixture.game_type, &fixture.game_path).is_ok()); + } else { + assert!(Game::new(fixture.game_type, &fixture.game_path).is_err()); + } + } + + #[test] + fn should_succeed_if_given_a_relative_game_path() { + let fixture = Fixture::in_path(GameType::Morrowind, Path::new("target")); + + let game_path = make_relative(&fixture.game_path); + assert!(game_path.is_relative()); + + assert!(Game::new(fixture.game_type, &game_path).is_ok()); + } + + #[test] + fn should_succeed_if_given_an_absolute_game_path() { + let fixture = Fixture::new(GameType::Morrowind); + + assert!(fixture.game_path.is_absolute()); + assert!(Game::new(fixture.game_type, &fixture.game_path).is_ok()); + } + + #[test] + fn should_succeed_if_given_a_symlink_path() { + let fixture = Fixture::new(GameType::Morrowind); + + let game_path = fixture.game_path.with_extension("symlink"); + symlink_dir(&fixture.game_path, &game_path); + assert!(game_path.is_symlink()); + + assert!(Game::new(fixture.game_type, &game_path).is_ok()); + } + + #[cfg(windows)] + #[test] + fn should_succeed_if_given_a_junction_link_path() { + let fixture = Fixture::new(GameType::Morrowind); + + let game_path = fixture.game_path.with_extension("junction"); + junction_link(&fixture.game_path, &game_path); + + assert!(Game::new(fixture.game_type, &game_path).is_ok()); + } + + #[test] + fn should_error_if_given_a_game_path_that_does_not_exist() { + let game_path = Path::new("missing"); + match Game::new(GameType::Morrowind, game_path) { + Err(GameHandleCreationError::NotADirectory(p)) => assert_eq!(game_path, p), + _ => panic!("Expected a not-a-directory error"), + } + } + } + + mod with_local_path { + use super::*; + + #[parameterized_test(ALL_GAME_TYPES)] + fn should_succeed_if_given_valid_paths(game_type: GameType) { + let fixture = Fixture::new(game_type); + + let game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ); + + assert!(game.is_ok()); + } + + #[test] + fn should_succeed_if_given_relative_paths() { + let fixture = Fixture::in_path(GameType::Morrowind, Path::new("target")); + + let game_path = make_relative(&fixture.game_path); + assert!(game_path.is_relative()); + + let local_path = make_relative(&fixture.local_path); + assert!(local_path.is_relative()); + + let game = Game::with_local_path(fixture.game_type, &game_path, &local_path); + + assert!(game.is_ok()); + } + + #[test] + fn should_succeed_if_given_absolute_paths() { + let fixture = Fixture::new(GameType::Oblivion); + + assert!(fixture.game_path.is_absolute()); + assert!(fixture.local_path.is_absolute()); + + let game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ); + + assert!(game.is_ok()); + } + + #[test] + fn should_succeed_if_given_symlink_paths() { + let fixture = Fixture::new(GameType::Oblivion); + + let game_path = fixture.game_path.with_extension("symlink"); + symlink_dir(&fixture.game_path, &game_path); + assert!(game_path.is_symlink()); + + let local_path = fixture.local_path.with_extension("symlink"); + symlink_dir(&fixture.local_path, &local_path); + assert!(local_path.is_symlink()); + + let game = Game::with_local_path(fixture.game_type, &game_path, &local_path); + + assert!(game.is_ok()); + } + + #[cfg(windows)] + #[test] + fn should_succeed_if_given_junction_link_paths() { + let fixture = Fixture::new(GameType::Oblivion); + + let game_path = fixture.game_path.with_extension("junction"); + junction_link(&fixture.game_path, &game_path); + + let local_path = fixture.local_path.with_extension("junction"); + junction_link(&fixture.local_path, &local_path); + + let game = Game::with_local_path(fixture.game_type, &game_path, &local_path); + + assert!(game.is_ok()); + } + + #[test] + fn should_error_if_given_a_game_path_that_does_not_exist() { + let fixture = Fixture::new(GameType::Oblivion); + + let game_path = Path::new("missing"); + let game = Game::with_local_path(fixture.game_type, game_path, &fixture.local_path); + + match game { + Err(GameHandleCreationError::NotADirectory(p)) => assert_eq!(game_path, p), + _ => panic!("Expected a not-a-directory error"), + } + } + + #[test] + fn should_succeed_if_given_a_local_path_that_does_not_exist() { + let fixture = Fixture::new(GameType::Oblivion); + + let local_path = Path::new("missing"); + let game = Game::with_local_path(fixture.game_type, &fixture.game_path, local_path); + + assert!(game.is_ok()); + } + + #[test] + fn should_error_if_given_a_local_path_that_is_not_a_directory() { + let fixture = Fixture::new(GameType::Oblivion); + + let local_path = Path::new("README.md"); + assert!(local_path.exists()); + + let game = Game::with_local_path(fixture.game_type, &fixture.game_path, local_path); + + match game { + Err(GameHandleCreationError::NotADirectory(p)) => assert_eq!(local_path, p), + _ => panic!("Expected a not-a-directory error"), + } + } + + #[parameterized_test(ALL_GAME_TYPES)] + fn should_set_default_additional_data_paths(game_type: GameType) { + let fixture = Fixture::new(game_type); + + match game_type { + GameType::Fallout4 => { + std::fs::File::create(fixture.game_path.join("appxmanifest.xml")).unwrap(); + } + GameType::OpenMW => { + let contents = format!( + "data-local=\"{}\"\nconfig=\"{}\"", + fixture.local_path.join("data").display(), + fixture.local_path.display() + ); + std::fs::write(fixture.game_path.join("openmw.cfg"), contents).unwrap(); + } + _ => {} + } + + let game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + match game_type { + GameType::Fallout4 => { + let base_path = fixture.game_path.join("../.."); + assert_eq!( + &[ + base_path + .join("Fallout 4- Automatron (PC)") + .join("Content") + .join("Data"), + base_path + .join("Fallout 4- Nuka-World (PC)") + .join("Content") + .join("Data"), + base_path + .join("Fallout 4- Wasteland Workshop (PC)") + .join("Content") + .join("Data"), + base_path + .join("Fallout 4- High Resolution Texture Pack") + .join("Content") + .join("Data"), + base_path + .join("Fallout 4- Vault-Tec Workshop (PC)") + .join("Content") + .join("Data"), + base_path + .join("Fallout 4- Far Harbor (PC)") + .join("Content") + .join("Data"), + base_path + .join("Fallout 4- Contraptions Workshop (PC)") + .join("Content") + .join("Data") + ], + game.additional_data_paths() + ); + } + GameType::Starfield => { + assert_eq!(1, game.additional_data_paths().len()); + + let expected_suffix = Path::new("Documents") + .join("My Games") + .join("Starfield") + .join("Data"); + + assert!(game.additional_data_paths()[0].ends_with(expected_suffix)); + } + GameType::OpenMW => { + assert_eq!( + &[fixture.local_path.join("data")], + game.additional_data_paths() + ); + } + _ => assert!(game.additional_data_paths().is_empty()), + } + } + + mod set_additional_data_paths { + use std::time::{Duration, SystemTime}; + + use super::*; + + #[test] + fn should_clear_the_condition_cache() { + let fixture = Fixture::new(GameType::Oblivion); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + let mut metadata = PluginMetadata::new(BLANK_ESM).unwrap(); + metadata.set_load_after_files(vec![ + File::new("plugin.esp".into()) + .with_condition("file(\"plugin.esp\")".into()), + ]); + game.database() + .write() + .unwrap() + .set_plugin_user_metadata(metadata); + + let evaluated_metadata = game + .database() + .read() + .unwrap() + .plugin_user_metadata(BLANK_ESM, true) + .unwrap(); + assert!(evaluated_metadata.is_none()); + + std::fs::File::create(fixture.data_path().join("plugin.esp")).unwrap(); + + game.set_additional_data_paths(&[Path::new("")]).unwrap(); + + let evaluated_metadata = game + .database() + .read() + .unwrap() + .plugin_user_metadata(BLANK_ESM, true) + .unwrap() + .unwrap(); + assert!(!evaluated_metadata.load_after_files().is_empty()); + } + + #[test] + fn should_update_where_load_order_plugins_are_found() { + let fixture = Fixture::new(GameType::Oblivion); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + game.load_current_load_order_state().unwrap(); + + let mut load_order: Vec<_> = + game.load_order().iter().map(ToString::to_string).collect(); + + let filename = "plugin.esp"; + let data_file_path = fixture + .game_path + .parent() + .unwrap() + .join("Data") + .join(filename); + + std::fs::create_dir_all(data_file_path.parent().unwrap()).unwrap(); + std::fs::copy(fixture.data_path().join(BLANK_ESP), &data_file_path).unwrap(); + + std::fs::File::options() + .write(true) + .open(&data_file_path) + .unwrap() + .set_modified(SystemTime::now() + Duration::from_secs(3600)) + .unwrap(); + + game.set_additional_data_paths(&[data_file_path.parent().unwrap()]) + .unwrap(); + game.load_current_load_order_state().unwrap(); + + load_order.push(filename.to_owned()); + + assert_eq!(load_order, game.load_order()); + } + } + } + + mod is_valid_plugin { + use super::*; + + use crate::tests::{NON_ASCII_ESM, NON_PLUGIN_FILE}; + + #[parameterized_test(ALL_GAME_TYPES)] + fn should_return_true_for_a_valid_non_ascii_plugin(game_type: GameType) { + let fixture = Fixture::new(game_type); + + std::fs::copy( + fixture.data_path().join(BLANK_ESM), + fixture.data_path().join(NON_ASCII_ESM), + ) + .unwrap(); + + let game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + assert!(game.is_valid_plugin(Path::new(NON_ASCII_ESM))); + } + + #[parameterized_test(ALL_GAME_TYPES)] + fn should_return_true_for_an_omwscripts_plugin(game_type: GameType) { + let fixture = Fixture::new(game_type); + + let game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + let plugin = fixture.data_path().join("empty.omwscripts"); + std::fs::File::create(&plugin).unwrap(); + + if game_type == GameType::OpenMW { + assert!(game.is_valid_plugin(&plugin)); + } else { + assert!(!game.is_valid_plugin(&plugin)); + } + } + + #[parameterized_test(ALL_GAME_TYPES)] + fn should_return_false_for_a_non_plugin_file(game_type: GameType) { + let fixture = Fixture::new(game_type); + + let game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + assert!(!game.is_valid_plugin(Path::new(NON_PLUGIN_FILE))); + } + + #[parameterized_test(ALL_GAME_TYPES)] + fn should_return_false_for_an_empty_file(game_type: GameType) { + let fixture = Fixture::new(game_type); + + let game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + let empty_file_path = fixture.data_path().join("empty.esp"); + std::fs::File::create(&empty_file_path).unwrap(); + + assert!(!game.is_valid_plugin(&empty_file_path)); + } + + #[parameterized_test(ALL_GAME_TYPES)] + fn should_try_ghosted_path_if_given_plugin_does_not_exist_unless_game_is_openmw( + game_type: GameType, + ) { + let fixture = Fixture::new(game_type); + + let game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + let path = fixture.data_path().join(BLANK_MASTER_DEPENDENT_ESM); + + if game_type == GameType::OpenMW { + std::fs::rename( + &path, + path.with_file_name(format!("{BLANK_MASTER_DEPENDENT_ESM}.ghost")), + ) + .unwrap(); + + assert!(!game.is_valid_plugin(&path)); + } else { + assert!(!path.exists()); + + assert!(game.is_valid_plugin(&path)); + } + } + + #[test] + fn should_resolve_relative_paths_relative_to_the_data_path() { + let fixture = Fixture::new(GameType::Oblivion); + + let game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + let path = Path::new("..") + .join(fixture.data_path().file_name().unwrap()) + .join(BLANK_ESM); + + assert!(game.is_valid_plugin(&path)); + } + + #[test] + fn should_use_absolute_paths_as_given() { + let fixture = Fixture::new(GameType::Oblivion); + + let game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + let path = fixture.data_path().join(BLANK_ESM); + + assert!(game.is_valid_plugin(&path)); + } + } + + mod load_plugin_headers { + use crate::tests::NON_PLUGIN_FILE; + + use super::*; + + #[parameterized_test(ALL_GAME_TYPES)] + fn should_load_the_headers_of_the_given_plugins(game_type: GameType) { + let fixture = Fixture::new(game_type); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + assert!(game.plugin(BLANK_ESM).is_none()); + assert!(game.plugin(BLANK_DIFFERENT_ESM).is_none()); + assert!(game.plugin(BLANK_ESP).is_none()); + + game.load_plugin_headers(&[Path::new(BLANK_ESM), Path::new(BLANK_ESP)]) + .unwrap(); + + let plugin = game.plugin(BLANK_ESM).unwrap(); + assert_eq!("5.0", plugin.version().unwrap()); + assert!(plugin.crc().is_none()); + + assert!(game.plugin(BLANK_DIFFERENT_ESM).is_none()); + assert!(game.plugin(BLANK_ESP).is_some()); + } + + #[test] + fn should_not_modify_loaded_plugins_storage_if_given_a_non_plugin() { + let fixture = Fixture::new(GameType::Morrowind); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + game.load_plugin_headers(&[Path::new(BLANK_ESM)]).unwrap(); + assert!(game.plugin(BLANK_ESM).is_some()); + + assert!( + game.load_plugin_headers(&[Path::new(NON_PLUGIN_FILE)]) + .is_err() + ); + + assert!(game.plugin(BLANK_ESM).is_some()); + assert!(game.plugin(NON_PLUGIN_FILE).is_none()); + } + + #[test] + fn should_not_clear_the_plugins_cache() { + let fixture = Fixture::new(GameType::Morrowind); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + game.load_plugin_headers(&[Path::new(BLANK_ESM)]).unwrap(); + assert!(game.plugin(BLANK_ESM).is_some()); + + game.load_plugin_headers(&[Path::new(BLANK_ESP)]).unwrap(); + + assert!(game.plugin(BLANK_ESM).is_some()); + assert!(game.plugin(BLANK_ESP).is_some()); + } + + #[test] + fn should_replace_an_existing_cache_entry_for_the_same_plugin() { + let fixture = Fixture::new(GameType::Morrowind); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + game.load_plugin_headers(&[Path::new(BLANK_ESM)]).unwrap(); + let plugin1: *const str = game.plugin(BLANK_ESM).unwrap().name(); + let plugin2: *const str = game.plugin(BLANK_ESM).unwrap().name(); + + assert_eq!(plugin1, plugin2); + + game.load_plugin_headers(&[Path::new(BLANK_ESM)]).unwrap(); + + let plugin3: *const str = game.plugin(BLANK_ESM).unwrap().name(); + + assert_ne!(plugin2, plugin3); + } + } + + mod load_plugins { + use crate::tests::BLANK_FULL_ESM; + + use super::*; + + #[parameterized_test(ALL_GAME_TYPES)] + fn should_fully_load_the_given_plugins(game_type: GameType) { + let fixture = Fixture::new(game_type); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + assert!(game.plugin(BLANK_ESM).is_none()); + assert!(game.plugin(BLANK_DIFFERENT_ESM).is_none()); + assert!(game.plugin(BLANK_ESP).is_none()); + + game.load_plugins(&[Path::new(BLANK_ESM), Path::new(BLANK_ESP)]) + .unwrap(); + + let plugin = game.plugin(BLANK_ESM).unwrap(); + assert_eq!("5.0", plugin.version().unwrap()); + assert!(plugin.crc().is_some()); + + assert!(game.plugin(BLANK_DIFFERENT_ESM).is_none()); + assert!(game.plugin(BLANK_ESP).is_some()); + } + + #[test] + fn should_not_clear_the_plugins_cache() { + let fixture = Fixture::new(GameType::Morrowind); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + game.load_plugin_headers(&[Path::new(BLANK_ESM)]).unwrap(); + assert!(game.plugin(BLANK_ESM).is_some()); + + game.load_plugin_headers(&[Path::new(BLANK_ESP)]).unwrap(); + + assert!(game.plugin(BLANK_ESM).is_some()); + assert!(game.plugin(BLANK_ESP).is_some()); + } + + #[test] + fn should_replace_an_existing_cache_entry_for_the_same_plugin() { + let fixture = Fixture::new(GameType::Morrowind); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + game.load_plugins(&[Path::new(BLANK_ESM)]).unwrap(); + let plugin1: *const str = game.plugin(BLANK_ESM).unwrap().name(); + let plugin2: *const str = game.plugin(BLANK_ESM).unwrap().name(); + + assert_eq!(plugin1, plugin2); + + game.load_plugins(&[Path::new(BLANK_ESM)]).unwrap(); + + let plugin3: *const str = game.plugin(BLANK_ESM).unwrap().name(); + + assert_ne!(plugin2, plugin3); + } + + #[parameterized_test(ALL_GAME_TYPES)] + fn should_error_if_loading_a_plugin_with_a_master_that_is_not_loaded_if_game_is_morrowind_or_starfield( + game_type: GameType, + ) { + let fixture = Fixture::new(game_type); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + let paths = &[Path::new(BLANK_MASTER_DEPENDENT_ESM)]; + + if matches!( + game_type, + GameType::Morrowind | GameType::OpenMW | GameType::Starfield + ) { + match game.load_plugins(paths) { + Err(LoadPluginsError::PluginNotLoaded(p)) => { + if game_type == GameType::Starfield { + assert_eq!(BLANK_FULL_ESM, p); + } else { + assert_eq!(BLANK_ESM, p); + } + } + _ => panic!("Expected an error due to esplugin metadata not found"), + } + } else { + game.load_plugins(paths).unwrap(); + + assert!(game.plugin(BLANK_MASTER_DEPENDENT_ESM).is_some()); + } + } + + #[parameterized_test(ALL_GAME_TYPES)] + fn should_not_error_if_loading_a_plugin_with_a_master_that_is_also_being_loaded_if_game_is_morrowind_or_starfield( + game_type: GameType, + ) { + let fixture = Fixture::new(game_type); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + let paths: &[&Path] = if game_type == GameType::Starfield { + &[ + Path::new(BLANK_MASTER_DEPENDENT_ESM), + Path::new(BLANK_FULL_ESM), + ] + } else { + &[Path::new(BLANK_MASTER_DEPENDENT_ESM), Path::new(BLANK_ESM)] + }; + + game.load_plugins(paths).unwrap(); + + assert!(game.plugin(BLANK_MASTER_DEPENDENT_ESM).is_some()); + } + + #[parameterized_test(ALL_GAME_TYPES)] + fn should_not_error_if_loading_a_plugin_with_a_master_that_is_already_loaded_if_game_is_morrowind_or_starfield( + game_type: GameType, + ) { + let fixture = Fixture::new(game_type); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + let master = if game_type == GameType::Starfield { + BLANK_FULL_ESM + } else { + BLANK_ESM + }; + + game.load_plugins(&[Path::new(master)]).unwrap(); + + game.load_plugins(&[Path::new(BLANK_MASTER_DEPENDENT_ESM)]) + .unwrap(); + + assert!(game.plugin(BLANK_MASTER_DEPENDENT_ESM).is_some()); + } + } + + mod load_plugins_common { + use super::*; + + #[parameterized_test(ALL_GAME_TYPES)] + fn should_find_archives_in_additional_data_paths(game_type: GameType) { + let fixture = Fixture::new(game_type); + + let extension = if matches!( + game_type, + GameType::Fallout4 | GameType::Fallout4VR | GameType::Starfield + ) { + ".ba2" + } else { + ".bsa" + }; + + let path1 = fixture + .game_path + .join("sub1") + .join("archive") + .with_extension(extension); + let path2 = fixture + .game_path + .join("sub2") + .join("archive") + .with_extension(extension); + std::fs::create_dir_all(path1.parent().unwrap()).unwrap(); + std::fs::create_dir_all(path2.parent().unwrap()).unwrap(); + std::fs::File::create(&path1).unwrap(); + std::fs::File::create(&path2).unwrap(); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + game.set_additional_data_paths(&[path1.parent().unwrap(), path2.parent().unwrap()]) + .unwrap(); + + game.load_plugins_common(&[], LoadScope::HeaderOnly) + .unwrap(); + + assert_eq!(HashSet::from([path1, path2]), game.cache.archive_paths); + } + + #[test] + fn should_clear_the_archive_cache_before_finding_archives() { + let fixture = Fixture::new(GameType::Oblivion); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + std::fs::File::create(fixture.data_path().join("Blank.bsa")).unwrap(); + + game.load_plugins_common(&[], LoadScope::HeaderOnly) + .unwrap(); + game.load_plugins_common(&[], LoadScope::HeaderOnly) + .unwrap(); + + assert_eq!(1, game.cache.archive_paths.len()); + } + + #[test] + fn should_not_error_if_an_installed_filename_has_non_windows_1252_encodable_characters() + { + let fixture = Fixture::new(GameType::Oblivion); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + let filename = + "\u{2551}\u{00BB}\u{00C1}\u{2510}\u{2557}\u{00FE}\u{00C3}\u{00CE}.txt"; + std::fs::File::create(fixture.data_path().join(filename)).unwrap(); + + assert!(game.load_plugins_common(&[], LoadScope::HeaderOnly).is_ok()); + } + + #[test] + fn should_error_given_duplicate_filenames() { + let fixture = Fixture::new(GameType::Oblivion); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + let paths = &[ + Path::new("a").join(BLANK_ESM), + Path::new("b").join(BLANK_ESM), + ]; + + let expected_path = if cfg!(windows) { + "b\\\\Blank.esm" + } else { + "b/Blank.esm" + }; + match game.load_plugins_common(&[&paths[0], &paths[1]], LoadScope::HeaderOnly) { + Err(LoadPluginsError::PluginValidationError(e)) => { + assert_eq!( + format!( + "the path \"{expected_path}\" has a filename that is not unique" + ), + e.to_string() + ); + } + _ => panic!("Expected an error due to duplicate filenames"), + } + } + + #[test] + fn should_resolve_relative_paths_relative_to_the_data_path() { + let fixture = Fixture::new(GameType::Oblivion); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + let path = Path::new("..") + .join(fixture.data_path().file_name().unwrap()) + .join(BLANK_ESM); + + let plugins = game + .load_plugins_common(&[&path], LoadScope::HeaderOnly) + .unwrap(); + + assert_eq!(1, plugins.len()); + assert_eq!(BLANK_ESM, plugins[0].name()); + } + + #[test] + fn should_use_absolute_paths_as_given() { + let fixture = Fixture::new(GameType::Oblivion); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + let path = fixture.data_path().join(BLANK_ESM); + + let plugins = game + .load_plugins_common(&[&path], LoadScope::HeaderOnly) + .unwrap(); + + assert_eq!(1, plugins.len()); + assert_eq!(BLANK_ESM, plugins[0].name()); + } + + #[test] + fn should_trim_ghost_extensions_from_loaded_plugin_names() { + let fixture = Fixture::new(GameType::Oblivion); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + let path = fixture + .data_path() + .join(format!("{BLANK_MASTER_DEPENDENT_ESM}.ghost")); + + let plugins = game + .load_plugins_common(&[&path], LoadScope::HeaderOnly) + .unwrap(); + + assert_eq!(1, plugins.len()); + assert_eq!(BLANK_MASTER_DEPENDENT_ESM, plugins[0].name()); + } + } + + #[test] + fn clear_loaded_plugins_should_clear_the_plugins_cache() { + let fixture = Fixture::new(GameType::Oblivion); + + let mut game = + Game::with_local_path(fixture.game_type, &fixture.game_path, &fixture.local_path) + .unwrap(); + + game.load_plugin_headers(&[Path::new(BLANK_ESM)]).unwrap(); + + assert!(!game.cache.plugins.is_empty()); + + game.clear_loaded_plugins(); + + assert!(game.cache.plugins.is_empty()); + } + + mod sort_plugins { + use crate::tests::initial_load_order; + + use super::*; + + fn load_all_installed_plugins(game: &mut Game, fixture: &Fixture) { + let load_order = initial_load_order(fixture.game_type); + + let plugins: Vec<_> = load_order.iter().map(|(n, _)| Path::new(n)).collect(); + + game.load_current_load_order_state().unwrap(); + game.load_plugins(&plugins).unwrap(); + } + + #[test] + fn should_return_an_empty_list_if_given_an_empty_list() { + let fixture = Fixture::new(GameType::Oblivion); + + let game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + assert!(game.sort_plugins(&[]).unwrap().is_empty()); + } + + #[test] + fn should_only_sort_the_given_plugins() { + let fixture = Fixture::new(GameType::Oblivion); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + load_all_installed_plugins(&mut game, &fixture); + + let input = &[BLANK_ESP, BLANK_DIFFERENT_ESP]; + let sorted = game.sort_plugins(input).unwrap(); + + assert_eq!(input, sorted.as_slice()); + } + + #[test] + fn should_error_if_a_given_plugin_is_not_loaded() { + let fixture = Fixture::new(GameType::Oblivion); + + let game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + assert!(game.sort_plugins(&[BLANK_ESP]).is_err()); + } + } + + mod is_plugin_active { + use super::*; + + #[test] + fn should_be_independent_of_plugins_being_loaded() { + let fixture = Fixture::new(GameType::Oblivion); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + game.load_current_load_order_state().unwrap(); + + assert!(game.is_plugin_active(BLANK_ESM)); + assert!(!game.is_plugin_active(BLANK_ESP)); + + let paths = &[Path::new(BLANK_ESM), Path::new(BLANK_ESP)]; + game.load_plugin_headers(paths).unwrap(); + + assert!(game.is_plugin_active(BLANK_ESM)); + assert!(!game.is_plugin_active(BLANK_ESP)); + + game.load_plugins(paths).unwrap(); + + assert!(game.is_plugin_active(BLANK_ESM)); + assert!(!game.is_plugin_active(BLANK_ESP)); + } + } + + #[test] + fn set_load_order_should_persist_the_given_load_order() { + let fixture = Fixture::new(GameType::Oblivion); + + let mut game = + Game::with_local_path(fixture.game_type, &fixture.game_path, &fixture.local_path) + .unwrap(); + + game.load_current_load_order_state().unwrap(); + + let mut load_order: Vec<_> = + game.load_order().iter().map(ToString::to_string).collect(); + load_order.swap(7, 10); + let load_order: Vec<_> = load_order.iter().map(String::as_str).collect(); + + game.set_load_order(&load_order).unwrap(); + + let mut game = + Game::with_local_path(fixture.game_type, &fixture.game_path, &fixture.local_path) + .unwrap(); + + game.load_current_load_order_state().unwrap(); + + assert_eq!(load_order, game.load_order()); + } + + #[test] + fn should_support_loading_plugins_and_metadata_in_parallel() { + let fixture = Fixture::new(GameType::Morrowind); + + let mut game = + Game::with_local_path(fixture.game_type, &fixture.game_path, &fixture.local_path) + .unwrap(); + + let masterlist_path = fixture.local_path.join("masterlist.yaml"); + std::fs::write(&masterlist_path, "bash_tags: [Relev]").unwrap(); + + std::thread::scope(|s| { + let database = game.database(); + s.spawn(move || { + if let Ok(mut database) = database.write() { + database.load_masterlist(&masterlist_path).unwrap(); + } + }); + s.spawn(|| { + game.load_plugins(&[]).unwrap(); + }); + }); + } + } + + #[test] + fn to_plugin_sorting_data_should_filter_out_files_with_false_constraints() { + let game_type = GameType::Oblivion; + let true_constraint = "file(\"Blank.esm\")"; + let false_constraint = "file(\"missing.esm\")"; + + let fixture = Fixture::new(game_type); + + let plugin = Arc::new( + Plugin::new( + game_type, + &GameCache::default(), + &fixture.data_path().join(BLANK_ESP), + LoadScope::HeaderOnly, + ) + .unwrap(), + ); + + let mut database = Database::new(loot_condition_interpreter::State::new( + game_type.into(), + fixture.data_path(), + )); + + let masterlist_path = fixture.local_path.join("masterlist.yaml"); + let masterlist = format!( + "{{plugins: [{{name: Blank.esp, after: [{{name: A.esp, constraint: '{true_constraint}'}}, {{name: B.esp, constraint: '{false_constraint}'}}], req: [{{name: C.esp, constraint: '{true_constraint}'}}, {{name: D.esp, constraint: '{false_constraint}'}}]}}]}}" + ); + std::fs::write(&masterlist_path, masterlist).unwrap(); + + database.load_masterlist(&masterlist_path).unwrap(); + + let mut user_metadata = PluginMetadata::new(BLANK_ESP).unwrap(); + user_metadata.set_load_after_files(vec![ + File::new(BLANK_ESM.to_owned()).with_constraint(true_constraint.to_owned()), + File::new(BLANK_DIFFERENT_ESM.to_owned()).with_constraint(false_constraint.to_owned()), + ]); + user_metadata.set_requirements(vec![ + File::new(BLANK_DIFFERENT_ESP.to_owned()).with_constraint(true_constraint.to_owned()), + File::new(BLANK_MASTER_DEPENDENT_ESM.to_owned()) + .with_constraint(false_constraint.to_owned()), + ]); + + database.set_plugin_user_metadata(user_metadata); + + let data = to_plugin_sorting_data(&database, &plugin, 0).unwrap(); + + assert_eq!(["A.esp".to_owned()], *data.masterlist_load_after); + assert_eq!(["C.esp".to_owned()], *data.masterlist_req); + assert_eq!([BLANK_ESM.to_owned()], *data.user_load_after); + assert_eq!([BLANK_DIFFERENT_ESP.to_owned()], *data.user_req); + } + + mod game_cache { + use super::*; + + use crate::tests::{BLANK_ESM, source_plugins_path}; + + mod insert_plugins { + + use super::*; + + #[test] + fn should_add_plugins_not_already_cached() { + let mut cache = GameCache::default(); + + cache.insert_plugins(vec![ + Plugin::new( + GameType::Oblivion, + &cache, + &source_plugins_path(GameType::Oblivion).join(BLANK_ESM), + LoadScope::HeaderOnly, + ) + .unwrap(), + ]); + + assert_eq!(BLANK_ESM, cache.plugin(BLANK_ESM).unwrap().name()); + } + + #[test] + fn should_replace_plugins_that_are_already_cached() { + let mut cache = GameCache::default(); + + cache.insert_plugins(vec![ + Plugin::new( + GameType::Oblivion, + &cache, + &source_plugins_path(GameType::Oblivion).join(BLANK_ESM), + LoadScope::HeaderOnly, + ) + .unwrap(), + ]); + + assert!(cache.plugin(BLANK_ESM).unwrap().crc().is_none()); + + cache.insert_plugins(vec![ + Plugin::new( + GameType::Oblivion, + &cache, + &source_plugins_path(GameType::Oblivion).join(BLANK_ESM), + LoadScope::WholePlugin, + ) + .unwrap(), + ]); + + assert!(cache.plugin(BLANK_ESM).unwrap().crc().is_some()); + } + } + + mod plugin { + use super::*; + + #[test] + fn should_be_case_insensitive() { + let mut cache = GameCache::default(); + + cache.insert_plugins(vec![ + Plugin::new( + GameType::Oblivion, + &cache, + &source_plugins_path(GameType::Oblivion).join(BLANK_ESM), + LoadScope::HeaderOnly, + ) + .unwrap(), + ]); + + assert_eq!("Blank.esm", cache.plugin("blank.esm").unwrap().name()); + } + + #[test] + fn should_return_none_if_the_plugin_is_not_cached() { + let cache = GameCache::default(); + + assert!(cache.plugin(BLANK_ESM).is_none()); + } + } + + mod clear_plugins { + use super::*; + + #[test] + fn should_clear_any_cached_plugins() { + let mut cache = GameCache::default(); + + cache.insert_plugins(vec![ + Plugin::new( + GameType::Oblivion, + &cache, + &source_plugins_path(GameType::Oblivion).join(BLANK_ESM), + LoadScope::HeaderOnly, + ) + .unwrap(), + ]); + + assert!(!cache.plugins.is_empty()); + + cache.clear_plugins(); + + assert!(cache.plugins.is_empty()); + } + } + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 00000000..16a612ba --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,145 @@ +// Deny some rustc lints that are allow-by-default. +#![deny( + ambiguous_negative_literals, + impl_trait_overcaptures, + let_underscore_drop, + missing_copy_implementations, + missing_debug_implementations, + non_ascii_idents, + redundant_imports, + redundant_lifetimes, + trivial_casts, + trivial_numeric_casts, + unit_bindings, + // unreachable_pub, + unsafe_code +)] +#![deny(clippy::pedantic)] +// Allow a few clippy pedantic lints. +#![allow(clippy::doc_markdown)] +#![allow(clippy::must_use_candidate)] +#![allow(clippy::missing_errors_doc)] +// Selectively deny clippy restriction lints. +#![deny( + clippy::allow_attributes, + clippy::as_conversions, + clippy::as_underscore, + clippy::assertions_on_result_states, + clippy::big_endian_bytes, + clippy::cfg_not_test, + clippy::clone_on_ref_ptr, + clippy::create_dir, + clippy::dbg_macro, + clippy::decimal_literal_representation, + clippy::default_numeric_fallback, + clippy::doc_include_without_cfg, + clippy::empty_drop, + clippy::error_impl_error, + clippy::exit, + // clippy::exhaustive_enums, + clippy::expect_used, + // clippy::filetype_is_file, + clippy::float_cmp_const, + clippy::fn_to_numeric_cast_any, + clippy::get_unwrap, + clippy::host_endian_bytes, + clippy::if_then_some_else_none, + clippy::indexing_slicing, + clippy::infinite_loop, + clippy::integer_division, + clippy::integer_division_remainder_used, + clippy::iter_over_hash_type, + clippy::let_underscore_must_use, + clippy::lossy_float_literal, + clippy::map_err_ignore, + clippy::map_with_unused_argument_over_ranges, + clippy::mem_forget, + clippy::missing_assert_message, + clippy::missing_asserts_for_indexing, + clippy::mixed_read_write_in_expression, + clippy::multiple_inherent_impl, + clippy::multiple_unsafe_ops_per_block, + clippy::mutex_atomic, + clippy::mutex_integer, + clippy::needless_raw_strings, + clippy::non_ascii_literal, + clippy::non_zero_suggestions, + clippy::panic, + clippy::panic_in_result_fn, + clippy::partial_pub_fields, + clippy::pathbuf_init_then_push, + clippy::precedence_bits, + clippy::print_stderr, + clippy::print_stdout, + clippy::rc_buffer, + clippy::rc_mutex, + clippy::redundant_type_annotations, + clippy::ref_patterns, + clippy::rest_pat_in_fully_bound_structs, + clippy::str_to_string, + clippy::string_lit_chars_any, + clippy::string_slice, + clippy::string_to_string, + clippy::suspicious_xor_used_as_pow, + clippy::tests_outside_test_module, + clippy::todo, + clippy::try_err, + clippy::undocumented_unsafe_blocks, + clippy::unimplemented, + clippy::unnecessary_safety_comment, + clippy::unneeded_field_pattern, + clippy::unreachable, + clippy::unused_result_ok, + clippy::unwrap_in_result, + clippy::unwrap_used, + clippy::use_debug, + clippy::verbose_file_reads, + // clippy::wildcard_enum_match_arm, +)] +#![cfg_attr( + test, + allow( + clippy::assertions_on_result_states, + clippy::indexing_slicing, + clippy::missing_asserts_for_indexing, + clippy::panic, + clippy::unwrap_used, + ) +)] + +mod archive; +mod database; +pub mod error; +mod game; +mod logging; +pub mod metadata; +mod plugin; +mod sorting; +#[cfg(test)] +mod tests; +mod version; + +use std::{path::Path, slice::EscapeAscii}; + +use fancy_regex::{Error as RegexImplError, Regex, RegexBuilder}; + +pub use database::{Database, WriteMode}; +pub use game::{Game, GameType}; +pub use logging::{LogLevel, set_log_level, 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 case_insensitive_regex(value: &str) -> Result> { + RegexBuilder::new(value) + .case_insensitive(true) + .build() + .map_err(Into::into) +} + +fn escape_ascii(path: &Path) -> EscapeAscii { + path.as_os_str().as_encoded_bytes().escape_ascii() +} diff --git a/src/logging.rs b/src/logging.rs new file mode 100644 index 00000000..2ddb6848 --- /dev/null +++ b/src/logging.rs @@ -0,0 +1,324 @@ +use std::sync::{LazyLock, RwLock}; + +type Callback = dyn Fn(LogLevel, &str) + Send + Sync; + +pub(crate) static LOGGER: LazyLock> = + LazyLock::new(|| RwLock::new(Logger::new(Box::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(callback: T) +where + T: Fn(LogLevel, &str) + Send + Sync + 'static, +{ + let boxed = Box::new(callback); + + match LOGGER.write() { + Ok(mut logger) => logger.set_callback(boxed), + Err(e) => { + e.into_inner().set_callback(boxed); + LOGGER.clear_poison(); + } + } +} + +// Set the log severity level. +// +// The default level setting is trace. This function has no effect if no logging callback has been set. +pub fn set_log_level(level: LogLevel) { + match LOGGER.write() { + Ok(mut logger) => logger.set_level(level), + Err(e) => { + e.into_inner().set_level(level); + LOGGER.clear_poison(); + } + } +} + +/// 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, +} + +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"), + } + } +} + +impl From for log::Level { + fn from(value: LogLevel) -> Self { + match value { + LogLevel::Trace => log::Level::Trace, + LogLevel::Debug => log::Level::Debug, + LogLevel::Info => log::Level::Info, + LogLevel::Warning => log::Level::Warn, + LogLevel::Error => log::Level::Error, + } + } +} + +pub(crate) struct Logger { + callback: Box, + level: LogLevel, +} + +impl Logger { + fn new(callback: Box) -> Self { + Self { + callback, + level: LogLevel::Trace, + } + } + + pub(crate) fn log(&self, level: LogLevel, message: &str) { + if level >= self.level { + (self.callback)(level, message); + } + } + + fn level(&self) -> LogLevel { + self.level + } + + fn set_callback(&mut self, callback: Box) { + self.callback = callback; + } + + fn set_level(&mut self, level: LogLevel) { + self.level = level; + } +} + +#[clippy::format_args] +macro_rules! log { + ($level:expr, $($arg:tt)+) => { + // Log using the Rust log crate, as it's probably good to support that. + log::log!(log::Level::from($level), $($arg)+); + + // Also log using the callback. + let message = std::fmt::format(format_args!($($arg)+)); + + match $crate::logging::LOGGER.read() { + Ok(logger) => logger.log($level, &message), + Err(e) => { + $crate::logging::LOGGER.clear_poison(); + e.into_inner().log($level, &message); + } + } + }; +} + +#[clippy::format_args] +macro_rules! error { + ($($arg:tt)+) => { $crate::logging::log!(crate::LogLevel::Error, $($arg)+) }; +} + +#[clippy::format_args] +macro_rules! warning { + ($($arg:tt)+) => { $crate::logging::log!(crate::LogLevel::Warning, $($arg)+) }; +} + +#[clippy::format_args] +macro_rules! info { + ($($arg:tt)+) => { $crate::logging::log!(crate::LogLevel::Info, $($arg)+) }; +} + +#[clippy::format_args] +macro_rules! debug { + ($($arg:tt)+) => { $crate::logging::log!(crate::LogLevel::Debug, $($arg)+) }; +} + +#[clippy::format_args] +macro_rules! trace { + ($($arg:tt)+) => { $crate::logging::log!(crate::LogLevel::Trace, $($arg)+) }; +} + +pub fn is_log_enabled(level: LogLevel) -> bool { + if log::log_enabled!(level.into()) { + return true; + } + + let logger = match LOGGER.read() { + Ok(logger) => logger, + Err(e) => { + LOGGER.clear_poison(); + e.into_inner() + } + }; + + level >= logger.level() +} + +pub(crate) use {debug, error, info, log, trace, warning as warn}; + +pub(crate) fn format_details(error: &E) -> String { + let mut details = error.to_string(); // The display string. + if let Some(source) = error.source() { + details += ": "; + details += &format_details(&source); + } + + details +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::sync::{Arc, LazyLock, Mutex}; + + // Since the callback is a global object, these tests need to be run in + // series so that one doesn't switch out the callback between another + // doing the same and trying to use it. + static TEST_LOCK: Mutex<()> = Mutex::new(()); + + mod set_logging_callback { + use super::*; + + #[test] + fn should_support_a_function() { + static MESSAGES: LazyLock>> = + LazyLock::new(|| Mutex::new(Vec::new())); + + fn callback(level: LogLevel, message: &str) { + if let Ok(mut messages) = MESSAGES.lock() { + messages.push((level, message.to_owned())); + } + } + + let _lock = TEST_LOCK.lock().unwrap(); + + set_logging_callback(callback); + + error!("Test message"); + + assert_eq!( + vec![(LogLevel::Error, "Test message".into())], + *MESSAGES.lock().unwrap() + ); + } + + #[test] + fn should_support_a_closure_with_captured_state() { + let _lock = TEST_LOCK.lock().unwrap(); + + let messages = Arc::new(Mutex::new(Vec::new())); + let cloned_messages = Arc::clone(&messages); + let callback = move |level, message: &str| { + if let Ok(mut messages) = cloned_messages.lock() { + messages.push((level, message.to_owned())); + } + }; + + set_logging_callback(callback); + + error!("Test message"); + + assert_eq!( + vec![(LogLevel::Error, "Test message".into())], + *messages.lock().unwrap() + ); + } + + #[test] + fn set_logging_callback_should_be_callable_multiple_times() { + static MESSAGES: LazyLock>> = + LazyLock::new(|| Mutex::new(Vec::new())); + + fn callback_fn(level: LogLevel, message: &str) { + if let Ok(mut messages) = MESSAGES.lock() { + messages.push((level, message.to_owned())); + } + } + + let _lock = TEST_LOCK.lock().unwrap(); + + let callback = |_, _: &str| {}; + set_logging_callback(callback); + + let messages = Arc::new(Mutex::new(Vec::new())); + let cloned_messages = Arc::clone(&messages); + let callback = move |level, message: &str| { + if let Ok(mut messages) = cloned_messages.lock() { + messages.push((level, message.to_owned())); + } + }; + + set_logging_callback(callback); + + error!("Test message"); + + assert_eq!( + vec![(LogLevel::Error, "Test message".into())], + *messages.lock().unwrap() + ); + + set_logging_callback(callback_fn); + + error!("Test message"); + + assert_eq!( + vec![(LogLevel::Error, "Test message".into())], + *MESSAGES.lock().unwrap() + ); + } + } + + mod set_log_level { + use super::*; + + #[test] + fn should_set_the_level_used_to_filter_messages_passed_to_the_callback() { + static MESSAGES: LazyLock>> = + LazyLock::new(|| Mutex::new(Vec::new())); + + fn callback(level: LogLevel, message: &str) { + if let Ok(mut messages) = MESSAGES.lock() { + messages.push((level, message.to_owned())); + } + } + + let _lock = TEST_LOCK.lock().unwrap(); + + set_logging_callback(callback); + set_log_level(LogLevel::Warning); + + error!("Test error"); + info!("Test info"); + + assert_eq!( + vec![(LogLevel::Error, "Test error".into())], + *MESSAGES.lock().unwrap() + ); + } + } + + mod is_log_enabled { + use super::*; + + #[test] + fn should_return_true_iff_log_level_is_less_than_or_equal_to_given_level() { + set_log_level(LogLevel::Warning); + + assert!(!is_log_enabled(LogLevel::Trace)); + assert!(!is_log_enabled(LogLevel::Debug)); + assert!(!is_log_enabled(LogLevel::Info)); + assert!(is_log_enabled(LogLevel::Warning)); + assert!(is_log_enabled(LogLevel::Error)); + } + } +} diff --git a/src/metadata/error.rs b/src/metadata/error.rs new file mode 100644 index 00000000..523031b2 --- /dev/null +++ b/src/metadata/error.rs @@ -0,0 +1,421 @@ +//! Holds all error types related to LOOT metadata. +use std::path::PathBuf; + +use fancy_regex::Error as RegexImplError; +use saphyr::Marker; + +use crate::{escape_ascii, metadata::MessageContent}; + +use super::yaml::{YamlObjectType, to_unmarked_yaml}; + +/// Represents an error that occurred when validating a collection of +/// [MessageContent] objects. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct MultilingualMessageContentsError; + +impl std::fmt::Display for MultilingualMessageContentsError { + 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 MultilingualMessageContentsError {} + +/// Represents an error that occurred while parsing metadata. +#[derive(Debug)] +pub struct ParseMetadataError { + marker: saphyr::Marker, + reason: MetadataParsingErrorReason, +} + +impl ParseMetadataError { + pub(super) fn new(marker: Marker, reason: MetadataParsingErrorReason) -> Self { + Self { marker, reason } + } + + pub(super) fn invalid_condition( + marker: Marker, + condition: String, + cause: loot_condition_interpreter::Error, + ) -> Self { + Self { + marker, + reason: MetadataParsingErrorReason::InvalidCondition(Box::new((condition, cause))), + } + } + + pub(super) fn missing_key( + marker: Marker, + key: &'static str, + yaml_type: YamlObjectType, + ) -> Self { + Self { + marker, + reason: MetadataParsingErrorReason::MissingKey(key, yaml_type), + } + } + + pub(super) fn duplicate_entry(marker: Marker, id: String, yaml_type: YamlObjectType) -> Self { + Self { + marker, + reason: MetadataParsingErrorReason::DuplicateEntry(id, yaml_type), + } + } + + pub(super) fn unexpected_type( + marker: Marker, + yaml_type: YamlObjectType, + expected_type: ExpectedType, + ) -> Self { + Self { + marker, + reason: MetadataParsingErrorReason::UnexpectedType(expected_type, yaml_type), + } + } + + pub(super) fn unexpected_value_type( + marker: Marker, + key: &'static str, + yaml_type: YamlObjectType, + expected_type: ExpectedType, + ) -> Self { + Self { + marker, + reason: MetadataParsingErrorReason::UnexpectedValueType(key, expected_type, yaml_type), + } + } +} + +impl std::fmt::Display for ParseMetadataError { + 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.reason + ) + } +} + +impl std::error::Error for ParseMetadataError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match &self.reason { + MetadataParsingErrorReason::InvalidCondition(b) => Some(&b.1), + MetadataParsingErrorReason::InvalidRegex(b) => Some(b), + _ => None, + } + } +} + +impl From for ParseMetadataError { + fn from(value: saphyr::ScanError) -> Self { + Self { + marker: *value.marker(), + reason: MetadataParsingErrorReason::Other(Box::new(value)), + } + } +} + +#[derive(Debug)] +pub(super) enum MetadataParsingErrorReason { + InvalidCondition(Box<(String, loot_condition_interpreter::Error)>), + MissingKey(&'static str, YamlObjectType), + InvalidRegex(Box), + InvalidMultilingualMessageContents, + UnexpectedType(ExpectedType, YamlObjectType), + UnexpectedValueType(&'static str, ExpectedType, YamlObjectType), + MissingPlaceholder(String, usize), + MissingSubstitution(String), + NonU32Number(i64), + DuplicateEntry(String, YamlObjectType), + Other(Box), +} + +impl std::fmt::Display for MetadataParsingErrorReason { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidCondition(b) => { + write!(f, "the condition string \"{}\" is invalid", b.0) + } + Self::MissingKey(key, yaml_object_type) => { + write!(f, "\"{key}\" key in \"{yaml_object_type}\" map is missing") + } + Self::InvalidRegex(_) => { + write!(f, "invalid regex in \"name\" key") + } + Self::InvalidMultilingualMessageContents => MultilingualMessageContentsError.fmt(f), + Self::UnexpectedType(expected_type, yaml_object_type) => { + write!(f, "\"{yaml_object_type}\" object must be {expected_type}") + } + Self::UnexpectedValueType(key, expected_type, yaml_object_type) => write!( + f, + "\"{key}\" key in \"{yaml_object_type}\" map must be {expected_type}" + ), + Self::MissingPlaceholder(sub, placeholder_index) => write!( + f, + "failed to substitute \"{sub}\" into message, no placeholder {{{placeholder_index}}} was found" + ), + Self::MissingSubstitution(placeholder) => write!( + f, + "failed to substitute a value into message, no substitution was given for the placeholder \"{placeholder}\"" + ), + Self::NonU32Number(i) => { + write!(f, "{i} is not valid as a 32-bit unsigned integer") + } + Self::DuplicateEntry(id, yaml_object_type) => write!( + f, + "more than one entry exists for {yaml_object_type} \"{id}\"" + ), + Self::Other(m) => m.fmt(f), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub(super) enum ExpectedType { + String, + Number, + Array, + Map, + MapOrString, + ArrayOrString, +} + +impl std::fmt::Display for ExpectedType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ExpectedType::String => write!(f, "a string"), + ExpectedType::Number => write!(f, "a number"), + ExpectedType::Array => write!(f, "an array"), + ExpectedType::Map => write!(f, "a map"), + ExpectedType::MapOrString => write!(f, "a map or string"), + ExpectedType::ArrayOrString => write!(f, "an array or string"), + } + } +} + +/// Represents an error encountered while parsing and compiling a regex plugin +/// name. +#[derive(Clone, Debug)] +pub struct RegexError(Box); + +impl std::fmt::Display for RegexError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "encountered a regex error: {}", self.0) + } +} + +impl std::error::Error for RegexError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(&self.0) + } +} + +impl From> for RegexError { + fn from(value: Box) -> Self { + Self(value) + } +} + +/// Represents an error encountered while loading metadata from a file. +#[derive(Debug)] +pub struct LoadMetadataError { + path: PathBuf, + reason: MetadataDocumentParsingError, +} + +impl LoadMetadataError { + pub(super) fn new(path: PathBuf, reason: MetadataDocumentParsingError) -> Self { + Self { path, reason } + } + + pub(super) fn from_io_error(path: PathBuf, error: std::io::Error) -> Self { + Self { + path, + reason: MetadataDocumentParsingError::IoError(error), + } + } +} + +impl std::fmt::Display for LoadMetadataError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "failed to parse the file at \"{}\"", + escape_ascii(&self.path) + ) + } +} + +impl std::error::Error for LoadMetadataError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(&self.reason) + } +} + +#[derive(Debug)] +#[non_exhaustive] +pub(super) enum MetadataDocumentParsingError { + PathNotFound, + NoDocuments, + MoreThanOneDocument(usize), + IoError(std::io::Error), + MetadataParsingError(ParseMetadataError), + YamlMergeKeyError(YamlMergeKeyError), +} + +impl std::fmt::Display for MetadataDocumentParsingError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::PathNotFound => write!(f, "path not found"), + Self::NoDocuments => write!(f, "no YAML document found"), + Self::MoreThanOneDocument(n) => write!(f, "expected 1 YAML document, found {n}"), + Self::IoError(_) => write!(f, "an I/O error occurred"), + Self::MetadataParsingError(_) => write!(f, "a metadata parsing error occurred"), + Self::YamlMergeKeyError(_) => { + write!(f, "an error occurred while resolving YAML merge keys",) + } + } + } +} + +impl std::error::Error for MetadataDocumentParsingError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::PathNotFound | Self::NoDocuments | Self::MoreThanOneDocument(_) => None, + Self::IoError(e) => Some(e), + Self::MetadataParsingError(e) => Some(e), + Self::YamlMergeKeyError(e) => Some(e), + } + } +} + +impl From for MetadataDocumentParsingError { + fn from(value: std::io::Error) -> Self { + MetadataDocumentParsingError::IoError(value) + } +} + +impl From for MetadataDocumentParsingError { + fn from(value: ParseMetadataError) -> Self { + MetadataDocumentParsingError::MetadataParsingError(value) + } +} + +impl From for MetadataDocumentParsingError { + fn from(value: YamlMergeKeyError) -> Self { + MetadataDocumentParsingError::YamlMergeKeyError(value) + } +} + +impl From for MetadataDocumentParsingError { + fn from(value: saphyr::ScanError) -> Self { + Self::MetadataParsingError(value.into()) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) struct YamlMergeKeyError { + start: Marker, + yaml: String, +} + +impl YamlMergeKeyError { + pub(super) fn new(value: &saphyr::MarkedYaml) -> Self { + let mut yaml = String::new(); + + let unmarked_yaml = to_unmarked_yaml(value); + + if saphyr::YamlEmitter::new(&mut yaml) + .dump(&unmarked_yaml) + .is_ok() + { + // The emitter starts the dumped YAML with ---\n, so strip that. + let index = 4; + if yaml.is_char_boundary(index) { + yaml = yaml.split_off(index); + } + } else { + yaml = format!("{unmarked_yaml:?}"); + } + + YamlMergeKeyError { + start: value.span.start, + yaml, + } + } +} + +impl std::fmt::Display for YamlMergeKeyError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "invalid YAML merge key value at line {} column {}: {}", + self.start.line(), + self.start.col(), + self.yaml + ) + } +} + +impl std::error::Error for YamlMergeKeyError {} + +/// Represents an error that occurred while trying to write metadata to a file. +#[derive(Debug)] +pub struct WriteMetadataError { + path: PathBuf, + reason: WriteMetadataErrorReason, +} + +impl WriteMetadataError { + pub(crate) fn new(path: PathBuf, reason: WriteMetadataErrorReason) -> Self { + Self { path, reason } + } +} + +impl std::fmt::Display for WriteMetadataError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self.reason { + WriteMetadataErrorReason::ParentDirectoryNotFound => write!( + f, + "the parent directory of the path \"{}\" was not found", + escape_ascii(&self.path) + ), + WriteMetadataErrorReason::PathAlreadyExists => { + write!( + f, + "the path \"{}\" already exists", + escape_ascii(&self.path) + ) + } + WriteMetadataErrorReason::IoError(_) => write!(f, "an I/O error occurred"), + } + } +} + +impl std::error::Error for WriteMetadataError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match &self.reason { + WriteMetadataErrorReason::IoError(e) => Some(e), + _ => None, + } + } +} + +#[derive(Debug)] +pub(crate) enum WriteMetadataErrorReason { + ParentDirectoryNotFound, + PathAlreadyExists, + IoError(std::io::Error), +} + +impl From for WriteMetadataErrorReason { + fn from(value: std::io::Error) -> Self { + WriteMetadataErrorReason::IoError(value) + } +} diff --git a/src/metadata/file.rs b/src/metadata/file.rs new file mode 100644 index 00000000..e33d10f2 --- /dev/null +++ b/src/metadata/file.rs @@ -0,0 +1,520 @@ +use saphyr::{MarkedYaml, Scalar, YamlData}; +use unicase::UniCase; + +use super::{ + error::{ExpectedType, MultilingualMessageContentsError, ParseMetadataError}, + message::{ + MessageContent, emit_message_contents, parse_message_contents_yaml, + validate_message_contents, + }, + yaml::{ + EmitYaml, TryFromYaml, YamlEmitter, YamlObjectType, get_required_string_value, + get_string_value, get_value, parse_condition, + }, +}; + +/// 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>, + detail: Box<[MessageContent]>, + condition: Option>, + constraint: Option>, +} + +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.into_boxed_str()); + self + } + + /// Set the condition string. + #[must_use] + pub fn with_condition(mut self, condition: String) -> Self { + self.condition = Some(condition.into_boxed_str()); + 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, + ) -> Result { + validate_message_contents(&detail)?; + self.detail = detail.into_boxed_slice(); + Ok(self) + } + + /// Set the constraint string. + #[must_use] + pub fn with_constraint(mut self, constraint: String) -> Self { + self.constraint = Some(constraint.into_boxed_str()); + 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() + } + + /// Get the constraint string. + pub fn constraint(&self) -> Option<&str> { + self.constraint.as_deref() + } +} + +/// Represents a case-insensitive filename. +#[derive(Clone, Debug, Default)] +pub struct Filename(Box); + +impl Filename { + /// Construct a Filename using the given string. + #[must_use] + pub fn new(s: String) -> Self { + Filename(s.into()) + } + + /// Get this Filename as a string. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl PartialEq for Filename { + fn eq(&self, other: &Self) -> bool { + unicase::eq(&self.0, &other.0) + } +} + +impl Eq for Filename {} + +impl PartialOrd for Filename { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Filename { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + UniCase::new(&self.0).cmp(&UniCase::new(&other.0)) + } +} + +impl std::hash::Hash for Filename { + fn hash(&self, state: &mut H) { + UniCase::new(&self.0).hash(state); + } +} + +impl AsRef 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 { + self.0.fmt(f) + } +} + +impl TryFromYaml for File { + fn try_from_yaml(value: &MarkedYaml) -> Result { + match &value.data { + YamlData::Value(Scalar::String(s)) => Ok(File { + name: Filename::new(s.to_string()), + display_name: None, + detail: Box::default(), + condition: None, + constraint: None, + }), + YamlData::Mapping(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 get_value(h, "detail") { + Some(n) => parse_message_contents_yaml( + n, + "detail", + YamlObjectType::PluginCleaningData, + )?, + None => Box::default(), + }; + + let condition = parse_condition(h, "condition", YamlObjectType::File)?; + + let constraint = parse_condition(h, "constraint", YamlObjectType::File)?; + + Ok(File { + name: Filename::new(name.to_owned()), + display_name: display_name.map(|(_, s)| s.into()), + detail, + condition, + constraint, + }) + } + _ => Err(ParseMetadataError::unexpected_type( + value.span.start, + YamlObjectType::File, + ExpectedType::MapOrString, + )), + } + } +} + +impl EmitYaml for File { + fn is_scalar(&self) -> bool { + self.condition.is_none() + && self.constraint.is_none() + && self.detail.is_empty() + && self.display_name.is_none() + } + + fn emit_yaml(&self, emitter: &mut YamlEmitter) { + if self.is_scalar() { + emitter.single_quoted_str(self.name.as_str()); + } else { + emitter.begin_map(); + + emitter.map_key("name"); + emitter.single_quoted_str(self.name.as_str()); + + if let Some(display_name) = &self.display_name { + emitter.map_key("display"); + emitter.single_quoted_str(display_name); + } + + if let Some(condition) = &self.condition { + emitter.map_key("condition"); + emitter.single_quoted_str(condition); + } + + if let Some(constraint) = &self.constraint { + emitter.map_key("constraint"); + emitter.single_quoted_str(constraint); + } + + emit_message_contents(&self.detail, emitter, "detail"); + + emitter.end_map(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod file_eq { + use super::*; + + #[test] + fn should_be_case_insensitive_on_name() { + assert_eq!(File::new("name".into()), File::new("name".into())); + assert_eq!(File::new("name".into()), File::new("NAME".into())); + assert_ne!(File::new("name1".into()), File::new("name2".into())); + } + } + + mod filename_eq { + use super::*; + + #[test] + fn should_be_case_insensitive_on_name() { + assert_eq!(Filename::new("name".into()), Filename::new("name".into())); + assert_eq!(Filename::new("name".into()), Filename::new("NAME".into())); + assert_ne!(Filename::new("name1".into()), Filename::new("name2".into())); + } + } + + mod try_from_yaml { + use crate::metadata::parse; + + use super::*; + + #[test] + fn should_only_set_name_if_decoding_from_scalar() { + let yaml = parse("name1"); + + let file = File::try_from_yaml(&yaml).unwrap(); + + assert_eq!("name1", file.name().as_str()); + assert!(file.display_name().is_none()); + assert!(file.condition().is_none()); + assert!(file.constraint().is_none()); + assert!(file.detail().is_empty()); + } + + #[test] + fn should_error_if_given_a_list() { + let yaml = parse("[0, 1, 2]"); + + assert!(File::try_from_yaml(&yaml).is_err()); + } + + #[test] + fn should_error_if_name_is_missing() { + let yaml = parse("{display: display1}"); + + assert!(File::try_from_yaml(&yaml).is_err()); + } + + #[test] + fn should_error_if_given_an_invalid_condition() { + let yaml = parse("{name: name1, condition: invalid}"); + + assert!(File::try_from_yaml(&yaml).is_err()); + } + + #[test] + fn should_error_if_given_an_invalid_constraint() { + let yaml = parse("{name: name1, constraint: invalid}"); + + assert!(File::try_from_yaml(&yaml).is_err()); + } + + #[test] + fn should_set_all_given_fields() { + let yaml = parse( + "{name: name1, display: display1, condition: 'file(\"Foo.esp\")', constraint: 'file(\"Bar.esp\")', detail: 'details'}", + ); + + let file = File::try_from_yaml(&yaml).unwrap(); + + assert_eq!("name1", file.name().as_str()); + assert_eq!("display1", file.display_name().unwrap()); + assert_eq!("file(\"Foo.esp\")", file.condition().unwrap()); + assert_eq!("file(\"Bar.esp\")", file.constraint().unwrap()); + assert_eq!(&[MessageContent::new("details".into())], file.detail()); + } + + #[test] + fn should_leave_optional_fields_empty_if_not_present() { + let yaml = parse("{name: name1}"); + + let file = File::try_from_yaml(&yaml).unwrap(); + + assert_eq!("name1", file.name().as_str()); + assert!(file.display_name().is_none()); + assert!(file.condition().is_none()); + assert!(file.constraint().is_none()); + assert!(file.detail().is_empty()); + } + + #[test] + fn should_read_all_listed_detail_message_contents() { + let yaml = parse( + "{name: name1, detail: [{text: english, lang: en}, {text: french, lang: fr}]}", + ); + + let file = File::try_from_yaml(&yaml).unwrap(); + + assert_eq!( + &[ + MessageContent::new("english".into()), + MessageContent::new("french".into()).with_language("fr".into()) + ], + file.detail() + ); + } + + #[test] + fn should_not_error_if_one_detail_is_given_and_it_is_not_english() { + let yaml = parse("name: name1\ndetail:\n - lang: fr\n text: content1"); + + let file = File::try_from_yaml(&yaml).unwrap(); + + assert_eq!( + &[MessageContent::new("content1".into()).with_language("fr".into())], + file.detail() + ); + } + + #[test] + fn should_error_if_multiple_details_are_given_and_none_are_english() { + let yaml = parse( + "name: name1\ndetail:\n - lang: de\n text: content1\n - lang: fr\n text: content2", + ); + + assert!(File::try_from_yaml(&yaml).is_err()); + } + } + + mod emit_yaml { + use crate::metadata::emit; + + use super::*; + + #[test] + fn should_emit_only_name_scalar_if_other_fields_are_empty() { + let file = File::new("filename".into()); + let yaml = emit(&file); + + assert_eq!(format!("'{}'", file.name.as_str()), yaml); + } + + #[test] + fn should_emit_map_with_display_if_display_name_is_not_empty() { + let file = File::new("filename".into()).with_display_name("display1".into()); + let yaml = emit(&file); + + assert_eq!( + format!( + "name: '{}'\ndisplay: '{}'", + file.name.as_str(), + file.display_name.unwrap() + ), + yaml + ); + } + + #[test] + fn should_emit_map_with_condition_if_it_is_not_empty() { + let file = File::new("filename".into()).with_condition("condition1".into()); + let yaml = emit(&file); + + assert_eq!( + format!( + "name: '{}'\ncondition: '{}'", + file.name.as_str(), + file.condition.unwrap() + ), + yaml + ); + } + + #[test] + fn should_emit_map_with_constraint_if_it_is_not_empty() { + let file = File::new("filename".into()).with_constraint("constraint1".into()); + let yaml = emit(&file); + + assert_eq!( + format!( + "name: '{}'\nconstraint: '{}'", + file.name.as_str(), + file.constraint.unwrap() + ), + yaml + ); + } + + #[test] + fn should_emit_map_with_a_detail_string_if_detail_is_monolingual() { + let file = File::new("filename".into()) + .with_detail(vec![MessageContent::new("message".into())]) + .unwrap(); + let yaml = emit(&file); + + assert_eq!( + format!( + "name: '{}'\ndetail: '{}'", + file.name.as_str(), + file.detail[0].text() + ), + yaml + ); + } + + #[test] + fn should_emit_map_with_a_detail_array_if_detail_is_multilingual() { + let file = File::new("filename".into()) + .with_detail(vec![ + MessageContent::new("english".into()).with_language("en".into()), + MessageContent::new("french".into()).with_language("fr".into()), + ]) + .unwrap(); + let yaml = emit(&file); + + assert_eq!( + format!( + "name: '{}' +detail: + - lang: {} + text: '{}' + - lang: {} + text: '{}'", + file.name.as_str(), + file.detail[0].language(), + file.detail[0].text(), + file.detail[1].language(), + file.detail[1].text() + ), + yaml + ); + } + + #[test] + fn should_emit_map_with_all_fields_set() { + let file = File::new("filename".into()) + .with_display_name("display1".into()) + .with_condition("condition1".into()) + .with_constraint("constraint1".into()) + .with_detail(vec![ + MessageContent::new("english".into()).with_language("en".into()), + MessageContent::new("french".into()).with_language("fr".into()), + ]) + .unwrap(); + let yaml = emit(&file); + + assert_eq!( + format!( + "name: '{}' +display: '{}' +condition: '{}' +constraint: '{}' +detail: + - lang: {} + text: '{}' + - lang: {} + text: '{}'", + file.name.as_str(), + file.display_name.unwrap(), + file.condition.unwrap(), + file.constraint.unwrap(), + file.detail[0].language(), + file.detail[0].text(), + file.detail[1].language(), + file.detail[1].text() + ), + yaml + ); + } + } +} diff --git a/src/metadata/group.rs b/src/metadata/group.rs new file mode 100644 index 00000000..3be15c4d --- /dev/null +++ b/src/metadata/group.rs @@ -0,0 +1,240 @@ +use saphyr::MarkedYaml; + +use super::{ + error::ParseMetadataError, + yaml::{ + EmitYaml, TryFromYaml, YamlEmitter, YamlObjectType, as_mapping, get_required_string_value, + get_string_value, get_strings_vec_value, + }, +}; + +/// Represents a group to which plugin metadata objects can belong. +#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct Group { + name: Box, + description: Option>, + after_groups: Box<[String]>, +} + +impl Group { + /// Construct a [Group] with the given name. + #[must_use] + pub fn new(name: String) -> Self { + Self { + name: name.into_boxed_str(), + ..Default::default() + } + } + + /// Set a description for the group. + #[must_use] + pub fn with_description(mut self, description: String) -> Self { + self.description = Some(description.into_boxed_str()); + self + } + + /// Set the names of the groups that this group loads after. + #[must_use] + pub fn with_after_groups(mut self, after_groups: Vec) -> Self { + self.after_groups = after_groups.into_boxed_slice(); + 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. + fn default() -> Self { + Self { + name: Group::DEFAULT_NAME.into(), + description: Option::default(), + after_groups: Box::default(), + } + } +} + +impl TryFromYaml for Group { + fn try_from_yaml(value: &MarkedYaml) -> Result { + let mapping = as_mapping(value, YamlObjectType::Group)?; + + let name = + get_required_string_value(value.span.start, mapping, "name", YamlObjectType::Group)?; + + let description = get_string_value(mapping, "description", YamlObjectType::Group)?; + + let after = get_strings_vec_value(mapping, "after", YamlObjectType::Group)?; + + Ok(Group { + name: name.into(), + description: description.map(|d| d.1.into()), + after_groups: after.into_iter().map(str::to_owned).collect(), + }) + } +} + +impl EmitYaml for Group { + fn emit_yaml(&self, emitter: &mut YamlEmitter) { + emitter.begin_map(); + + emitter.map_key("name"); + emitter.single_quoted_str(&self.name); + + if let Some(description) = &self.description { + emitter.map_key("description"); + emitter.single_quoted_str(description); + } + + if !self.after_groups.is_empty() { + emitter.map_key("after"); + emitter.begin_array(); + + for after in &self.after_groups { + emitter.unquoted_str(after); + } + + emitter.end_array(); + } + + emitter.end_map(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod try_from_yaml { + use crate::metadata::parse; + + use super::*; + + #[test] + fn should_error_if_given_a_list() { + let yaml = parse("[0, 1, 2]"); + + assert!(Group::try_from_yaml(&yaml).is_err()); + } + + #[test] + fn should_error_if_name_is_missing() { + let yaml = parse("{description: text}"); + + assert!(Group::try_from_yaml(&yaml).is_err()); + } + + #[test] + fn should_error_if_after_is_not_an_array_of_strings() { + let yaml = parse("{name: group1, after: other_group}"); + + assert!(Group::try_from_yaml(&yaml).is_err()); + + let yaml = parse("{name: group1, after: [0, 1]}"); + + assert!(Group::try_from_yaml(&yaml).is_err()); + } + + #[test] + fn should_set_all_given_fields() { + let yaml = parse("{name: group1, description: text, after: [ other_group ]}"); + + let group = Group::try_from_yaml(&yaml).unwrap(); + + assert_eq!("group1", group.name()); + assert_eq!("text", group.description().unwrap()); + assert_eq!(&["other_group"], group.after_groups()); + } + + #[test] + fn should_leave_optional_fields_empty_if_not_present() { + let yaml = parse("{name: group1}"); + + let group = Group::try_from_yaml(&yaml).unwrap(); + + assert_eq!("group1", group.name()); + assert!(group.description().is_none()); + assert!(group.after_groups().is_empty()); + } + } + + mod emit_yaml { + use super::*; + use crate::metadata::emit; + + #[test] + fn should_omit_description_and_after_keys_if_their_fields_are_empty() { + let group = Group::new("name".into()); + let yaml = emit(&group); + + assert_eq!(format!("name: '{}'", group.name), yaml); + } + + #[test] + fn should_include_description_key_if_a_description_is_set() { + let group = Group::new("name".into()).with_description("desc".into()); + let yaml = emit(&group); + + assert_eq!( + format!( + "name: '{}'\ndescription: '{}'", + group.name, + group.description.unwrap() + ), + yaml + ); + } + + #[test] + fn should_include_after_key_if_after_groups_is_not_empty() { + let group = + Group::new("name".into()).with_after_groups(vec!["after1".into(), "after2".into()]); + + let yaml = emit(&group); + + assert_eq!( + format!( + "name: '{}'\nafter:\n - {}\n - {}", + group.name, group.after_groups[0], group.after_groups[1] + ), + yaml + ); + } + + #[test] + fn should_emit_map_with_all_fields_set() { + let group = Group::new("name".into()) + .with_description("desc".into()) + .with_after_groups(vec!["after1".into(), "after2".into()]); + + let yaml = emit(&group); + + assert_eq!( + format!( + "name: '{}'\ndescription: '{}'\nafter:\n - {}\n - {}", + group.name, + group.description.unwrap(), + group.after_groups[0], + group.after_groups[1] + ), + yaml + ); + } + } +} diff --git a/src/metadata/location.rs b/src/metadata/location.rs new file mode 100644 index 00000000..86348ddf --- /dev/null +++ b/src/metadata/location.rs @@ -0,0 +1,194 @@ +use saphyr::{MarkedYaml, Scalar, YamlData}; + +use super::{ + error::{ExpectedType, ParseMetadataError}, + yaml::{EmitYaml, TryFromYaml, YamlEmitter, 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: Box, + name: Option>, +} + +impl Location { + /// Construct a [Location] with the given URL. + #[must_use] + pub fn new(url: String) -> Self { + Location { + url: url.into_boxed_str(), + ..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.into_boxed_str()); + 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 TryFromYaml for Location { + fn try_from_yaml(value: &MarkedYaml) -> Result { + match &value.data { + YamlData::Value(Scalar::String(s)) => Ok(Location { + url: s.to_string().into_boxed_str(), + name: None, + }), + YamlData::Mapping(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.into(), + name: Some(name.into()), + }) + } + _ => Err(ParseMetadataError::unexpected_type( + value.span.start, + YamlObjectType::Location, + ExpectedType::MapOrString, + )), + } + } +} + +impl EmitYaml for Location { + fn is_scalar(&self) -> bool { + self.name.is_none() + } + + fn emit_yaml(&self, emitter: &mut YamlEmitter) { + if let Some(name) = &self.name { + emitter.begin_map(); + + emitter.map_key("link"); + emitter.single_quoted_str(&self.url); + + emitter.map_key("name"); + emitter.single_quoted_str(name); + + emitter.end_map(); + } else { + emitter.single_quoted_str(&self.url); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod try_from_yaml { + use crate::metadata::parse; + + use super::*; + + #[test] + fn should_only_set_name_if_decoding_from_scalar() { + let yaml = parse("https://www.example.com"); + + let location = Location::try_from_yaml(&yaml).unwrap(); + + assert_eq!("https://www.example.com", location.url()); + assert!(location.name().is_none()); + } + + #[test] + fn should_error_if_given_a_list() { + let yaml = parse("[0, 1, 2]"); + + assert!(Location::try_from_yaml(&yaml).is_err()); + } + + #[test] + fn should_error_if_link_is_missing() { + let yaml = parse("{name: example}"); + + assert!(Location::try_from_yaml(&yaml).is_err()); + } + + #[test] + fn should_error_if_link_is_not_a_string() { + let yaml = parse("{link: [https://www.example.com], name: example}"); + + assert!(Location::try_from_yaml(&yaml).is_err()); + } + + #[test] + fn should_error_if_name_is_not_a_string() { + let yaml = parse("{link: https://www.example.com, name: [example]}"); + + assert!(Location::try_from_yaml(&yaml).is_err()); + } + + #[test] + fn should_error_if_name_is_missing() { + let yaml = parse("{link: https://www.example.com}"); + + assert!(Location::try_from_yaml(&yaml).is_err()); + } + + #[test] + fn should_set_all_fields() { + let yaml = parse("{link: https://www.example.com, name: example}"); + + let location = Location::try_from_yaml(&yaml).unwrap(); + + assert_eq!("https://www.example.com", location.url()); + assert_eq!("example", location.name().unwrap()); + } + } + + mod emit_yaml { + use crate::metadata::emit; + + use super::*; + + #[test] + fn should_emit_url_only_if_there_is_no_name() { + let location = Location::new("https://www.example.com".into()); + let yaml = emit(&location); + + assert_eq!(format!("'{}'", location.url), yaml); + } + + #[test] + fn should_emit_map_if_there_is_a_name() { + let location = + Location::new("https://www.example.com".into()).with_name("example".into()); + let yaml = emit(&location); + + assert_eq!( + format!( + "link: '{}'\nname: '{}'", + location.url, + location.name.unwrap() + ), + yaml + ); + } + } +} diff --git a/src/metadata/message.rs b/src/metadata/message.rs new file mode 100644 index 00000000..c89badcd --- /dev/null +++ b/src/metadata/message.rs @@ -0,0 +1,829 @@ +use std::collections::BTreeSet; + +use saphyr::{MarkedYaml, Scalar, YamlData}; + +use super::{ + error::{ + ExpectedType, MetadataParsingErrorReason, MultilingualMessageContentsError, + ParseMetadataError, + }, + yaml::{ + EmitYaml, TryFromYaml, YamlEmitter, YamlObjectType, as_mapping, get_required_string_value, + get_strings_vec_value, get_value, parse_condition, + }, +}; + +/// 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, +} + +impl std::fmt::Display for MessageType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MessageType::Say => write!(f, "say"), + MessageType::Warn => write!(f, "warn"), + MessageType::Error => write!(f, "error"), + } + } +} + +/// Represents a message's localised text content. +#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct MessageContent { + text: Box, + language: Box, +} + +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: text.into_boxed_str(), + ..Default::default() + } + } + + /// Set the language to the given value. + #[must_use] + pub fn with_language(mut self, language: String) -> Self { + self.language = language.into_boxed_str(); + self + } + + /// Get the message text. + pub fn text(&self) -> &str { + &self.text + } + + /// Get the text's language. + 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. + fn default() -> Self { + Self { + text: Box::default(), + language: MessageContent::DEFAULT_LANGUAGE.into(), + } + } +} + +/// Choose a [MessageContent] object from those given in `content` based on the +/// given `language`. +/// +/// Language strings are expected to have the form +/// `[language code]` or `[language code]_[country code]`, where +/// `[language code]` is an ISO 639-1 language code, and `[country code]` is an +/// ISO 3166 country code. +/// +/// * If the vector only contains a single element, that element is returned. +/// * If content with a language that exactly matches the given `language` value +/// is present, that content is returned. +/// * If the given `language` value includes a country code and there is no +/// exact match but content for the same language code is present, that +/// content is returned. +/// * If the given `language` value has no country code and there is no exact +/// match but content for the same language code is present, that content is +/// returned. +/// * If no 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.as_ref() == language { + return Some(mc); + } else if matched.is_none() { + if language_code.is_some_and(|c| c == mc.language.as_ref()) { + 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.as_ref() == 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 { + level: MessageType, + content: Box<[MessageContent]>, + condition: Option>, +} + +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 { + level: message_type, + content: Box::new([MessageContent::new(content)]), + condition: None, + } + } + + /// Construct a [Message] with the given type and content. If more than one + /// [MessageContent] object is given, one must use + /// the language given by [MessageContent::DEFAULT_LANGUAGE]. + pub fn multilingual( + message_type: MessageType, + content: Vec, + ) -> Result { + validate_message_contents(&content)?; + + Ok(Self { + level: message_type, + content: content.into_boxed_slice(), + condition: None, + }) + } + + /// Set the condition string. + #[must_use] + pub fn with_condition(mut self, condition: String) -> Self { + self.condition = Some(condition.into_boxed_str()); + self + } + + /// Get the message type. + pub fn message_type(&self) -> MessageType { + self.level + } + + /// 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<(), MultilingualMessageContentsError> { + if contents.len() > 1 { + let english_string_exists = contents + .iter() + .any(|c| c.language.as_ref() == MessageContent::DEFAULT_LANGUAGE); + + if !english_string_exists { + return Err(MultilingualMessageContentsError {}); + } + } + + Ok(()) +} + +impl TryFromYaml for MessageContent { + fn try_from_yaml(value: &MarkedYaml) -> Result { + let mapping = as_mapping(value, YamlObjectType::MessageContent)?; + + let text = + get_required_string_value(value.span.start, mapping, "text", YamlObjectType::Message)?; + + let language = + get_required_string_value(value.span.start, mapping, "lang", YamlObjectType::Message)?; + + Ok(MessageContent { + text: text.into(), + language: language.into(), + }) + } +} + +pub(crate) fn parse_message_contents_yaml( + value: &MarkedYaml, + key: &'static str, + parent_yaml_type: YamlObjectType, +) -> Result, ParseMetadataError> { + let contents = match &value.data { + YamlData::Value(Scalar::String(s)) => Box::new([MessageContent::new(s.to_string())]), + YamlData::Sequence(a) => a + .iter() + .map(MessageContent::try_from_yaml) + .collect::, _>>()?, + _ => { + return Err(ParseMetadataError::unexpected_value_type( + value.span.start, + key, + parent_yaml_type, + ExpectedType::ArrayOrString, + )); + } + }; + + if validate_message_contents(&contents).is_err() { + Err(ParseMetadataError::new( + value.span.start, + MetadataParsingErrorReason::InvalidMultilingualMessageContents, + )) + } else { + Ok(contents) + } +} + +impl TryFromYaml for Message { + fn try_from_yaml(value: &MarkedYaml) -> Result { + let mapping = as_mapping(value, YamlObjectType::Message)?; + + let message_type = + get_required_string_value(value.span.start, mapping, "type", YamlObjectType::Message)?; + let message_type = match message_type { + "warn" => MessageType::Warn, + "error" => MessageType::Error, + _ => MessageType::Say, + }; + + let mut content = match get_value(mapping, "content") { + Some(n) => parse_message_contents_yaml(n, "content", YamlObjectType::Message)?, + None => { + return Err(ParseMetadataError::missing_key( + value.span.start, + "content", + YamlObjectType::Message, + )); + } + }; + + let subs = get_strings_vec_value(mapping, "subs", YamlObjectType::Message)?; + + if !subs.is_empty() { + for mc in &mut content { + mc.text = format(&mc.text, &subs) + .map_err(|e| ParseMetadataError::new(value.span.start, e))?; + } + } + + let condition = parse_condition(mapping, "condition", YamlObjectType::Message)?; + + Ok(Message { + level: message_type, + content, + condition, + }) + } +} + +fn format(text: &str, subs: &[&str]) -> Result, MetadataParsingErrorReason> { + let mut unused_sub_indexes = BTreeSet::new(); + for i in 0..subs.len() { + unused_sub_indexes.insert(i); + } + + let mut new_text = String::new(); + let mut maybe_in_placeholder = false; + + for slice in text.split_inclusive(['{', '}']) { + if let Some(prefix) = slice.strip_suffix('{') { + new_text.push_str(prefix); + maybe_in_placeholder = true; + } else if let Some(prefix) = slice.strip_suffix('}') { + if maybe_in_placeholder { + if let Ok(sub_index) = prefix.parse::() { + let Some(sub) = subs.get(sub_index) else { + return Err(MetadataParsingErrorReason::MissingSubstitution(format!( + "{{{prefix}}}" + ))); + }; + + new_text.push_str(sub); + + unused_sub_indexes.remove(&sub_index); + } else { + // Not a valid placeholder, treat it as normal text. + new_text.push_str(prefix); + } + + maybe_in_placeholder = false; + } else { + new_text.push_str(prefix); + } + } else { + new_text.push_str(slice); + } + } + + if let Some(sub_index) = unused_sub_indexes.first() { + if let Some(sub) = subs.get(*sub_index) { + return Err(MetadataParsingErrorReason::MissingPlaceholder( + (*sub).to_owned(), + *sub_index, + )); + } + } + + Ok(new_text.into_boxed_str()) +} + +impl EmitYaml for MessageContent { + fn emit_yaml(&self, emitter: &mut YamlEmitter) { + emitter.begin_map(); + + emitter.map_key("lang"); + emitter.unquoted_str(&self.language); + + emitter.map_key("text"); + emitter.single_quoted_str(&self.text); + + emitter.end_map(); + } +} + +pub(super) fn emit_message_contents( + slice: &[MessageContent], + emitter: &mut YamlEmitter, + key: &'static str, +) { + match slice { + [] => {} + [detail] => { + emitter.map_key(key); + emitter.single_quoted_str(detail.text()); + } + details => { + emitter.map_key(key); + + details.emit_yaml(emitter); + } + } +} + +impl EmitYaml for Message { + fn emit_yaml(&self, emitter: &mut YamlEmitter) { + emitter.begin_map(); + + emitter.map_key("type"); + emitter.unquoted_str(&self.level.to_string()); + + emit_message_contents(&self.content, emitter, "content"); + + if let Some(condition) = &self.condition { + emitter.map_key("condition"); + emitter.single_quoted_str(condition); + } + + emitter.end_map(); + } +} + +#[cfg(test)] +mod tests { + use crate::metadata::emit; + + use super::*; + + mod select_message_content { + use super::*; + + #[test] + fn should_return_none_if_the_slice_is_empty() { + let content = select_message_content(&[], MessageContent::DEFAULT_LANGUAGE); + + assert!(content.is_none()); + } + + #[test] + fn should_return_the_only_element_of_a_single_element_slice() { + let slice = &[MessageContent::new("test".into()).with_language("de".into())]; + let content = select_message_content(slice, "fr").unwrap(); + + assert_eq!(&slice[0], content); + } + + #[test] + fn should_return_element_with_exactly_matching_locale_code() { + let slice = &[ + MessageContent::new("test1".into()).with_language("en".into()), + MessageContent::new("test2".into()).with_language("de".into()), + MessageContent::new("test3".into()).with_language("pt".into()), + MessageContent::new("test4".into()).with_language("pt_PT".into()), + MessageContent::new("test5".into()).with_language("pt_BR".into()), + ]; + + let content = select_message_content(slice, "pt_BR").unwrap(); + + assert_eq!(&slice[4], content); + } + + #[test] + fn should_return_element_with_matching_language_code_if_exactly_matching_local_code_is_not_present() + { + let slice = &[ + MessageContent::new("test1".into()).with_language("en".into()), + MessageContent::new("test2".into()).with_language("de".into()), + MessageContent::new("test3".into()).with_language("pt".into()), + MessageContent::new("test4".into()).with_language("pt_PT".into()), + ]; + + let content = select_message_content(slice, "pt_BR").unwrap(); + + assert_eq!(&slice[2], content); + } + + #[test] + fn should_return_element_with_en_language_code_if_no_matching_language_code_is_present() { + let slice = &[ + MessageContent::new("test1".into()).with_language("en".into()), + MessageContent::new("test2".into()).with_language("de".into()), + MessageContent::new("test3".into()).with_language("pt_PT".into()), + ]; + + let content = select_message_content(slice, "pt_BR").unwrap(); + + assert_eq!(&slice[0], content); + } + + #[test] + fn should_return_element_with_exactly_matching_language_code_if_language_code_is_given() { + let slice = &[ + MessageContent::new("test1".into()).with_language("en".into()), + MessageContent::new("test2".into()).with_language("de".into()), + MessageContent::new("test3".into()).with_language("pt_BR".into()), + MessageContent::new("test4".into()).with_language("pt".into()), + ]; + + let content = select_message_content(slice, "pt").unwrap(); + + assert_eq!(&slice[3], content); + } + + #[test] + fn should_return_first_element_with_matching_language_code_if_language_code_is_given_and_no_exact_match_is_present() + { + let slice = &[ + MessageContent::new("test1".into()).with_language("en".into()), + MessageContent::new("test2".into()).with_language("de".into()), + MessageContent::new("test3".into()).with_language("pt_PT".into()), + MessageContent::new("test4".into()).with_language("pt_BR".into()), + ]; + + let content = select_message_content(slice, "pt").unwrap(); + + assert_eq!(&slice[2], content); + } + + #[test] + fn should_return_none_if_there_is_no_match_and_no_english_text() { + let slice = &[ + MessageContent::new("test2".into()).with_language("de".into()), + MessageContent::new("test3".into()).with_language("pt_PT".into()), + MessageContent::new("test4".into()).with_language("pt_BR".into()), + ]; + + assert!(select_message_content(slice, "fr").is_none()); + } + } + + mod message_content { + use super::*; + + mod try_from_yaml { + use crate::metadata::parse; + + use super::*; + + #[test] + fn should_error_if_given_a_scalar() { + let yaml = parse("content"); + + assert!(MessageContent::try_from_yaml(&yaml).is_err()); + } + + #[test] + fn should_error_if_given_a_list() { + let yaml = parse("[0, 1, 2]"); + + assert!(MessageContent::try_from_yaml(&yaml).is_err()); + } + + #[test] + fn should_set_all_given_fields() { + let yaml = parse("{text: content, lang: fr}"); + + let content = MessageContent::try_from_yaml(&yaml).unwrap(); + + assert_eq!("content", content.text()); + assert_eq!("fr", content.language()); + } + } + + mod emit_yaml { + use super::*; + + #[test] + fn should_emit_map() { + let content = MessageContent::new("message".into()).with_language("fr".into()); + let yaml = emit(&content); + + assert_eq!( + format!("lang: {}\ntext: '{}'", content.language, content.text), + yaml + ); + } + } + } + + mod message { + use super::*; + + mod try_from_yaml { + use crate::metadata::parse; + + use super::*; + + #[test] + fn should_error_if_given_a_scalar() { + let yaml = parse("content"); + + assert!(Message::try_from_yaml(&yaml).is_err()); + } + + #[test] + fn should_error_if_given_a_list() { + let yaml = parse("[0, 1, 2]"); + + assert!(Message::try_from_yaml(&yaml).is_err()); + } + + #[test] + fn should_error_if_content_is_missing() { + let yaml = parse("{type: say}"); + + assert!(Message::try_from_yaml(&yaml).is_err()); + } + + #[test] + fn should_error_if_given_an_invalid_condition() { + let yaml = parse("{type: say, content: text, condition: invalid}"); + + assert!(Message::try_from_yaml(&yaml).is_err()); + } + + #[test] + fn should_set_all_given_fields() { + let yaml = parse("{type: say, content: text, condition: 'file(\"Foo.esp\")'}"); + + let message = Message::try_from_yaml(&yaml).unwrap(); + + assert_eq!(MessageType::Say, message.message_type()); + assert_eq!(&[MessageContent::new("text".into())], message.content()); + assert_eq!("file(\"Foo.esp\")", message.condition().unwrap()); + } + + #[test] + fn should_leave_optional_fields_empty_if_not_present() { + let yaml = parse("{type: say, content: text}"); + + let message = Message::try_from_yaml(&yaml).unwrap(); + + assert_eq!(MessageType::Say, message.message_type()); + assert_eq!(&[MessageContent::new("text".into())], message.content()); + assert!(message.condition().is_none()); + } + + #[test] + fn should_set_say_warn_and_error_message_types() { + let yaml = parse("{type: say, content: text}"); + + let message = Message::try_from_yaml(&yaml).unwrap(); + assert_eq!(MessageType::Say, message.message_type()); + + let yaml = parse("{type: warn, content: text}"); + + let message = Message::try_from_yaml(&yaml).unwrap(); + assert_eq!(MessageType::Warn, message.message_type()); + + let yaml = parse("{type: error, content: text}"); + + let message = Message::try_from_yaml(&yaml).unwrap(); + assert_eq!(MessageType::Error, message.message_type()); + } + + #[test] + fn should_use_say_if_message_type_is_unrecognised() { + let yaml = parse("{type: info, content: text}"); + + let message = Message::try_from_yaml(&yaml).unwrap(); + assert_eq!(MessageType::Say, message.message_type()); + } + + #[test] + fn should_read_all_listed_message_contents() { + let yaml = parse( + "{type: say, content: [{text: english, lang: en}, {text: french, lang: fr}]}", + ); + + let message = Message::try_from_yaml(&yaml).unwrap(); + + assert_eq!( + &[ + MessageContent::new("english".into()), + MessageContent::new("french".into()).with_language("fr".into()) + ], + message.content() + ); + } + + #[test] + fn should_not_error_if_one_content_object_is_given_and_it_is_not_english() { + let yaml = parse("type: say\ncontent:\n - lang: fr\n text: content1"); + + let message = Message::try_from_yaml(&yaml).unwrap(); + + assert_eq!( + &[MessageContent::new("content1".into()).with_language("fr".into())], + message.content() + ); + } + + #[test] + fn should_error_if_multiple_contents_are_given_and_none_are_english() { + let yaml = parse( + "type: say\ncontent:\n - lang: de\n text: content1\n - lang: fr\n text: content2", + ); + + assert!(Message::try_from_yaml(&yaml).is_err()); + } + + #[test] + fn should_apply_substitutions_when_there_is_only_one_content_string() { + let yaml = parse("type: say\ncontent: con{0}tent1\nsubs:\n - sub1"); + + let message = Message::try_from_yaml(&yaml).unwrap(); + + assert_eq!("consub1tent1", message.content()[0].text()); + } + + #[test] + fn should_apply_substitutions_to_all_content_strings() { + let yaml = parse( + "type: say\ncontent:\n - lang: en\n text: content1 {0}\n - lang: fr\n text: content2 {0}\nsubs:\n - sub", + ); + + let message = Message::try_from_yaml(&yaml).unwrap(); + + assert_eq!("content1 sub", message.content()[0].text()); + assert_eq!("content2 sub", message.content()[1].text()); + } + + #[test] + fn should_error_if_the_message_has_more_substitutions_than_expected() { + let yaml = parse("{type: say, content: 'content1', subs: [sub1]}"); + + assert!(Message::try_from_yaml(&yaml).is_err()); + } + + #[test] + fn should_error_if_the_content_string_expects_more_substitutions_than_exist() { + let yaml = parse("{type: say, content: '{0} {1}', subs: [sub1]}"); + + assert!(Message::try_from_yaml(&yaml).is_err()); + } + + #[test] + fn should_ignore_substution_syntax_if_no_substitutions_exist() { + let yaml = parse("{type: say, content: 'content {0}'}"); + + let message = Message::try_from_yaml(&yaml).unwrap(); + + assert_eq!("content {0}", message.content()[0].text()); + } + } + + mod emit_yaml { + use super::*; + + #[test] + fn should_emit_say_message_type_correctly() { + let message = Message::new(MessageType::Say, "message".into()); + let yaml = emit(&message); + + assert_eq!( + format!("type: say\ncontent: '{}'", message.content[0].text), + yaml + ); + } + + #[test] + fn should_emit_warn_message_type_correctly() { + let message = Message::new(MessageType::Warn, "message".into()); + let yaml = emit(&message); + + assert_eq!( + format!("type: warn\ncontent: '{}'", message.content[0].text), + yaml + ); + } + + #[test] + fn should_emit_error_message_type_correctly() { + let message = Message::new(MessageType::Error, "message".into()); + let yaml = emit(&message); + + assert_eq!( + format!("type: error\ncontent: '{}'", message.content[0].text), + yaml + ); + } + + #[test] + fn should_emit_condition_if_it_is_not_empty() { + let message = Message::new(MessageType::Say, "message".into()) + .with_condition("condition1".into()); + let yaml = emit(&message); + + assert_eq!( + format!( + "type: {}\ncontent: '{}'\ncondition: '{}'", + message.level, + message.content[0].text, + message.condition.unwrap() + ), + yaml + ); + } + + #[test] + fn should_emit_a_content_array_if_content_is_multilingual() { + let message = Message::multilingual( + MessageType::Say, + vec![ + MessageContent::new("english".into()).with_language("en".into()), + MessageContent::new("french".into()).with_language("fr".into()), + ], + ) + .unwrap(); + let yaml = emit(&message); + + assert_eq!( + format!( + "type: {} +content: + - lang: {} + text: '{}' + - lang: {} + text: '{}'", + message.level, + message.content[0].language(), + message.content[0].text(), + message.content[1].language(), + message.content[1].text() + ), + yaml + ); + } + } + } +} diff --git a/src/metadata/metadata_document.rs b/src/metadata/metadata_document.rs new file mode 100644 index 00000000..25f1dd4d --- /dev/null +++ b/src/metadata/metadata_document.rs @@ -0,0 +1,1027 @@ +use std::{ + collections::{HashMap, HashSet}, + path::Path, +}; + +use saphyr::{LoadableYamlNode, MarkedYaml, YamlData}; + +use crate::{escape_ascii, logging}; + +use super::{ + error::{ + ExpectedType, LoadMetadataError, MetadataDocumentParsingError, ParseMetadataError, + RegexError, WriteMetadataError, + }, + file::Filename, + group::Group, + message::Message, + plugin_metadata::PluginMetadata, + yaml::{ + EmitYaml, TryFromYaml, YamlEmitter, YamlObjectType, get_slice_value, process_merge_keys, + }, +}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MetadataDocument { + bash_tags: Vec, + groups: Vec, + messages: Vec, + plugins: HashMap, + regex_plugins: Vec, +} + +impl MetadataDocument { + pub fn load(&mut self, file_path: &Path) -> Result<(), LoadMetadataError> { + if !file_path.exists() { + return Err(LoadMetadataError::new( + file_path.into(), + MetadataDocumentParsingError::PathNotFound, + )); + } + + logging::trace!("Loading file at \"{}\"", escape_ascii(file_path)); + + let content = std::fs::read_to_string(file_path) + .map_err(|e| LoadMetadataError::from_io_error(file_path.into(), e))?; + + self.load_from_str(&content) + .map_err(|e| LoadMetadataError::new(file_path.into(), e))?; + + logging::trace!( + "Successfully loaded metadata from file at \"{}\".", + escape_ascii(file_path) + ); + + Ok(()) + } + + pub fn load_with_prelude( + &mut self, + masterlist_path: &Path, + prelude_path: &Path, + ) -> Result<(), LoadMetadataError> { + if !masterlist_path.exists() { + return Err(LoadMetadataError::new( + masterlist_path.into(), + MetadataDocumentParsingError::PathNotFound, + )); + } + + if !prelude_path.exists() { + return Err(LoadMetadataError::new( + prelude_path.into(), + MetadataDocumentParsingError::PathNotFound, + )); + } + + let masterlist = std::fs::read_to_string(masterlist_path) + .map_err(|e| LoadMetadataError::from_io_error(masterlist_path.into(), e))?; + + let prelude = std::fs::read_to_string(prelude_path) + .map_err(|e| LoadMetadataError::from_io_error(masterlist_path.into(), e))?; + + let masterlist = replace_prelude(masterlist, &prelude); + + self.load_from_str(&masterlist) + .map_err(|e| LoadMetadataError::new(masterlist_path.into(), e))?; + + logging::trace!( + "Successfully loaded metadata from file at \"{}\".", + escape_ascii(masterlist_path) + ); + + Ok(()) + } + + fn load_from_str(&mut self, string: &str) -> Result<(), MetadataDocumentParsingError> { + let mut docs = MarkedYaml::load_from_str(string)?; + + let doc = docs + .pop() + .ok_or_else(|| MetadataDocumentParsingError::NoDocuments)?; + if !docs.is_empty() { + return Err(MetadataDocumentParsingError::MoreThanOneDocument( + docs.len() + 1, + )); + } + let doc = process_merge_keys(doc)?; + + let YamlData::Mapping(doc) = doc.data else { + return Err(ParseMetadataError::unexpected_type( + doc.span.start, + YamlObjectType::MetadataDocument, + ExpectedType::Map, + ) + .into()); + }; + + let mut plugins: HashMap = HashMap::new(); + let mut regex_plugins: Vec = Vec::new(); + for plugin_yaml in get_slice_value(&doc, "plugins", YamlObjectType::MetadataDocument)? { + let plugin = PluginMetadata::try_from_yaml(plugin_yaml)?; + if plugin.is_regex_plugin() { + regex_plugins.push(plugin); + } else { + let filename = Filename::new(plugin.name().to_owned()); + if let Some(old) = plugins.insert(filename, plugin) { + return Err(ParseMetadataError::duplicate_entry( + plugin_yaml.span.start, + old.name().to_owned(), + YamlObjectType::PluginMetadata, + ) + .into()); + } + } + } + + let messages = get_slice_value(&doc, "globals", YamlObjectType::MetadataDocument)? + .iter() + .map(Message::try_from_yaml) + .collect::, _>>()?; + + let mut bash_tags = Vec::new(); + let mut str_set = HashSet::new(); + for bash_tag_yaml in get_slice_value(&doc, "bash_tags", YamlObjectType::MetadataDocument)? { + let bash_tag: &str = match bash_tag_yaml.data.as_str() { + Some(b) => b, + None => { + return Err(ParseMetadataError::unexpected_type( + bash_tag_yaml.span.start, + YamlObjectType::BashTagsElement, + ExpectedType::String, + ) + .into()); + } + }; + + if str_set.contains(bash_tag) { + return Err(ParseMetadataError::duplicate_entry( + bash_tag_yaml.span.start, + bash_tag.to_owned(), + YamlObjectType::BashTagsElement, + ) + .into()); + } + + bash_tags.push(bash_tag.to_owned()); + str_set.insert(bash_tag); + } + + let mut group_names = HashSet::new(); + let mut groups = Vec::new(); + for group_yaml in get_slice_value(&doc, "groups", YamlObjectType::MetadataDocument)? { + let group = Group::try_from_yaml(group_yaml)?; + + let name = group.name().to_owned(); + if group_names.contains(&name) { + return Err(ParseMetadataError::duplicate_entry( + group_yaml.span.start, + group.name().to_owned(), + YamlObjectType::Group, + ) + .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<(), WriteMetadataError> { + logging::trace!("Saving metadata list to: \"{}\"", escape_ascii(file_path)); + + let mut emitter = YamlEmitter::new(); + + if !self.bash_tags.is_empty() { + emitter.map_key("bash_tags"); + + emitter.begin_array(); + + for tag in &self.bash_tags { + emitter.unquoted_str(tag); + } + + emitter.end_array(); + } + + if self.groups.len() > 1 { + emitter.map_key("groups"); + self.groups.emit_yaml(&mut emitter); + } + + if !self.messages.is_empty() { + emitter.map_key("globals"); + self.messages.emit_yaml(&mut emitter); + } + + if !self.plugins.is_empty() || !self.regex_plugins.is_empty() { + emitter.map_key("plugins"); + + emitter.begin_array(); + + for plugin in self.plugins_iter() { + if !plugin.has_name_only() { + plugin.emit_yaml(&mut emitter); + } + } + + emitter.end_array(); + } + + let mut contents = emitter.into_string(); + if contents.is_empty() { + contents = "{}".into(); + } + + std::fs::write(file_path, contents) + .map_err(|e| WriteMetadataError::new(file_path.into(), e.into()))?; + + Ok(()) + } + + 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_iter(&self) -> impl Iterator { + self.plugins.values().chain(self.regex_plugins.iter()) + } + + pub fn find_plugin(&self, plugin_name: &str) -> Result, RegexError> { + let mut metadata = match self.plugins.get(&Filename::new(plugin_name.to_owned())) { + 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) { + // Ensure that the default group is present. + let default_group_exists = groups.iter().any(|g| g.name() == Group::DEFAULT_NAME); + + if default_group_exists { + self.groups = groups; + } else { + self.groups.clear(); + self.groups.push(Group::default()); + self.groups.extend(groups); + } + } + + pub fn set_plugin_metadata(&mut self, plugin_metadata: PluginMetadata) { + if plugin_metadata.is_regex_plugin() { + self.regex_plugins.push(plugin_metadata); + } else { + self.plugins.insert( + Filename::new(plugin_metadata.name().to_owned()), + plugin_metadata, + ); + } + } + + pub fn remove_plugin_metadata(&mut self, plugin_name: &str) { + self.plugins.remove(&Filename::new(plugin_name.to_owned())); + } + + pub fn clear(&mut self) { + self.bash_tags.clear(); + self.groups.clear(); + self.messages.clear(); + self.plugins.clear(); + self.regex_plugins.clear(); + } +} + +impl std::default::Default for MetadataDocument { + fn default() -> Self { + Self { + bash_tags: Vec::default(), + groups: vec![Group::default()], + messages: Vec::default(), + plugins: HashMap::default(), + regex_plugins: Vec::default(), + } + } +} + +fn replace_prelude(masterlist: String, prelude: &str) -> String { + if let Some((start, end)) = split_on_prelude(&masterlist) { + let prelude = indent_prelude(prelude); + + format!("{start}{prelude}{end}") + } else { + masterlist + } +} + +fn split_on_prelude(masterlist: &str) -> Option<(&str, &str)> { + let (prefix, remainder) = split_on_prelude_start(masterlist)?; + + let mut iter = remainder.bytes().enumerate().peekable(); + while let Some((index, byte)) = iter.next() { + if byte != b'\n' { + continue; + } + + if let Some((_, next_byte)) = iter.peek() { + if !matches!(next_byte, b' ' | b'#' | b'\n' | b'\r') { + // Slicing at index should never fail, but we can't prove that, + // and we don't want to risk panicking. + if let Some(suffix) = remainder.get(index..) { + return Some((prefix, suffix)); + } + } + } + } + + Some((prefix, "")) +} + +fn split_on_prelude_start(masterlist: &str) -> Option<(&str, &str)> { + let prelude_on_first_line = "prelude:"; + let prelude_on_new_line = "\nprelude:"; + + if let Some(remainder) = masterlist.strip_prefix(prelude_on_first_line) { + Some((prelude_on_first_line, remainder)) + } else { + if let Some(pos) = masterlist.find(prelude_on_new_line) { + let index = pos + prelude_on_new_line.len(); + // A checked split shouldn't be necessary, but there's no + // split_inclusive_once() method, so we need to find and split in + // two steps and there's always the risk of a bug being introduced + // in the middle. + if let Some((prefix, remainder)) = masterlist.split_at_checked(index) { + return Some((prefix, remainder)); + } + } + None + } +} + +fn indent_prelude(prelude: &str) -> String { + let prelude = ("\n ".to_owned() + &prelude.replace('\n', "\n ")) + .replace(" \r\n", "\r\n") + .replace(" \n", "\n"); + + if prelude.ends_with("\n ") { + prelude.trim_end_matches(' ').to_owned() + } else { + prelude + } +} + +#[cfg(test)] +mod tests { + use tempfile::tempdir; + + use crate::metadata::File; + + use super::*; + + mod metadata_document { + use crate::metadata::MessageType; + + 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_from_str_should_resolve_aliases() { + let yaml = " + prelude: + - &anchor + type: say + content: test message + + globals: + - *anchor + "; + + let mut metadata_list = MetadataDocument::default(); + metadata_list.load_from_str(yaml).unwrap(); + } + + #[test] + fn load_from_str_should_resolve_merge_keys() { + let yaml = r#" + prelude: + - &anchor + type: say + content: test message + + globals: + - <<: *anchor + condition: file("test.esp") + "#; + + let mut metadata_list = MetadataDocument::default(); + metadata_list.load_from_str(yaml).unwrap(); + } + + #[test] + fn load_from_str_should_error_if_a_plugin_has_two_exact_entries() { + let yaml = " +plugins: + - name: 'Blank.esm' + msg: + - type: warn + content: 'This is a warning.' + + - name: 'Blank.esm' + msg: + - type: error + content: 'This plugin entry will cause a failure, as it is not the first exact entry.' + "; + + let mut metadata_list = MetadataDocument::default(); + assert!(metadata_list.load_from_str(yaml).is_err()); + } + + #[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(); + + let plugin_names: Vec<_> = metadata_list + .plugins_iter() + .map(PluginMetadata::name) + .collect(); + assert!(plugin_names.contains(&"Blank.esm")); + assert!(plugin_names.contains(&"Blank.esp")); + assert!(plugin_names.contains(&"Blank.+\\.esp")); + assert!(plugin_names.contains(&"Blank.+(Different)?.*\\.esp")); + + assert_eq!(&["C.Climate", "Relev"], metadata_list.bash_tags()); + + let groups = metadata_list.groups(); + assert_eq!(3, groups.len()); + + assert_eq!("default", groups[0].name()); + assert!(groups[0].after_groups().is_empty()); + + assert_eq!("group1", groups[1].name()); + assert_eq!(&["group2"], groups[1].after_groups()); + + assert_eq!("group2", groups[2].name()); + assert_eq!(&["default"], groups[2].after_groups()); + } + + #[test] + fn load_should_error_if_an_invalid_metadata_file_is_given() { + let tmp_dir = tempdir().unwrap(); + let path = tmp_dir.path().join("masterlist.yaml"); + let yaml = r" + - 'C.Climate' + - 'Relev' + +globals: + - type: say + content: 'A global message.' + +plugins: + - name: 'Blank.+\.esp' + after: + - 'Blank.esm' + "; + + std::fs::write(&path, yaml).unwrap(); + + let mut metadata_list = MetadataDocument::default(); + assert!(metadata_list.load(&path).is_err()); + } + + #[test] + fn load_should_error_if_the_given_path_does_not_exist() { + let mut metadata_list = MetadataDocument::default(); + assert!(metadata_list.load(Path::new("missing")).is_err()); + } + + #[test] + fn load_with_prelude_should_merge_docs_with_crlf_line_endings() { + let tmp_dir = tempdir().unwrap(); + + let masterlist_path = tmp_dir.path().join("masterlist.yaml"); + std::fs::write(&masterlist_path, "prelude:\r\n - &ref\r\n type: say\r\n content: Loaded from same file\r\n\r\n - &otherRef\r\n type: error\r\n content: Error from same file\r\nglobals:\r\n - *ref\r\n - *otherRef").unwrap(); + + let prelude_path = tmp_dir.path().join("prelude.yaml"); + std::fs::write( + &prelude_path, + "common:\r\n - &ref\r\n type: say\r\n content: Loaded from prelude\r\n\r\n - &otherRef\r\n type: error\r\n content: An error message", + ) + .unwrap(); + + let mut metadata_list = MetadataDocument::default(); + metadata_list + .load_with_prelude(&masterlist_path, &prelude_path) + .unwrap(); + + assert_eq!( + [ + Message::new(MessageType::Say, "Loaded from prelude".to_owned()), + Message::new(MessageType::Error, "An error message".to_owned()), + ], + metadata_list.messages() + ); + } + + #[test] + fn load_with_prelude_should_merge_docs_with_lf_line_endings() { + let tmp_dir = tempdir().unwrap(); + + let masterlist_path = tmp_dir.path().join("masterlist.yaml"); + std::fs::write(&masterlist_path, "prelude:\n - &ref\n type: say\n content: Loaded from same file\n\n - &otherRef\n type: error\n content: Error from same file\nglobals:\n - *ref\n - *otherRef").unwrap(); + + let prelude_path = tmp_dir.path().join("prelude.yaml"); + std::fs::write( + &prelude_path, + "common:\n - &ref\n type: say\n content: Loaded from prelude\n\n - &otherRef\n type: error\n content: An error message", + ) + .unwrap(); + + let mut metadata_list = MetadataDocument::default(); + metadata_list + .load_with_prelude(&masterlist_path, &prelude_path) + .unwrap(); + + assert_eq!( + [ + Message::new(MessageType::Say, "Loaded from prelude".to_owned()), + Message::new(MessageType::Error, "An error message".to_owned()), + ], + metadata_list.messages() + ); + } + + #[test] + fn load_with_prelude_should_error_if_the_given_masterlist_path_does_not_exist() { + let tmp_dir = tempdir().unwrap(); + + let prelude_path = tmp_dir.path().join("prelude.yaml"); + std::fs::write( + &prelude_path, + "common:\n - &ref\n type: say\n content: Loaded from prelude\n", + ) + .unwrap(); + + let mut metadata_list = MetadataDocument::default(); + assert!( + metadata_list + .load_with_prelude(Path::new("missing"), &prelude_path) + .is_err() + ); + } + + #[test] + fn load_with_prelude_should_error_if_the_given_prelude_path_does_not_exist() { + let tmp_dir = tempdir().unwrap(); + + let masterlist_path = tmp_dir.path().join("masterlist.yaml"); + std::fs::write(&masterlist_path, "prelude:\n - &ref\n type: say\n content: Loaded from same file\nglobals:\n - *ref\n").unwrap(); + + let mut metadata_list = MetadataDocument::default(); + assert!( + metadata_list + .load_with_prelude(&masterlist_path, Path::new("missing")) + .is_err() + ); + } + + #[test] + fn save_should_write_the_loaded_metadata() { + let tmp_dir = tempdir().unwrap(); + + let path = tmp_dir.path().join("masterlist.yaml"); + std::fs::write(&path, METADATA_LIST_YAML).unwrap(); + + let mut metadata = MetadataDocument::default(); + metadata.load(&path).unwrap(); + + let other_path = tmp_dir.path().join("other.yaml"); + metadata.save(&other_path).unwrap(); + + let mut other_metadata = MetadataDocument::default(); + other_metadata.load(&other_path).unwrap(); + + assert_eq!(metadata, other_metadata); + } + + #[test] + fn clear_should_clear_all_loaded_data() { + let mut metadata = MetadataDocument::default(); + metadata.load_from_str(METADATA_LIST_YAML).unwrap(); + + assert!(!metadata.messages().is_empty()); + assert!(metadata.plugins_iter().next().is_some()); + assert!(!metadata.bash_tags().is_empty()); + + metadata.clear(); + + assert!(metadata.messages().is_empty()); + assert!(metadata.plugins_iter().next().is_none()); + assert!(metadata.bash_tags().is_empty()); + } + + #[test] + fn set_groups_should_replace_existing_groups() { + let mut metadata = MetadataDocument::default(); + metadata.load_from_str(METADATA_LIST_YAML).unwrap(); + + metadata.set_groups(vec![Group::new("group4".into())]); + + let groups = metadata.groups(); + + assert_eq!("default", groups[0].name()); + assert!(groups[0].after_groups().is_empty()); + + assert_eq!("group4", groups[1].name()); + assert!(groups[1].after_groups().is_empty()); + } + + #[test] + fn find_plugin_should_return_none_if_the_given_plugin_has_no_metadata() { + let metadata = MetadataDocument::default(); + assert!(metadata.find_plugin("Blank.esp").unwrap().is_none()); + } + + #[test] + fn find_plugin_should_return_the_metadata_object_if_one_exists() { + let mut metadata = MetadataDocument::default(); + metadata.load_from_str(METADATA_LIST_YAML).unwrap(); + + let name = "Blank - Different.esp"; + let plugin = metadata.find_plugin(name).unwrap().unwrap(); + + assert_eq!(name, plugin.name()); + assert_eq!(&[File::new("Blank.esm".into())], plugin.load_after_files()); + assert_eq!(&[File::new("Blank.esp".into())], plugin.incompatibilities()); + } + + #[test] + fn add_plugin_should_store_specific_plugin_metadata() { + let mut metadata = MetadataDocument::default(); + + let name = "Blank.esp"; + let mut plugin = PluginMetadata::new(name).unwrap(); + plugin.set_group("group1".into()); + metadata.set_plugin_metadata(plugin); + + let plugin = metadata.find_plugin(name).unwrap().unwrap(); + + assert_eq!(name, plugin.name()); + assert_eq!("group1", plugin.group().unwrap()); + } + + #[test] + fn add_plugin_should_store_given_regex_plugin_metadata() { + let mut metadata = MetadataDocument::default(); + + let mut plugin = PluginMetadata::new(".+Dependent\\.esp").unwrap(); + plugin.set_group("group1".into()); + metadata.set_plugin_metadata(plugin); + + let name = "Blank - Plugin Dependent.esp"; + let plugin = metadata.find_plugin(name).unwrap().unwrap(); + + assert_eq!(name, plugin.name()); + assert_eq!("group1", plugin.group().unwrap()); + } + + #[test] + fn remove_plugin_metadata_should_remove_the_given_plugin_specific_metadata() { + let mut metadata = MetadataDocument::default(); + metadata.load_from_str(METADATA_LIST_YAML).unwrap(); + + let name = "Blank.esp"; + assert!(metadata.find_plugin(name).unwrap().is_some()); + + metadata.remove_plugin_metadata(name); + + assert!(metadata.find_plugin(name).unwrap().is_none()); + } + + #[test] + fn remove_plugin_metadata_should_not_remove_matching_regex_plugin_metadata() { + let mut metadata = MetadataDocument::default(); + metadata.load_from_str(METADATA_LIST_YAML).unwrap(); + + let name = "Blank.+\\.esp"; + assert!(metadata.find_plugin(name).unwrap().is_some()); + + metadata.remove_plugin_metadata(name); + + assert!(metadata.find_plugin(name).unwrap().is_some()); + + metadata.remove_plugin_metadata("Blank - Different.esp"); + + assert!(metadata.find_plugin(name).unwrap().is_some()); + } + } + + mod replace_prelude { + use super::*; + + #[test] + fn should_return_an_empty_string_if_given_empty_strings() { + let result = replace_prelude(String::new(), ""); + + assert!(result.is_empty()); + } + + #[test] + fn should_not_change_a_masterlist_with_no_prelude() { + let prelude = "globals: + - type: note + content: A message. +"; + let masterlist = "plugins: + - name: a.esp +"; + + let result = replace_prelude(masterlist.into(), prelude); + + assert_eq!(masterlist, result); + } + + #[test] + fn should_not_change_a_flow_style_masterlist() { + let prelude = "globals: [{type: note, content: A message.}]"; + let masterlist = "{prelude: {}, plugins: [{name: a.esp}]}"; + + let result = replace_prelude(masterlist.into(), prelude); + + assert_eq!(masterlist, result); + } + + #[test] + fn should_replace_a_prelude_at_the_start_of_the_masterlist() { + let prelude = "globals: + - type: note + content: A message. +"; + let masterlist = "prelude: + a: b + +plugins: + - name: a.esp +"; + + let result = replace_prelude(masterlist.into(), prelude); + + let expected_result = "prelude: + globals: + - type: note + content: A message. + +plugins: + - name: a.esp +"; + + assert_eq!(expected_result, result); + } + + #[test] + fn should_change_a_masterlist_that_ends_with_a_prelude() { + let prelude = "globals: + - type: note + content: A message. +"; + let masterlist = "plugins: + - name: a.esp +prelude: + a: b + +"; + + let result = replace_prelude(masterlist.into(), prelude); + + let expected_result = "plugins: + - name: a.esp +prelude: + globals: + - type: note + content: A message. +"; + + assert_eq!(expected_result, result); + } + + #[test] + fn should_replace_only_the_prelude_in_the_masterlist() { + let prelude = " + +globals: + - type: note + content: A message. + +"; + let masterlist = " +common: + key: value +prelude: + a: b +plugins: + - name: a.esp +"; + + let result = replace_prelude(masterlist.into(), prelude); + + let expected_result = " +common: + key: value +prelude: + + + globals: + - type: note + content: A message. + + +plugins: + - name: a.esp +"; + + assert_eq!(expected_result, result); + } + + #[test] + fn should_succeed_given_block_style_prelude_and_masterlist() { + let prelude = "globals: + - type: note + content: A message. +"; + let masterlist = "prelude: + a: b + +plugins: + - name: a.esp +"; + + let result = replace_prelude(masterlist.into(), prelude); + + let expected_result = "prelude: + globals: + - type: note + content: A message. + +plugins: + - name: a.esp +"; + + assert_eq!(expected_result, result); + } + + #[test] + fn should_succeed_given_a_flow_style_prelude_and_a_block_style_masterlist() { + let prelude = "globals: [{type: note, content: A message.}]"; + let masterlist = "prelude: + a: b + +plugins: + - name: a.esp +"; + + let result = replace_prelude(masterlist.into(), prelude); + + let expected_result = "prelude: + globals: [{type: note, content: A message.}] +plugins: + - name: a.esp +"; + + assert_eq!(expected_result, result); + } + + #[test] + fn should_not_stop_at_comments() { + let prelude = "globals: + - type: note + content: A message. +"; + let masterlist = "prelude: + a: b +# Comment line + c: d + +plugins: + - name: a.esp +"; + + let result = replace_prelude(masterlist.into(), prelude); + + let expected_result = "prelude: + globals: + - type: note + content: A message. + +plugins: + - name: a.esp +"; + + assert_eq!(expected_result, result); + } + + #[test] + fn should_not_stop_at_a_blank_line() { + let prelude = "globals: + - type: note + content: A message. +"; + let masterlist = "prelude: + a: b + + +plugins: + - name: a.esp +"; + + let result = replace_prelude(masterlist.into(), prelude); + + let expected_result = "prelude: + globals: + - type: note + content: A message. + +plugins: + - name: a.esp +"; + + assert_eq!(expected_result, result); + } + } +} diff --git a/src/metadata/mod.rs b/src/metadata/mod.rs new file mode 100644 index 00000000..4446cd66 --- /dev/null +++ b/src/metadata/mod.rs @@ -0,0 +1,37 @@ +//! Holds all types related to LOOT metadata. +pub mod error; +mod file; +mod group; +mod location; +mod message; +pub(crate) mod metadata_document; +mod plugin_cleaning_data; +pub(crate) mod plugin_metadata; +mod tag; +mod yaml; + +pub use file::{File, Filename}; +pub use group::Group; +pub use location::Location; +pub use message::{Message, MessageContent, MessageType, select_message_content}; +pub use plugin_cleaning_data::PluginCleaningData; +pub use plugin_metadata::PluginMetadata; +pub use tag::{Tag, TagSuggestion}; + +#[cfg(test)] +fn emit(metadata: &T) -> String { + let mut emitter = yaml::YamlEmitter::new(); + metadata.emit_yaml(&mut emitter); + + emitter.into_string() +} + +#[cfg(test)] +fn parse(yaml: &str) -> saphyr::MarkedYaml { + use saphyr::LoadableYamlNode; + + saphyr::MarkedYaml::load_from_str(yaml) + .unwrap() + .pop() + .unwrap() +} diff --git a/src/metadata/plugin_cleaning_data.rs b/src/metadata/plugin_cleaning_data.rs new file mode 100644 index 00000000..07cc9c3e --- /dev/null +++ b/src/metadata/plugin_cleaning_data.rs @@ -0,0 +1,427 @@ +use saphyr::MarkedYaml; + +use super::{ + error::{MultilingualMessageContentsError, ParseMetadataError}, + message::{ + MessageContent, emit_message_contents, parse_message_contents_yaml, + validate_message_contents, + }, + yaml::{ + EmitYaml, TryFromYaml, YamlEmitter, YamlObjectType, as_mapping, get_required_string_value, + get_u32_value, get_value, + }, +}; + +/// Represents data identifying the plugin under which it is stored as dirty or +/// clean. +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct PluginCleaningData { + crc: u32, + itm_count: u32, + deleted_reference_count: u32, + deleted_navmesh_count: u32, + cleaning_utility: Box, + detail: Box<[MessageContent]>, +} + +impl PluginCleaningData { + /// Construct a [PluginCleaningData] object with the given CRC and cleaning + /// utility, no detail and the ITM, deleted reference and deleted navmesh + /// counts set to zero. + #[must_use] + pub fn new(crc: u32, cleaning_utility: String) -> Self { + Self { + crc, + cleaning_utility: cleaning_utility.into_boxed_str(), + ..Default::default() + } + } + + /// Set the number of Identical To Master records found in the plugin. + #[must_use] + pub fn with_itm_count(mut self, itm_count: u32) -> Self { + self.itm_count = itm_count; + self + } + + /// Set the number of deleted references found in the plugin. + #[must_use] + pub fn with_deleted_reference_count(mut self, deleted_reference_count: u32) -> Self { + self.deleted_reference_count = deleted_reference_count; + self + } + + /// Set the number of deleted navmeshes found in the plugin. + #[must_use] + pub fn with_deleted_navmesh_count(mut self, deleted_navmesh_count: u32) -> Self { + self.deleted_navmesh_count = deleted_navmesh_count; + self + } + + /// Set the detail message content, which may be appended to any messages + /// generated for this cleaning data. If multilingual, one language must be + /// [MessageContent::DEFAULT_LANGUAGE]. + pub fn with_detail( + mut self, + detail: Vec, + ) -> Result { + validate_message_contents(&detail)?; + self.detail = detail.into_boxed_slice(); + Ok(self) + } + + /// Get the CRC that identifies the plugin that the cleaning data is for. + pub fn crc(&self) -> u32 { + self.crc + } + + /// Get the number of Identical To Master records found in the plugin. + pub fn itm_count(&self) -> u32 { + self.itm_count + } + + /// Get the number of deleted references found in the plugin. + pub fn deleted_reference_count(&self) -> u32 { + self.deleted_reference_count + } + + /// Get the number of deleted navmeshes found in the plugin. + pub fn deleted_navmesh_count(&self) -> u32 { + self.deleted_navmesh_count + } + + /// Get the cleaning utility that was used to check the plugin. + /// + /// The string may include a cleaning utility name, possibly related + /// information such as a version number and/or a CommonMark-formatted URL + /// to the utility's download location. + pub fn cleaning_utility(&self) -> &str { + &self.cleaning_utility + } + + /// Get any additional informative message content supplied with the + /// cleaning data, eg. a link to a cleaning guide or information on wild + /// edits or manual cleaning steps. + pub fn detail(&self) -> &[MessageContent] { + &self.detail + } +} + +impl TryFromYaml for PluginCleaningData { + fn try_from_yaml(value: &MarkedYaml) -> Result { + let mapping = as_mapping(value, YamlObjectType::PluginCleaningData)?; + + let Some(crc) = get_u32_value(mapping, "crc", YamlObjectType::PluginCleaningData)? else { + return Err(ParseMetadataError::missing_key( + value.span.start, + "crc", + YamlObjectType::PluginCleaningData, + )); + }; + + let util = get_required_string_value( + value.span.start, + mapping, + "util", + YamlObjectType::PluginCleaningData, + )?; + + let itm = get_u32_value(mapping, "itm", YamlObjectType::PluginCleaningData)?.unwrap_or(0); + let udr = get_u32_value(mapping, "udr", YamlObjectType::PluginCleaningData)?.unwrap_or(0); + let nav = get_u32_value(mapping, "nav", YamlObjectType::PluginCleaningData)?.unwrap_or(0); + + let detail = match get_value(mapping, "detail") { + Some(n) => { + parse_message_contents_yaml(n, "detail", YamlObjectType::PluginCleaningData)? + } + None => Box::default(), + }; + + Ok(PluginCleaningData { + crc, + itm_count: itm, + deleted_reference_count: udr, + deleted_navmesh_count: nav, + cleaning_utility: util.into(), + detail, + }) + } +} + +impl EmitYaml for PluginCleaningData { + fn emit_yaml(&self, emitter: &mut YamlEmitter) { + emitter.begin_map(); + + emitter.map_key("crc"); + emitter.unquoted_str(&format!("0x{:08X}", self.crc)); + + emitter.map_key("util"); + emitter.single_quoted_str(&self.cleaning_utility); + + if self.itm_count > 0 { + emitter.map_key("itm"); + emitter.u32(self.itm_count); + } + + if self.deleted_reference_count > 0 { + emitter.map_key("udr"); + emitter.u32(self.deleted_reference_count); + } + + if self.deleted_navmesh_count > 0 { + emitter.map_key("nav"); + emitter.u32(self.deleted_navmesh_count); + } + + emit_message_contents(&self.detail, emitter, "detail"); + + emitter.end_map(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod try_from_yaml { + use crate::metadata::parse; + + use super::*; + + #[test] + fn should_error_if_given_a_scalar() { + let yaml = parse("0x12345678"); + + assert!(PluginCleaningData::try_from_yaml(&yaml).is_err()); + } + + #[test] + fn should_error_if_given_a_list() { + let yaml = parse("[0, 1, 2]"); + + assert!(PluginCleaningData::try_from_yaml(&yaml).is_err()); + } + + #[test] + fn should_error_if_crc_is_missing() { + let yaml = parse("{util: cleaner}"); + + assert!(PluginCleaningData::try_from_yaml(&yaml).is_err()); + } + + #[test] + fn should_error_util_is_missing() { + let yaml = parse("{crc: 0x12345678}"); + + assert!(PluginCleaningData::try_from_yaml(&yaml).is_err()); + } + + #[test] + fn should_error_if_a_count_is_not_a_number() { + let yaml = parse("{crc: 0x12345678, util: cleaner, itm: true}"); + + assert!(PluginCleaningData::try_from_yaml(&yaml).is_err()); + + let yaml = parse("{crc: 0x12345678, util: cleaner, udr: true}"); + + assert!(PluginCleaningData::try_from_yaml(&yaml).is_err()); + + let yaml = parse("{crc: 0x12345678, util: cleaner, nav: true}"); + + assert!(PluginCleaningData::try_from_yaml(&yaml).is_err()); + } + + #[test] + fn should_error_if_a_count_does_not_fit_in_a_u32() { + let yaml = parse("{crc: 0x12345678, util: cleaner, itm: -1}"); + + assert!(PluginCleaningData::try_from_yaml(&yaml).is_err()); + + let yaml = parse("{crc: 0x12345678, util: cleaner, udr: -2}"); + + assert!(PluginCleaningData::try_from_yaml(&yaml).is_err()); + + let yaml = parse("{crc: 0x12345678, util: cleaner, nav: -3}"); + + assert!(PluginCleaningData::try_from_yaml(&yaml).is_err()); + } + + #[test] + fn should_set_all_given_fields() { + let yaml = + parse("{crc: 0x12345678, util: cleaner, detail: info, itm: 2, udr: 10, nav: 30}"); + + let data = PluginCleaningData::try_from_yaml(&yaml).unwrap(); + + assert_eq!(0x1234_5678, data.crc()); + assert_eq!("cleaner", data.cleaning_utility()); + assert_eq!(&[MessageContent::new("info".into())], data.detail()); + assert_eq!(2, data.itm_count()); + assert_eq!(10, data.deleted_reference_count()); + assert_eq!(30, data.deleted_navmesh_count()); + } + + #[test] + fn should_leave_optional_fields_at_defaults_if_not_present() { + let yaml = parse("{crc: 0x12345678, util: cleaner}"); + + let data = PluginCleaningData::try_from_yaml(&yaml).unwrap(); + + assert_eq!(0x1234_5678, data.crc()); + assert_eq!("cleaner", data.cleaning_utility()); + assert!(data.detail().is_empty()); + assert_eq!(0, data.itm_count()); + assert_eq!(0, data.deleted_reference_count()); + assert_eq!(0, data.deleted_navmesh_count()); + } + + #[test] + fn should_read_all_listed_detail_message_contents() { + let yaml = parse( + "{crc: 0x12345678, util: cleaner, detail: [{text: english, lang: en}, {text: french, lang: fr}]}", + ); + + let data = PluginCleaningData::try_from_yaml(&yaml).unwrap(); + + assert_eq!( + &[ + MessageContent::new("english".into()), + MessageContent::new("french".into()).with_language("fr".into()) + ], + data.detail() + ); + } + + #[test] + fn should_not_error_if_one_detail_is_given_and_it_is_not_english() { + let yaml = + parse("crc: 0x12345678\nutil: cleaner\ndetail:\n - lang: fr\n text: content1"); + + let data = PluginCleaningData::try_from_yaml(&yaml).unwrap(); + + assert_eq!( + &[MessageContent::new("content1".into()).with_language("fr".into())], + data.detail() + ); + } + + #[test] + fn should_error_if_multiple_details_are_given_and_none_are_english() { + let yaml = parse( + "crc: 0x12345678\nutil: cleaner\ndetail:\n - lang: de\n text: content1\n - lang: fr\n text: content2", + ); + + assert!(PluginCleaningData::try_from_yaml(&yaml).is_err()); + } + } + + mod emit_yaml { + use crate::metadata::emit; + + use super::*; + + #[test] + fn should_omit_zero_counts() { + let data = PluginCleaningData::new(0xDEAD_BEEF, "TES5Edit".into()); + let yaml = emit(&data); + + assert_eq!("crc: 0xDEADBEEF\nutil: 'TES5Edit'", yaml); + } + + #[test] + fn should_emit_non_zero_counts() { + let data = PluginCleaningData::new(0xDEAD_BEEF, "TES5Edit".into()) + .with_itm_count(1) + .with_deleted_reference_count(2) + .with_deleted_navmesh_count(3); + let yaml = emit(&data); + + assert_eq!( + "crc: 0xDEADBEEF\nutil: 'TES5Edit'\nitm: 1\nudr: 2\nnav: 3", + yaml + ); + } + + #[test] + fn should_emit_map_with_a_detail_string_if_detail_is_monolingual() { + let data = PluginCleaningData::new(0xDEAD_BEEF, "TES5Edit".into()) + .with_detail(vec![MessageContent::new("message".into())]) + .unwrap(); + let yaml = emit(&data); + + assert_eq!( + format!( + "crc: 0xDEADBEEF\nutil: 'TES5Edit'\ndetail: '{}'", + data.detail[0].text() + ), + yaml + ); + } + + #[test] + fn should_emit_map_with_a_detail_array_if_detail_is_multilingual() { + let data = PluginCleaningData::new(0xDEAD_BEEF, "TES5Edit".into()) + .with_detail(vec![ + MessageContent::new("english".into()).with_language("en".into()), + MessageContent::new("french".into()).with_language("fr".into()), + ]) + .unwrap(); + let yaml = emit(&data); + + assert_eq!( + format!( + "crc: 0xDEADBEEF +util: 'TES5Edit' +detail: + - lang: {} + text: '{}' + - lang: {} + text: '{}'", + data.detail[0].language(), + data.detail[0].text(), + data.detail[1].language(), + data.detail[1].text() + ), + yaml + ); + } + + #[test] + fn should_emit_map_with_all_fields_set() { + let data = PluginCleaningData::new(0xDEAD_BEEF, "TES5Edit".into()) + .with_itm_count(1) + .with_deleted_reference_count(2) + .with_deleted_navmesh_count(3) + .with_detail(vec![ + MessageContent::new("english".into()).with_language("en".into()), + MessageContent::new("french".into()).with_language("fr".into()), + ]) + .unwrap(); + let yaml = emit(&data); + + assert_eq!( + format!( + "crc: 0xDEADBEEF +util: '{}' +itm: {} +udr: {} +nav: {} +detail: + - lang: {} + text: '{}' + - lang: {} + text: '{}'", + data.cleaning_utility, + data.itm_count, + data.deleted_reference_count, + data.deleted_navmesh_count, + data.detail[0].language(), + data.detail[0].text(), + data.detail[1].language(), + data.detail[1].text() + ), + yaml + ); + } + } +} diff --git a/src/metadata/plugin_metadata.rs b/src/metadata/plugin_metadata.rs new file mode 100644 index 00000000..02f57bc3 --- /dev/null +++ b/src/metadata/plugin_metadata.rs @@ -0,0 +1,1277 @@ +use std::borrow::Cow; + +use fancy_regex::{Error as RegexImplError, Regex}; +use saphyr::MarkedYaml; + +use crate::{Database, case_insensitive_regex, error::ConditionEvaluationError, logging}; + +use super::{ + error::{MetadataParsingErrorReason, ParseMetadataError, RegexError}, + file::File, + location::Location, + message::Message, + plugin_cleaning_data::PluginCleaningData, + tag::Tag, + yaml::{ + EmitYaml, TryFromYaml, YamlEmitter, YamlObjectType, as_mapping, get_required_string_value, + get_slice_value, get_string_value, + }, +}; + +pub(crate) const GHOST_FILE_EXTENSION: &str = ".ghost"; + +/// Represents a plugin's metadata. +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct PluginMetadata { + name: PluginName, + group: Option>, + load_after: Box<[File]>, + requirements: Box<[File]>, + incompatibilities: Box<[File]>, + messages: Box<[Message]>, + tags: Box<[Tag]>, + dirty_info: Box<[PluginCleaningData]>, + clean_info: Box<[PluginCleaningData]>, + locations: Box<[Location]>, +} + +impl PluginMetadata { + /// Construct a [PluginMetadata] object with no metadata for a plugin with + /// the given filename. + pub fn new(name: &str) -> Result { + Ok(Self { + name: PluginName::new(name)?, + ..Default::default() + }) + } + + /// Get the plugin name. + pub fn name(&self) -> &str { + self.name.as_str() + } + + /// Get the plugin's group. + /// + /// The [Option] is `None` if no group is explicitly set. + pub fn group(&self) -> Option<&str> { + self.group.as_deref() + } + + /// Get the plugins that the plugin must load after. + pub fn load_after_files(&self) -> &[File] { + &self.load_after + } + + /// Get the files that the plugin requires to be installed. + pub fn requirements(&self) -> &[File] { + &self.requirements + } + + /// Get the files that the plugin is incompatible with. + pub fn incompatibilities(&self) -> &[File] { + &self.incompatibilities + } + + /// Get the plugin's messages. + pub fn messages(&self) -> &[Message] { + &self.messages + } + + /// Get the plugin's Bash Tag suggestions. + pub fn tags(&self) -> &[Tag] { + &self.tags + } + + /// Get the plugin's dirty plugin information. + pub fn dirty_info(&self) -> &[PluginCleaningData] { + &self.dirty_info + } + + /// Get the plugin's clean plugin information. + pub fn clean_info(&self) -> &[PluginCleaningData] { + &self.clean_info + } + + /// Get the locations at which this plugin can be found. + pub fn locations(&self) -> &[Location] { + &self.locations + } + + /// Set the plugin's group. + pub fn set_group(&mut self, group: String) { + self.group = Some(group.into_boxed_str()); + } + + /// Unsets the plugin's group, so that it is implicitly a member of the + /// default group. + pub fn unset_group(&mut self) { + self.group = None; + } + + /// Get the plugins that the plugin must load after. + pub fn set_load_after_files(&mut self, files: Vec) { + self.load_after = files.into_boxed_slice(); + } + + /// Get the files that the plugin requires to be installed. + pub fn set_requirements(&mut self, files: Vec) { + self.requirements = files.into_boxed_slice(); + } + + /// Get the files that the plugin is incompatible with. + pub fn set_incompatibilities(&mut self, files: Vec) { + self.incompatibilities = files.into_boxed_slice(); + } + + /// Get the plugin's messages. + pub fn set_messages(&mut self, messages: Vec) { + self.messages = messages.into_boxed_slice(); + } + + /// Get the plugin's Bash Tag suggestions. + pub fn set_tags(&mut self, tags: Vec) { + self.tags = tags.into_boxed_slice(); + } + + /// Get the plugin's dirty plugin information. + pub fn set_dirty_info(&mut self, dirty_info: Vec) { + self.dirty_info = dirty_info.into_boxed_slice(); + } + + /// Get the plugin's clean plugin information. + pub fn set_clean_info(&mut self, clean_info: Vec) { + self.clean_info = clean_info.into_boxed_slice(); + } + + /// Get the locations at which this plugin can be found. + pub fn set_locations(&mut self, locations: Vec) { + self.locations = locations.into_boxed_slice(); + } + + /// Merge metadata from the given [PluginMetadata] object into this object. + /// + /// If an equal metadata object already exists in this PluginMetadata + /// object, it is not duplicated. This object's group is replaced by the + /// given object's group if the latter is explicit. + pub fn merge_metadata(&mut self, plugin: &PluginMetadata) { + if plugin.has_name_only() { + return; + } + + if self.group.is_none() && plugin.group.is_some() { + self.group.clone_from(&plugin.group); + } + + merge_slices(&mut self.load_after, &plugin.load_after); + merge_slices(&mut self.requirements, &plugin.requirements); + merge_slices(&mut self.incompatibilities, &plugin.incompatibilities); + merge_slices(&mut self.tags, &plugin.tags); + + self.messages = self + .messages + .iter() + .chain(plugin.messages.iter()) + .cloned() + .collect(); + + merge_slices(&mut self.dirty_info, &plugin.dirty_info); + merge_slices(&mut self.clean_info, &plugin.clean_info); + merge_slices(&mut self.locations, &plugin.locations); + } + + /// Check if no plugin metadata is set. + pub fn has_name_only(&self) -> bool { + self.group.is_none() + && self.load_after.is_empty() + && self.requirements.is_empty() + && self.incompatibilities.is_empty() + && self.messages.is_empty() + && self.tags.is_empty() + && self.dirty_info.is_empty() + && self.clean_info.is_empty() + && self.locations.is_empty() + } + + /// Check if the plugin name is a regular expression. + /// + /// Returns `true` if the plugin name contains any of the characters `:\*?|` + /// and `false` otherwise. + pub fn is_regex_plugin(&self) -> bool { + self.name.is_regex() + } + + /// Check if the given plugin name matches this plugin metadata object's + /// name field. + /// + /// If the name field is a regular expression, the given plugin name will be + /// matched against it, otherwise the strings will be compared + /// case-insensitively. The given plugin name must be literal, i.e. not a + /// regular expression. + pub fn name_matches(&self, other_name: &str) -> bool { + self.name.matches(other_name) + } + + /// Serialises the plugin metadata as YAML. + pub fn as_yaml(&self) -> String { + let mut emitter = YamlEmitter::new(); + self.emit_yaml(&mut emitter); + emitter.into_string() + } + + pub(crate) fn filter_by_constraints( + mut self, + database: &Database, + ) -> Result { + self.load_after = filter_files_by_constraint(self.load_after, database)?; + self.requirements = filter_files_by_constraint(self.requirements, database)?; + + Ok(self) + } +} + +fn filter_files_by_constraint( + files: Box<[File]>, + database: &Database, +) -> Result, ConditionEvaluationError> { + files + .into_iter() + .filter_map(|f| { + if let Some(c) = f.constraint() { + database.evaluate(c).map(|r| r.then_some(f)).transpose() + } else { + Some(Ok(f)) + } + }) + .collect() +} + +#[derive(Clone, Debug, Default)] +struct PluginName { + string: Box, + regex: Option, +} + +impl PluginName { + fn new(name: &str) -> Result> { + let name: Box = trim_dot_ghost(name).into(); + + if is_regex_name(&name) { + let non_capturing_name = replace_capturing_groups(&name); + + let regex = case_insensitive_regex(&format!("^{}$", &non_capturing_name))?; + + Ok(Self { + string: name, + regex: Some(regex), + }) + } else { + Ok(Self { + string: name, + regex: None, + }) + } + } + + fn matches(&self, other_name: &str) -> bool { + if let Some(regex) = &self.regex { + is_regex_match(regex, other_name) + } else { + unicase::eq(self.string.as_ref(), other_name) + } + } + + fn is_regex(&self) -> bool { + self.regex.is_some() + } + + fn as_str(&self) -> &str { + &self.string + } +} + +impl std::cmp::PartialEq for PluginName { + fn eq(&self, other: &Self) -> bool { + self.as_str() == other.as_str() + } +} + +impl std::cmp::Eq for PluginName {} + +impl std::cmp::PartialOrd for PluginName { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl std::cmp::Ord for PluginName { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.as_str().cmp(other.as_str()) + } +} + +impl std::hash::Hash for PluginName { + fn hash(&self, state: &mut H) { + self.as_str().hash(state); + } +} + +pub(crate) fn trim_dot_ghost(string: &str) -> &str { + let suffix_start_index = string.len().saturating_sub(GHOST_FILE_EXTENSION.len()); + if let Some((first, last)) = string.split_at_checked(suffix_start_index) { + if last.eq_ignore_ascii_case(GHOST_FILE_EXTENSION) { + first + } else { + string + } + } else { + string + } +} + +pub(crate) fn iends_with_ascii(string: &str, suffix: &str) -> bool { + // as_bytes().into_iter() is faster than bytes(). + string.len() >= suffix.len() + && string + .as_bytes() + .iter() + .rev() + .zip(suffix.as_bytes().iter().rev()) + .all(|(string_byte, suffix_byte)| string_byte.eq_ignore_ascii_case(suffix_byte)) +} + +fn is_regex_name(name: &str) -> bool { + name.contains([':', '\\', '*', '?', '|']) +} + +fn merge_slices(target: &mut Box<[T]>, source: &[T]) { + let mut vec = target.to_vec(); + for element in source { + if !target.contains(element) { + vec.push(element.clone()); + } + } + + *target = vec.into_boxed_slice(); +} + +fn replace_capturing_groups(regex_string: &str) -> Cow<'_, str> { + let mut iter = regex_string.split_inclusive('(').peekable(); + + let mut parts = Vec::new(); + + while let Some(before) = iter.next() { + parts.push(before); + + let Some(after) = iter.peek() else { + break; + }; + + if !after.starts_with('?') && (!before.ends_with("\\(") || before.ends_with("\\\\(")) { + parts.push("?:"); + } + } + + let new_length: usize = parts.iter().map(|s| s.len()).sum(); + + if new_length == regex_string.len() { + Cow::Borrowed(regex_string) + } else { + Cow::Owned(parts.into_iter().collect()) + } +} + +fn is_regex_match(regex: &Regex, string: &str) -> bool { + regex + .is_match(string) + .inspect_err(|e| { + logging::error!( + "Encountered an error while trying to match the regex {} to the string {}: {}", + regex.as_str(), + string, + e + ); + }) + .unwrap_or(false) +} + +impl TryFromYaml for PluginMetadata { + fn try_from_yaml(value: &MarkedYaml) -> Result { + let mapping = as_mapping(value, YamlObjectType::PluginMetadata)?; + + let name = get_required_string_value( + value.span.start, + mapping, + "name", + YamlObjectType::PluginMetadata, + )?; + let name = match PluginName::new(name) { + Ok(n) => n, + Err(e) => { + return Err(ParseMetadataError::new( + value.span.start, + MetadataParsingErrorReason::InvalidRegex(e), + )); + } + }; + + let group = get_string_value(mapping, "group", YamlObjectType::PluginMetadata)?; + + let load_after = get_boxed_slice(mapping, "after")?; + let requirements = get_boxed_slice(mapping, "req")?; + let incompatibilities = get_boxed_slice(mapping, "inc")?; + let messages = get_boxed_slice(mapping, "msg")?; + let tags = get_boxed_slice(mapping, "tag")?; + let dirty_info = get_boxed_slice(mapping, "dirty")?; + let clean_info = get_boxed_slice(mapping, "clean")?; + let locations = get_boxed_slice(mapping, "url")?; + + Ok(PluginMetadata { + name, + group: group.map(|g| g.1.into()), + load_after, + requirements, + incompatibilities, + messages, + dirty_info, + clean_info, + tags, + locations, + }) + } +} + +fn get_boxed_slice( + mapping: &saphyr::AnnotatedMapping, + key: &'static str, +) -> Result, ParseMetadataError> { + get_slice_value(mapping, key, YamlObjectType::PluginMetadata)? + .iter() + .map(|e| T::try_from_yaml(e)) + .collect() +} + +impl EmitYaml for PluginMetadata { + fn emit_yaml(&self, emitter: &mut YamlEmitter) { + emitter.begin_map(); + + emitter.map_key("name"); + emitter.single_quoted_str(self.name()); + + if !self.locations.is_empty() { + emitter.map_key("url"); + self.locations.emit_yaml(emitter); + } + + if let Some(group) = &self.group { + emitter.map_key("group"); + emitter.single_quoted_str(group); + } + + if !self.load_after.is_empty() { + emitter.map_key("after"); + self.load_after.emit_yaml(emitter); + } + + if !self.requirements.is_empty() { + emitter.map_key("req"); + self.requirements.emit_yaml(emitter); + } + + if !self.incompatibilities.is_empty() { + emitter.map_key("inc"); + self.incompatibilities.emit_yaml(emitter); + } + + if !self.messages.is_empty() { + emitter.map_key("msg"); + self.messages.emit_yaml(emitter); + } + + if !self.tags.is_empty() { + emitter.map_key("tag"); + self.tags.emit_yaml(emitter); + } + + if !self.dirty_info.is_empty() { + emitter.map_key("dirty"); + self.dirty_info.emit_yaml(emitter); + } + + if !self.clean_info.is_empty() { + emitter.map_key("clean"); + self.clean_info.emit_yaml(emitter); + } + + emitter.end_map(); + } +} + +#[cfg(test)] +mod tests { + use crate::{ + metadata::{MessageType, TagSuggestion}, + tests::{BLANK_DIFFERENT_ESM, BLANK_DIFFERENT_ESP, BLANK_ESM, BLANK_ESP}, + }; + + use super::*; + + mod name_matches { + use super::*; + + #[test] + fn should_use_case_insensitive_comparison_for_non_regex_names() { + let plugin = PluginMetadata::new("BLANK.ESM").unwrap(); + + assert!(plugin.name_matches("blank.esm")); + assert!(!plugin.name_matches("other.esm")); + } + + #[test] + fn should_treat_given_plugin_name_as_literal() { + let plugin = PluginMetadata::new("Blank.esm").unwrap(); + + assert!(!plugin.name_matches(".+")); + } + + #[test] + fn should_use_case_insensitive_regex_matching_for_a_regex_name() { + let plugin = PluginMetadata::new("Blank.ES(m|p)").unwrap(); + + assert!(plugin.name_matches("blank.esm")); + } + } + + mod merge_metadata { + use super::*; + + #[test] + fn should_not_change_name() { + let mut plugin1 = PluginMetadata::new(BLANK_ESM).unwrap(); + let plugin2 = PluginMetadata::new(BLANK_DIFFERENT_ESM).unwrap(); + + plugin1.merge_metadata(&plugin2); + + assert_eq!(BLANK_ESM, plugin1.name()); + } + + #[test] + fn should_not_use_other_group_if_current_group_is_set() { + let mut plugin1 = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin1.set_group("group1".into()); + let mut plugin2 = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin2.set_group("group2".into()); + + plugin1.merge_metadata(&plugin2); + + assert_eq!("group1", plugin1.group().unwrap()); + + plugin2.unset_group(); + + plugin1.merge_metadata(&plugin2); + + assert_eq!("group1", plugin1.group().unwrap()); + } + + #[test] + fn should_use_other_group_if_current_group_is_none() { + let mut plugin1 = PluginMetadata::new(BLANK_ESM).unwrap(); + let mut plugin2 = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin2.set_group("group2".into()); + + plugin1.merge_metadata(&plugin2); + + assert_eq!("group2", plugin1.group().unwrap()); + } + + #[test] + fn should_merge_load_after_files() { + let mut plugin1 = PluginMetadata::new(BLANK_ESM).unwrap(); + let mut plugin2 = PluginMetadata::new(BLANK_ESM).unwrap(); + + let file1 = File::new(BLANK_DIFFERENT_ESM.into()); + let file2 = File::new(BLANK_ESP.into()); + let file3 = File::new(BLANK_DIFFERENT_ESP.into()); + plugin1.set_load_after_files(vec![file1.clone(), file2.clone()]); + plugin2.set_load_after_files(vec![file1.clone(), file3.clone()]); + + plugin1.merge_metadata(&plugin2); + + assert_eq!( + &[file1.clone(), file2.clone(), file3.clone()], + plugin1.load_after_files() + ); + } + + #[test] + fn should_merge_requirements() { + let mut plugin1 = PluginMetadata::new(BLANK_ESM).unwrap(); + let mut plugin2 = PluginMetadata::new(BLANK_ESM).unwrap(); + + let file1 = File::new(BLANK_DIFFERENT_ESM.into()); + let file2 = File::new(BLANK_ESP.into()); + let file3 = File::new(BLANK_DIFFERENT_ESP.into()); + plugin1.set_requirements(vec![file1.clone(), file2.clone()]); + plugin2.set_requirements(vec![file1.clone(), file3.clone()]); + + plugin1.merge_metadata(&plugin2); + + assert_eq!( + &[file1.clone(), file2.clone(), file3.clone()], + plugin1.requirements() + ); + } + + #[test] + fn should_merge_incompatibilities() { + let mut plugin1 = PluginMetadata::new(BLANK_ESM).unwrap(); + let mut plugin2 = PluginMetadata::new(BLANK_ESM).unwrap(); + + let file1 = File::new(BLANK_DIFFERENT_ESM.into()); + let file2 = File::new(BLANK_ESP.into()); + let file3 = File::new(BLANK_DIFFERENT_ESP.into()); + plugin1.set_incompatibilities(vec![file1.clone(), file2.clone()]); + plugin2.set_incompatibilities(vec![file1.clone(), file3.clone()]); + + plugin1.merge_metadata(&plugin2); + + assert_eq!( + &[file1.clone(), file2.clone(), file3.clone()], + plugin1.incompatibilities() + ); + } + + #[test] + fn should_merge_messages() { + let mut plugin1 = PluginMetadata::new(BLANK_ESM).unwrap(); + let mut plugin2 = PluginMetadata::new(BLANK_ESM).unwrap(); + + let message1 = Message::new(MessageType::Say, "content1".into()); + let message2 = Message::new(MessageType::Say, "content2".into()); + let message3 = Message::new(MessageType::Say, "content3".into()); + plugin1.set_messages(vec![message1.clone(), message2.clone()]); + plugin2.set_messages(vec![message1.clone(), message3.clone()]); + + plugin1.merge_metadata(&plugin2); + + assert_eq!( + &[ + message1.clone(), + message2.clone(), + message1.clone(), + message3.clone() + ], + plugin1.messages() + ); + } + + #[test] + fn should_merge_tags() { + let mut plugin1 = PluginMetadata::new(BLANK_ESM).unwrap(); + let mut plugin2 = PluginMetadata::new(BLANK_ESM).unwrap(); + + let tag1 = Tag::new("Relev".into(), TagSuggestion::Addition); + let tag2 = Tag::new("Delev".into(), TagSuggestion::Addition); + let tag3 = Tag::new("Relev".into(), TagSuggestion::Removal); + plugin1.set_tags(vec![tag1.clone(), tag2.clone()]); + plugin2.set_tags(vec![tag1.clone(), tag3.clone()]); + + plugin1.merge_metadata(&plugin2); + + assert_eq!(&[tag1.clone(), tag2.clone(), tag3.clone()], plugin1.tags()); + } + + #[test] + fn should_merge_dirty_info() { + let mut plugin1 = PluginMetadata::new(BLANK_ESM).unwrap(); + let mut plugin2 = PluginMetadata::new(BLANK_ESM).unwrap(); + + let data1 = PluginCleaningData::new(0x1234_5678, "util1".into()); + let data2 = PluginCleaningData::new(0xDEAD_BEEF, "util2".into()); + let data3 = PluginCleaningData::new(0xFEED_CAFE, "util3".into()); + plugin1.set_dirty_info(vec![data1.clone(), data2.clone()]); + plugin2.set_dirty_info(vec![data1.clone(), data3.clone()]); + + plugin1.merge_metadata(&plugin2); + + assert_eq!( + &[data1.clone(), data2.clone(), data3.clone()], + plugin1.dirty_info() + ); + } + + #[test] + fn should_merge_clean_info() { + let mut plugin1 = PluginMetadata::new(BLANK_ESM).unwrap(); + let mut plugin2 = PluginMetadata::new(BLANK_ESM).unwrap(); + + let data1 = PluginCleaningData::new(0x1234_5678, "util1".into()); + let data2 = PluginCleaningData::new(0xDEAD_BEEF, "util2".into()); + let data3 = PluginCleaningData::new(0xFEED_CAFE, "util3".into()); + plugin1.set_clean_info(vec![data1.clone(), data2.clone()]); + plugin2.set_clean_info(vec![data1.clone(), data3.clone()]); + + plugin1.merge_metadata(&plugin2); + + assert_eq!( + &[data1.clone(), data2.clone(), data3.clone()], + plugin1.clean_info() + ); + } + + #[test] + fn should_merge_locations() { + let mut plugin1 = PluginMetadata::new(BLANK_ESM).unwrap(); + let mut plugin2 = PluginMetadata::new(BLANK_ESM).unwrap(); + + let location1 = Location::new("url1".into()); + let location2 = Location::new("url2".into()); + let location3 = Location::new("url3".into()); + plugin1.set_locations(vec![location1.clone(), location2.clone()]); + plugin2.set_locations(vec![location1.clone(), location3.clone()]); + + plugin1.merge_metadata(&plugin2); + + assert_eq!( + &[location1.clone(), location2.clone(), location3.clone()], + plugin1.locations() + ); + } + } + + #[test] + fn unset_group_should_set_group_to_none() { + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + + plugin.set_group("group1".into()); + plugin.unset_group(); + + assert!(plugin.group().is_none()); + } + + mod has_name_only { + use super::*; + + #[test] + fn should_be_true_if_only_a_name_is_set() { + assert!(PluginMetadata::new(BLANK_ESM).unwrap().has_name_only()); + } + + #[test] + fn should_be_false_if_a_group_is_set() { + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin.set_group("group1".into()); + + assert!(!plugin.has_name_only()); + } + + #[test] + fn should_be_false_if_load_after_files_are_set() { + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin.set_load_after_files(vec![File::new(BLANK_DIFFERENT_ESM.into())]); + + assert!(!plugin.has_name_only()); + } + + #[test] + fn should_be_false_if_requirements_are_set() { + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin.set_requirements(vec![File::new(BLANK_DIFFERENT_ESM.into())]); + + assert!(!plugin.has_name_only()); + } + + #[test] + fn should_be_false_if_incompatibilities_are_set() { + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin.set_incompatibilities(vec![File::new(BLANK_DIFFERENT_ESM.into())]); + + assert!(!plugin.has_name_only()); + } + + #[test] + fn should_be_false_if_messages_are_set() { + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin.set_messages(vec![Message::new(MessageType::Say, "content1".into())]); + + assert!(!plugin.has_name_only()); + } + + #[test] + fn should_be_false_if_tags_are_set() { + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin.set_tags(vec![Tag::new("Relev".into(), TagSuggestion::Addition)]); + + assert!(!plugin.has_name_only()); + } + + #[test] + fn should_be_false_if_dirty_info_is_set() { + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin.set_dirty_info(vec![PluginCleaningData::new(0x1234_5678, "util1".into())]); + + assert!(!plugin.has_name_only()); + } + + #[test] + fn should_be_false_if_clean_info_is_set() { + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin.set_clean_info(vec![PluginCleaningData::new(0x1234_5678, "util1".into())]); + + assert!(!plugin.has_name_only()); + } + + #[test] + fn should_be_false_if_locations_are_set() { + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin.set_locations(vec![Location::new("url1".into())]); + + assert!(!plugin.has_name_only()); + } + } + + mod is_regex_plugin { + use super::*; + + #[test] + fn should_be_false_for_an_empty_name() { + let plugin = PluginMetadata::new("").unwrap(); + + assert!(!plugin.is_regex_plugin()); + } + + #[test] + fn should_be_false_for_an_exact_plugin_name() { + let plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + + assert!(!plugin.is_regex_plugin()); + } + + #[test] + fn should_be_true_if_the_plugin_name_contains_a_colon() { + let plugin = PluginMetadata::new("Blank:.esm").unwrap(); + + assert!(plugin.is_regex_plugin()); + } + + #[test] + fn should_be_true_if_the_plugin_name_contains_a_backslash() { + let plugin = PluginMetadata::new("Blank\\.esm").unwrap(); + + assert!(plugin.is_regex_plugin()); + } + + #[test] + fn should_be_true_if_the_plugin_name_contains_an_asterisk() { + let plugin = PluginMetadata::new("Blank*.esm").unwrap(); + + assert!(plugin.is_regex_plugin()); + } + + #[test] + fn should_be_true_if_the_plugin_name_contains_a_question_mark() { + let plugin = PluginMetadata::new("Blank?.esm").unwrap(); + + assert!(plugin.is_regex_plugin()); + } + + #[test] + fn should_be_true_if_the_plugin_name_contains_a_vertical_bar() { + let plugin = PluginMetadata::new("Blank|.esm").unwrap(); + + assert!(plugin.is_regex_plugin()); + } + } + + mod as_yaml { + use super::*; + + #[test] + fn should_return_a_yaml_string_representation() { + let mut plugin = PluginMetadata::new("test.esp").unwrap(); + plugin.set_load_after_files(vec![File::new("other.esp".into())]); + let yaml = plugin.as_yaml(); + + assert_eq!( + format!( + "name: '{}'\nafter: ['{}']", + plugin.name(), + plugin.load_after[0].name() + ), + yaml + ); + } + } + + mod try_from_yaml { + use crate::metadata::parse; + + use super::*; + + #[test] + fn should_error_if_given_a_scalar() { + let yaml = parse("name1"); + + assert!(PluginMetadata::try_from_yaml(&yaml).is_err()); + } + + #[test] + fn should_error_if_given_a_list() { + let yaml = parse("[0, 1, 2]"); + + assert!(PluginMetadata::try_from_yaml(&yaml).is_err()); + } + + #[test] + fn should_store_all_given_data() { + let yaml = parse( + " + name: 'Blank.esp' + after: + - 'Blank.esm' + req: + - 'Blank - Different.esm' + inc: + - 'Blank - Different.esp' + msg: + - type: say + content: 'content' + tag: + - Relev + dirty: + - crc: 0x5 + util: 'utility' + clean: + - crc: 0x6 + util: 'utility' + url: + - 'https://www.example.com'", + ); + + let plugin = PluginMetadata::try_from_yaml(&yaml).unwrap(); + + assert_eq!(BLANK_ESP, plugin.name()); + assert_eq!(&[File::new(BLANK_ESM.into())], plugin.load_after_files()); + assert_eq!( + &[File::new(BLANK_DIFFERENT_ESM.into())], + plugin.requirements() + ); + assert_eq!( + &[File::new(BLANK_DIFFERENT_ESP.into())], + plugin.incompatibilities() + ); + assert_eq!( + &[Message::new(MessageType::Say, "content".into())], + plugin.messages() + ); + assert_eq!( + &[Tag::new("Relev".into(), TagSuggestion::Addition)], + plugin.tags() + ); + assert_eq!( + &[PluginCleaningData::new(0x5, "utility".into())], + plugin.dirty_info() + ); + assert_eq!( + &[PluginCleaningData::new(0x6, "utility".into())], + plugin.clean_info() + ); + assert_eq!( + &[Location::new("https://www.example.com".into())], + plugin.locations() + ); + } + + #[test] + fn should_not_error_if_regex_metadata_contains_dirty_or_clean_info() { + let yaml = parse( + " + name: 'Blank\\.esp' + dirty: + - crc: 0x5 + util: 'utility' + clean: + - crc: 0x6 + util: 'utility'", + ); + + let plugin = PluginMetadata::try_from_yaml(&yaml).unwrap(); + + assert_eq!("Blank\\.esp", plugin.name()); + assert_eq!( + &[PluginCleaningData::new(0x5, "utility".into())], + plugin.dirty_info() + ); + assert_eq!( + &[PluginCleaningData::new(0x6, "utility".into())], + plugin.clean_info() + ); + } + + #[test] + fn should_error_if_regex_name_is_invalid() { + let yaml = parse("{name: 'RagnvaldBook(Farengar(+Ragnvald)?)?\\.esp'}"); + + assert!(PluginMetadata::try_from_yaml(&yaml).is_err()); + } + + #[test] + fn should_error_if_a_field_that_should_be_an_array_is_not() { + let yaml = parse("{name: 'Blank.esp', after: Blank.esm}"); + + assert!(PluginMetadata::try_from_yaml(&yaml).is_err()); + } + } + + mod emit_yaml { + use super::*; + use crate::metadata::emit; + + #[test] + fn should_omit_group_if_not_set() { + let plugin = PluginMetadata::new("test.esp").unwrap(); + let yaml = emit(&plugin); + + assert_eq!(format!("name: '{}'", plugin.name()), yaml); + } + + #[test] + fn should_emit_group_if_set() { + let mut plugin = PluginMetadata::new("test.esp").unwrap(); + plugin.set_group("group1".into()); + let yaml = emit(&plugin); + + assert_eq!( + format!( + "name: '{}'\ngroup: '{}'", + plugin.name(), + plugin.group.as_ref().unwrap() + ), + yaml + ); + } + + #[test] + fn should_emit_a_single_scalar_load_after_file_in_flow_style() { + let mut plugin = PluginMetadata::new("test.esp").unwrap(); + plugin.set_load_after_files(vec![File::new("other.esp".into())]); + let yaml = emit(&plugin); + + assert_eq!( + format!( + "name: '{}'\nafter: ['{}']", + plugin.name(), + plugin.load_after[0].name() + ), + yaml + ); + } + + #[test] + fn should_emit_a_single_non_scalar_load_after_file_in_block_style() { + let mut plugin = PluginMetadata::new("test.esp").unwrap(); + plugin.set_load_after_files(vec![ + File::new("other.esp".into()).with_condition("condition1".into()), + ]); + let yaml = emit(&plugin); + + assert_eq!( + format!( + "name: '{}'\nafter:\n - name: '{}'\n condition: '{}'", + plugin.name(), + plugin.load_after[0].name(), + plugin.load_after[0].condition().unwrap(), + ), + yaml + ); + } + + #[test] + fn should_emit_multiple_load_after_files_in_block_style() { + let mut plugin = PluginMetadata::new("test.esp").unwrap(); + plugin.set_load_after_files(vec![ + File::new("other1.esp".into()), + File::new("other2.esp".into()), + ]); + let yaml = emit(&plugin); + + assert_eq!( + format!( + "name: '{}'\nafter:\n - '{}'\n - '{}'", + plugin.name(), + plugin.load_after[0].name(), + plugin.load_after[1].name(), + ), + yaml + ); + } + + #[test] + fn should_emit_a_single_scalar_requirements_in_flow_style() { + let mut plugin = PluginMetadata::new("test.esp").unwrap(); + plugin.set_requirements(vec![File::new("other.esp".into())]); + let yaml = emit(&plugin); + + assert_eq!( + format!( + "name: '{}'\nreq: ['{}']", + plugin.name(), + plugin.requirements[0].name() + ), + yaml + ); + } + + #[test] + fn should_emit_a_single_scalar_incompatibility_in_flow_style() { + let mut plugin = PluginMetadata::new("test.esp").unwrap(); + plugin.set_incompatibilities(vec![File::new("other.esp".into())]); + let yaml = emit(&plugin); + + assert_eq!( + format!( + "name: '{}'\ninc: ['{}']", + plugin.name(), + plugin.incompatibilities[0].name() + ), + yaml + ); + } + + #[test] + fn should_emit_messages() { + let mut plugin = PluginMetadata::new("test.esp").unwrap(); + plugin.set_messages(vec![ + Message::new(MessageType::Say, "content1".into()), + Message::new(MessageType::Say, "content2".into()), + ]); + let yaml = emit(&plugin); + + assert_eq!( + format!( + "name: '{}'\nmsg:\n - type: {}\n content: '{}'\n - type: {}\n content: '{}'", + plugin.name(), + plugin.messages[0].message_type(), + plugin.messages[0].content()[0].text(), + plugin.messages[1].message_type(), + plugin.messages[1].content()[0].text(), + ), + yaml + ); + } + + #[test] + fn should_emit_a_single_scalar_tag_in_flow_style() { + let mut plugin = PluginMetadata::new("test.esp").unwrap(); + plugin.set_tags(vec![Tag::new("Relev".into(), TagSuggestion::Addition)]); + let yaml = emit(&plugin); + + assert_eq!( + format!( + "name: '{}'\ntag: [{}]", + plugin.name(), + plugin.tags[0].name() + ), + yaml + ); + } + + #[test] + fn should_emit_dirty_info() { + let mut plugin = PluginMetadata::new("test.esp").unwrap(); + plugin.set_dirty_info(vec![PluginCleaningData::new(0xDEAD_BEEF, "utility".into())]); + let yaml = emit(&plugin); + + assert_eq!( + format!( + "name: '{}'\ndirty:\n - crc: 0x{:8X}\n util: '{}'", + plugin.name(), + plugin.dirty_info[0].crc(), + plugin.dirty_info[0].cleaning_utility() + ), + yaml + ); + } + + #[test] + fn should_emit_clean_info() { + let mut plugin = PluginMetadata::new("test.esp").unwrap(); + plugin.set_clean_info(vec![PluginCleaningData::new(0xDEAD_BEEF, "utility".into())]); + let yaml = emit(&plugin); + + assert_eq!( + format!( + "name: '{}'\nclean:\n - crc: 0x{:8X}\n util: '{}'", + plugin.name(), + plugin.clean_info[0].crc(), + plugin.clean_info[0].cleaning_utility() + ), + yaml + ); + } + + #[test] + fn should_emit_a_single_scalar_location_in_flow_style() { + let mut plugin = PluginMetadata::new("test.esp").unwrap(); + plugin.set_locations(vec![Location::new("https://www.example.com".into())]); + let yaml = emit(&plugin); + + assert_eq!( + format!( + "name: '{}'\nurl: ['{}']", + plugin.name(), + plugin.locations[0].url() + ), + yaml + ); + } + } + + mod replace_capturing_groups { + use super::*; + + #[test] + fn should_replace_capturing_groups_with_non_capturing_groups() { + let input = r"(a)?\((b)\)((?x)|\\(d))(?:e)\("; + + let output = replace_capturing_groups(input); + + assert_eq!(r"(?:a)?\((?:b)\)(?:(?x)|\\(?:d))(?:e)\(", output); + } + + #[test] + fn should_not_clone_string_if_no_opening_parentheses_are_found() { + let input = "no parentheses"; + + match replace_capturing_groups(input) { + Cow::Borrowed(output) => assert_eq!(input, output), + Cow::Owned(output) => panic!("Expected borrowed output, got \"{output}\""), + } + } + + #[test] + fn should_not_clone_string_if_no_capturing_groups_are_found() { + let input = "no paren(?:thes)es"; + + match replace_capturing_groups(input) { + Cow::Borrowed(output) => assert_eq!(input, output), + Cow::Owned(output) => panic!("Expected borrowed output, got \"{output}\""), + } + + let input = "no paren(?:th(?:e)s)es"; + + match replace_capturing_groups(input) { + Cow::Borrowed(output) => assert_eq!(input, output), + Cow::Owned(output) => panic!("Expected borrowed output, got \"{output}\""), + } + } + + #[test] + fn should_clone_non_capturing_prefix_correctly() { + let input = r"(?:a)?\(?:(b)\)((?x)|\\(d))(?:e)\("; + + let output = replace_capturing_groups(input); + + assert_eq!(r"(?:a)?\(?:(?:b)\)(?:(?x)|\\(?:d))(?:e)\(", output); + } + } +} diff --git a/src/metadata/tag.rs b/src/metadata/tag.rs new file mode 100644 index 00000000..08edd9e5 --- /dev/null +++ b/src/metadata/tag.rs @@ -0,0 +1,227 @@ +use saphyr::{MarkedYaml, Scalar, YamlData}; + +use super::{ + error::{ExpectedType, ParseMetadataError}, + yaml::{ + EmitYaml, TryFromYaml, YamlEmitter, YamlObjectType, get_required_string_value, + parse_condition, + }, +}; + +/// Represents whether a Bash Tag suggestion is for addition or removal. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub enum TagSuggestion { + #[default] + Addition, + Removal, +} + +/// Represents a Bash Tag suggestion for a plugin. +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct Tag { + name: Box, + suggestion: TagSuggestion, + condition: Option>, +} + +impl Tag { + /// Create a [Tag] suggestion for the given tag name. + #[must_use] + pub fn new(name: String, suggestion: TagSuggestion) -> Self { + Self { + name: name.into_boxed_str(), + suggestion, + condition: None, + } + } + + /// Set the condition string. + #[must_use] + pub fn with_condition(mut self, condition: String) -> Self { + self.condition = Some(condition.into_boxed_str()); + self + } + + /// Get the tag's name. + pub fn name(&self) -> &str { + &self.name + } + + /// Get if the tag should be added. + pub fn is_addition(&self) -> bool { + self.suggestion == TagSuggestion::Addition + } + + /// Get the condition string. + pub fn condition(&self) -> Option<&str> { + self.condition.as_deref() + } +} + +impl TryFromYaml for Tag { + fn try_from_yaml(value: &MarkedYaml) -> Result { + match &value.data { + YamlData::Value(Scalar::String(s)) => { + let (name, suggestion) = name_and_suggestion(s); + Ok(Tag { + name, + suggestion, + condition: None, + }) + } + YamlData::Mapping(h) => { + let name = + get_required_string_value(value.span.start, h, "name", YamlObjectType::Tag)?; + + let condition = parse_condition(h, "condition", YamlObjectType::Tag)?; + + let (name, suggestion) = name_and_suggestion(name); + Ok(Tag { + name, + suggestion, + condition, + }) + } + _ => Err(ParseMetadataError::unexpected_type( + value.span.start, + YamlObjectType::Tag, + ExpectedType::MapOrString, + )), + } + } +} + +fn name_and_suggestion(value: &str) -> (Box, TagSuggestion) { + if let Some(name) = value.strip_prefix("-") { + (name.into(), TagSuggestion::Removal) + } else { + (value.into(), TagSuggestion::Addition) + } +} + +impl EmitYaml for Tag { + fn is_scalar(&self) -> bool { + self.condition.is_none() + } + + fn emit_yaml(&self, emitter: &mut YamlEmitter) { + if let Some(condition) = &self.condition { + emitter.begin_map(); + + emitter.map_key("name"); + if self.is_addition() { + emitter.unquoted_str(&self.name); + } else { + emitter.unquoted_str(&format!("-{}", self.name)); + } + + emitter.map_key("condition"); + emitter.single_quoted_str(condition); + + emitter.end_map(); + } else if self.is_addition() { + emitter.unquoted_str(&self.name); + } else { + emitter.unquoted_str(&format!("-{}", self.name)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod try_from_yaml { + use crate::metadata::parse; + + use super::*; + + #[test] + fn should_only_set_name_and_suggestion_if_decoding_from_scalar() { + let yaml = parse("Relev"); + + let tag = Tag::try_from_yaml(&yaml).unwrap(); + + assert_eq!("Relev", tag.name()); + assert!(tag.is_addition()); + assert!(tag.condition().is_none()); + } + + #[test] + fn should_only_set_name_and_suggestion_if_decoding_from_scalar_with_leading_hyphen() { + let yaml = parse("-Relev"); + + let tag = Tag::try_from_yaml(&yaml).unwrap(); + + assert_eq!("Relev", tag.name()); + assert!(!tag.is_addition()); + assert!(tag.condition().is_none()); + } + + #[test] + fn should_error_if_given_a_list() { + let yaml = parse("[0, 1, 2]"); + + assert!(Tag::try_from_yaml(&yaml).is_err()); + } + + #[test] + fn should_error_if_name_is_missing() { + let yaml = parse("{condition: 'file(\"Foo.esp\")'}"); + + assert!(Tag::try_from_yaml(&yaml).is_err()); + } + + #[test] + fn should_error_if_given_an_invalid_condition() { + let yaml = parse("{name: Relev, condition: invalid}"); + + assert!(Tag::try_from_yaml(&yaml).is_err()); + } + + #[test] + fn should_set_all_fields() { + let yaml = parse("{name: Relev, condition: 'file(\"Foo.esp\")'}"); + + let tag = Tag::try_from_yaml(&yaml).unwrap(); + + assert_eq!("Relev", tag.name()); + assert!(tag.is_addition()); + assert_eq!("file(\"Foo.esp\")", tag.condition().unwrap()); + } + + #[test] + fn should_leave_optional_fields_empty_if_not_present() { + let yaml = parse("{name: Relev}"); + + let tag = Tag::try_from_yaml(&yaml).unwrap(); + + assert_eq!("Relev", tag.name()); + assert!(tag.is_addition()); + assert!(tag.condition().is_none()); + } + } + + mod emit_yaml { + use crate::metadata::emit; + + use super::*; + + #[test] + fn should_emit_name_only_if_unconditional_addition() { + let tag = Tag::new("name1".into(), TagSuggestion::Addition); + let yaml = emit(&tag); + + assert_eq!(tag.name(), yaml); + } + + #[test] + fn should_emit_map_if_there_is_a_condition() { + let tag = + Tag::new("name1".into(), TagSuggestion::Removal).with_condition("condition".into()); + let yaml = emit(&tag); + + assert_eq!("name: -name1\ncondition: 'condition'", yaml); + } + } +} diff --git a/src/metadata/yaml/emit.rs b/src/metadata/yaml/emit.rs new file mode 100644 index 00000000..a4a554bc --- /dev/null +++ b/src/metadata/yaml/emit.rs @@ -0,0 +1,539 @@ +pub trait EmitYaml { + fn is_scalar(&self) -> bool { + false + } + + fn emit_yaml(&self, emitter: &mut YamlEmitter); +} + +#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct YamlEmitter { + buffer: String, + scope: Vec, + style: YamlStyle, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +enum YamlBlock { + Array, + Map, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +enum YamlStyle { + /// YAML flow style + Flow, + /// YAML block style + /// + /// This is only respected for sequences. Mappings and scalars are always + /// emitted in flow style. + Block, +} + +impl YamlEmitter { + const INDENT_UNIT: &str = " "; + const ARRAY_ELEMENT_PREFIX: &str = "- "; + + pub fn new() -> Self { + Self { + buffer: String::new(), + scope: vec![], + style: YamlStyle::Block, + } + } + + pub fn into_string(self) -> String { + self.buffer + } + + pub fn unquoted_str(&mut self, value: &str) { + if self.style == YamlStyle::Block { + self.write_prefix(); + } + + if can_emit_unquoted(value, self.style) { + self.write(value); + } else if can_single_quote(value) { + self.write(&single_quote(value)); + } else { + self.write(&double_quote(value)); + } + } + + pub fn single_quoted_str(&mut self, value: &str) { + if self.style == YamlStyle::Block { + self.write_prefix(); + } + + if can_single_quote(value) { + self.write(&single_quote(value)); + } else { + self.write(&double_quote(value)); + } + } + + pub fn u32(&mut self, value: u32) { + if self.style == YamlStyle::Block { + self.write_prefix(); + } + + self.write(&value.to_string()); + } + + pub fn begin_map(&mut self) { + if self.scope.last() == Some(&YamlBlock::Array) { + self.end_line(); + self.write_indent(); + self.write(Self::ARRAY_ELEMENT_PREFIX); + } + } + + pub fn end_map(&mut self) { + if self.scope.last() == Some(&YamlBlock::Map) { + self.scope.pop(); + } + } + + /// This assumes that the given key is valid to be written as an unquoted + /// string, and expects a string literal so that it's obvious that a given + /// value is valid. + pub fn map_key(&mut self, key: &'static str) { + match self.scope.last() { + Some(&YamlBlock::Map) => { + self.end_line(); + self.write_indent(); + } + _ => self.scope.push(YamlBlock::Map), + } + + self.write(&format!("{key}:")); + } + + pub fn begin_array(&mut self) { + if self.style == YamlStyle::Flow { + if self.scope.last() == Some(&YamlBlock::Map) { + self.write(" "); + } + self.write("["); + } + + self.scope.push(YamlBlock::Array); + } + + pub fn end_array(&mut self) { + if self.scope.last() == Some(&YamlBlock::Array) { + self.scope.pop(); + } + + if self.style == YamlStyle::Flow { + self.write("]"); + } + } + + pub fn set_flow_style(&mut self) { + self.style = YamlStyle::Flow; + } + + pub fn set_block_style(&mut self) { + self.style = YamlStyle::Block; + } + + fn end_line(&mut self) { + self.write("\n"); + } + + fn write_indent(&mut self) { + // If in a map, no indent is needed, but an array needs an indent, and a + // map in an array needs an indent. + if !self.scope.is_empty() { + for _ in 0..self.scope.len() - 1 { + self.write(Self::INDENT_UNIT); + } + } + } + + fn write_prefix(&mut self) { + match self.scope.last() { + Some(&YamlBlock::Array) => { + self.end_line(); + self.write_indent(); + self.write(Self::ARRAY_ELEMENT_PREFIX); + } + Some(&YamlBlock::Map) => self.write(" "), + _ => self.write_indent(), + } + } + + fn write(&mut self, value: &str) { + self.buffer += value; + } +} + +fn is_yaml_whitespace(c: char) -> bool { + // + c == ' ' || c == '\t' +} + +fn is_flow_indicator(c: char) -> bool { + // + matches!(c, '[' | ']' | '{' | '}' | ',') +} + +fn should_escape(c: char) -> bool { + // This isn't defined by the YAML spec, but is based on guidance in + // , plus a extra few + // characters (\t, \r, \n, \x7F, \x85 and \uFEFF). + // Surrogates are not represented because Rust's char type is defined to not + // hold them. + matches!(c, '\x00'..='\x1F' | '\x7F' | '\u{0080}'..='\u{009F}' | '\u{FEFF}' | '\u{FFFE}' | '\u{FFFF}') +} + +/// This disallows multi-line unquoted strings, which YAML does allow in some +/// contexts, but there's no expectation of such strings coming out of libloot. +/// It also disallows strings containing tabs (\t), DEL (\x7F) and NEL (\x85), +/// which YAML does allow. +fn can_emit_unquoted(value: &str, style: YamlStyle) -> bool { + // + if value.is_empty() + || value.starts_with(is_yaml_whitespace) + || value.ends_with(is_yaml_whitespace) + { + return false; + } + + if value.starts_with(|c| { + matches!( + c, + ',' | '[' + | ']' + | '{' + | '}' + | '#' + | '&' + | '*' + | '!' + | '|' + | '>' + | '\'' + | '"' + | '%' + | '@' + | '`' + ) + }) { + return false; + } + + if value.starts_with("? ") + || value.starts_with("?\t") + || value.starts_with("- ") + || value.starts_with("-\t") + { + return false; + } + + if value.contains(": ") + || value.contains(":\t") + || value.contains(" #") + || value.contains("\t#") + { + return false; + } + + if style == YamlStyle::Flow && value.contains(is_flow_indicator) { + return false; + } + + !value.chars().any(should_escape) +} + +/// This disallows line breaks, which are allowed by YAML, but they can't be +/// escaped in single-quoted strings and the rules about emitting multi-line +/// YAML strings are relatively complicated so just avoid having to deal with +/// them. A couple of other characters are disallowed that YAML allows (e.g. +/// tab and the BOM). +fn can_single_quote(value: &str) -> bool { + // Single-quoted strings are restricted to printable characters + // + !value.chars().any(should_escape) +} + +fn single_quote(value: &str) -> String { + // Single-quoted strings need single quotes escaped by repeating them. + // + format!("'{}'", value.replace('\'', "''")) +} + +fn double_quote(value: &str) -> String { + // + let escaped: String = value + .chars() + .map(|c| { + if should_escape(c) { + match c { + '\x00' => "\\0".to_owned(), + '\x07' => "\\a".to_owned(), + '\x08' => "\\b".to_owned(), + '\x09' => "\\t".to_owned(), + '\x0A' => "\\n".to_owned(), + '\x0B' => "\\v".to_owned(), + '\x0C' => "\\f".to_owned(), + '\x0D' => "\\r".to_owned(), + '\x1B' => "\\e".to_owned(), + '\x20' => "\\x20".to_owned(), + '"' => "\\\"".to_owned(), + '/' => "\\/".to_owned(), + '\\' => "\\\\".to_owned(), + '\u{0085}' => "\\N".to_owned(), + '\u{00A0}' => "\\_".to_owned(), + '\u{2028}' => "\\L".to_owned(), + '\u{2029}' => "\\P".to_owned(), + '\u{00}'..='\u{FF}' => format!("\\x{:02X}", u32::from(c)), + '\u{0100}'..='\u{FFFF}' => format!("\\u{:04X}", u32::from(c)), + c => format!("\\U{:08X}", u32::from(c)), + } + } else { + c.to_string() + } + }) + .collect(); + + format!("\"{escaped}\"") +} + +impl EmitYaml for &[T] { + fn emit_yaml(&self, emitter: &mut YamlEmitter) { + match self { + [] => {} + [element] if element.is_scalar() => { + emitter.set_flow_style(); + emitter.begin_array(); + element.emit_yaml(emitter); + emitter.end_array(); + emitter.set_block_style(); + } + elements => { + emitter.begin_array(); + + for element in *elements { + element.emit_yaml(emitter); + } + + emitter.end_array(); + } + } + } +} + +impl EmitYaml for Vec { + fn emit_yaml(&self, emitter: &mut YamlEmitter) { + self.as_slice().emit_yaml(emitter); + } +} + +impl EmitYaml for Box<[T]> { + fn emit_yaml(&self, emitter: &mut YamlEmitter) { + self.as_ref().emit_yaml(emitter); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod yaml_emitter { + use super::*; + + mod unquoted_str { + use super::*; + + fn emit(str: &str) -> String { + let mut emitter = YamlEmitter::new(); + emitter.unquoted_str(str); + emitter.into_string() + } + + #[test] + fn should_emit_string_as_given() { + let value = "hello world"; + + assert_eq!(value, emit(value)); + } + + #[test] + fn should_fall_back_to_quoting_string_if_it_cannot_be_emitted_unquoted() { + assert_eq!("''", emit("")); + assert_eq!("' a'", emit(" a")); + assert_eq!("'a '", emit("a ")); + assert_eq!("',a'", emit(",a")); + assert_eq!("'[a'", emit("[a")); + assert_eq!("']a'", emit("]a")); + assert_eq!("'{a'", emit("{a")); + assert_eq!("'}a'", emit("}a")); + assert_eq!("'#a'", emit("#a")); + assert_eq!("'&a'", emit("&a")); + assert_eq!("'*a'", emit("*a")); + assert_eq!("'!a'", emit("!a")); + assert_eq!("'|a'", emit("|a")); + assert_eq!("'>a'", emit(">a")); + assert_eq!("'''a'", emit("'a")); + assert_eq!("'\"a'", emit("\"a")); + assert_eq!("'%a'", emit("%a")); + assert_eq!("'@a'", emit("@a")); + assert_eq!("'`a'", emit("`a")); + assert_eq!("'? a'", emit("? a")); + assert_eq!("\"?\\ta\"", emit("?\ta")); + assert_eq!("'- a'", emit("- a")); + assert_eq!("\"-\\ta\"", emit("-\ta")); + assert_eq!("'a: b'", emit("a: b")); + assert_eq!("\"a:\\tb\"", emit("a:\tb")); + assert_eq!("'a #b'", emit("a #b")); + assert_eq!("\"a\\t#b\"", emit("a\t#b")); + } + + #[test] + fn should_fall_back_to_single_quoting_string_that_contains_a_flow_indicator_when_style_is_flow() + { + fn emit_flow(str: &str) -> String { + let mut emitter = YamlEmitter::new(); + emitter.set_flow_style(); + emitter.unquoted_str(str); + emitter.into_string() + } + + assert_eq!("a[b", emit("a[b")); + assert_eq!("a]b", emit("a]b")); + assert_eq!("a{b", emit("a{b")); + assert_eq!("a}b", emit("a}b")); + assert_eq!("a,b", emit("a,b")); + + assert_eq!("'a[b'", emit_flow("a[b")); + assert_eq!("'a]b'", emit_flow("a]b")); + assert_eq!("'a{b'", emit_flow("a{b")); + assert_eq!("'a}b'", emit_flow("a}b")); + assert_eq!("'a,b'", emit_flow("a,b")); + } + + #[test] + fn should_fall_back_to_double_quoting_string_if_it_cannot_be_unquoted_or_single_quoted() + { + assert_eq!( + "\"\\e[1mhello world\\e[0m\"", + emit("\x1B[1mhello world\x1B[0m") + ); + } + + #[test] + fn should_fall_back_to_double_quoting_if_string_contains_a_c0_control_character() { + assert_eq!("\"\\0\"", emit("\x00")); + assert_eq!("\"\\x01\"", emit("\x01")); + assert_eq!("\"\\x02\"", emit("\x02")); + assert_eq!("\"\\x03\"", emit("\x03")); + assert_eq!("\"\\x04\"", emit("\x04")); + assert_eq!("\"\\x05\"", emit("\x05")); + assert_eq!("\"\\x06\"", emit("\x06")); + assert_eq!("\"\\a\"", emit("\x07")); + assert_eq!("\"\\b\"", emit("\x08")); + assert_eq!("\"\\t\"", emit("\x09")); + assert_eq!("\"\\n\"", emit("\x0A")); + assert_eq!("\"\\v\"", emit("\x0B")); + assert_eq!("\"\\f\"", emit("\x0C")); + assert_eq!("\"\\r\"", emit("\x0D")); + assert_eq!("\"\\x0E\"", emit("\x0E")); + assert_eq!("\"\\x0F\"", emit("\x0F")); + assert_eq!("\"\\x10\"", emit("\x10")); + assert_eq!("\"\\x11\"", emit("\x11")); + assert_eq!("\"\\x12\"", emit("\x12")); + assert_eq!("\"\\x13\"", emit("\x13")); + assert_eq!("\"\\x14\"", emit("\x14")); + assert_eq!("\"\\x15\"", emit("\x15")); + assert_eq!("\"\\x16\"", emit("\x16")); + assert_eq!("\"\\x17\"", emit("\x17")); + assert_eq!("\"\\x18\"", emit("\x18")); + assert_eq!("\"\\x19\"", emit("\x19")); + assert_eq!("\"\\x1A\"", emit("\x1A")); + assert_eq!("\"\\e\"", emit("\x1B")); + assert_eq!("\"\\x1C\"", emit("\x1C")); + assert_eq!("\"\\x1D\"", emit("\x1D")); + assert_eq!("\"\\x1E\"", emit("\x1E")); + assert_eq!("\"\\x1F\"", emit("\x1F")); + } + + #[test] + fn should_fall_back_to_double_quoting_if_string_contains_a_del_character() { + assert_eq!("\"\\x7F\"", emit("\x7F")); + } + + #[test] + fn should_fall_back_to_double_quoting_if_string_contains_a_c1_control_character() { + assert_eq!("\"\\x80\"", emit("\u{0080}")); + assert_eq!("\"\\x81\"", emit("\u{0081}")); + assert_eq!("\"\\x82\"", emit("\u{0082}")); + assert_eq!("\"\\x83\"", emit("\u{0083}")); + assert_eq!("\"\\x84\"", emit("\u{0084}")); + assert_eq!("\"\\N\"", emit("\u{0085}")); + assert_eq!("\"\\x86\"", emit("\u{0086}")); + assert_eq!("\"\\x87\"", emit("\u{0087}")); + assert_eq!("\"\\x88\"", emit("\u{0088}")); + assert_eq!("\"\\x89\"", emit("\u{0089}")); + assert_eq!("\"\\x8A\"", emit("\u{008A}")); + assert_eq!("\"\\x8B\"", emit("\u{008B}")); + assert_eq!("\"\\x8C\"", emit("\u{008C}")); + assert_eq!("\"\\x8D\"", emit("\u{008D}")); + assert_eq!("\"\\x8E\"", emit("\u{008E}")); + assert_eq!("\"\\x8F\"", emit("\u{008F}")); + assert_eq!("\"\\x90\"", emit("\u{0090}")); + assert_eq!("\"\\x91\"", emit("\u{0091}")); + assert_eq!("\"\\x92\"", emit("\u{0092}")); + assert_eq!("\"\\x93\"", emit("\u{0093}")); + assert_eq!("\"\\x94\"", emit("\u{0094}")); + assert_eq!("\"\\x95\"", emit("\u{0095}")); + assert_eq!("\"\\x96\"", emit("\u{0096}")); + assert_eq!("\"\\x97\"", emit("\u{0097}")); + assert_eq!("\"\\x98\"", emit("\u{0098}")); + assert_eq!("\"\\x99\"", emit("\u{0099}")); + assert_eq!("\"\\x9A\"", emit("\u{009A}")); + assert_eq!("\"\\x9B\"", emit("\u{009B}")); + assert_eq!("\"\\x9C\"", emit("\u{009C}")); + assert_eq!("\"\\x9D\"", emit("\u{009D}")); + assert_eq!("\"\\x9E\"", emit("\u{009E}")); + assert_eq!("\"\\x9F\"", emit("\u{009F}")); + } + + #[test] + fn should_fall_back_to_double_quoting_if_string_contains_a_bom() { + assert_eq!("\"\\uFEFF\"", emit("\u{FEFF}")); + } + + #[test] + fn should_fall_back_to_double_quoting_if_string_contains_unicode_fffe_or_ffff() { + assert_eq!("\"\\uFFFE\"", emit("\u{FFFE}")); + assert_eq!("\"\\uFFFF\"", emit("\u{FFFF}")); + } + } + + mod single_quoted_str { + use super::*; + + #[test] + fn single_quoted_str_should_emit_string_wrapped_in_single_quotes_and_with_single_quotes_doubled() + { + let value = "hello 'world'"; + let mut emitter = YamlEmitter::new(); + emitter.single_quoted_str(value); + + assert_eq!("'hello ''world'''", emitter.into_string()); + } + + #[test] + fn single_quoted_str_should_fall_back_to_double_quoting_string_if_it_contains_non_printable_characters() + { + let value = "\x1B[1mhello world\x1B[0m"; + let mut emitter = YamlEmitter::new(); + emitter.single_quoted_str(value); + + assert_eq!("\"\\e[1mhello world\\e[0m\"", emitter.into_string()); + } + } + } +} diff --git a/src/metadata/yaml/merge.rs b/src/metadata/yaml/merge.rs new file mode 100644 index 00000000..1ca5c986 --- /dev/null +++ b/src/metadata/yaml/merge.rs @@ -0,0 +1,114 @@ +use saphyr::{MarkedYaml, YamlData}; + +use crate::metadata::error::YamlMergeKeyError; + +pub fn process_merge_keys(mut yaml: MarkedYaml) -> Result { + match yaml.data { + YamlData::Sequence(a) => { + yaml.data = merge_array_elements(a).map(YamlData::Sequence)?; + Ok(yaml) + } + YamlData::Mapping(h) => { + yaml.data = merge_mapping_keys(h).map(YamlData::Mapping)?; + Ok(yaml) + } + _ => Ok(yaml), + } +} + +fn merge_array_elements( + array: saphyr::AnnotatedSequence, +) -> Result, YamlMergeKeyError> { + array.into_iter().map(process_merge_keys).collect() +} + +fn merge_mapping_keys<'a, 'b>( + mapping: saphyr::AnnotatedMapping<'a, MarkedYaml<'b>>, +) -> Result>, YamlMergeKeyError> { + let mut mapping: saphyr::AnnotatedMapping = mapping + .into_iter() + .map(|(key, value)| { + process_merge_keys(key) + .and_then(|key| process_merge_keys(value).map(|value| (key, value))) + }) + .collect::>()?; + + if let Some(value) = mapping.remove(&MarkedYaml::value_from_str("<<")) { + merge_into_mapping(mapping, value) + } else { + Ok(mapping) + } +} + +fn merge_into_mapping<'a, 'b>( + mapping: saphyr::AnnotatedMapping<'a, MarkedYaml<'b>>, + value: MarkedYaml<'b>, +) -> Result>, YamlMergeKeyError> { + match value.data { + YamlData::Sequence(a) => a.into_iter().try_fold(mapping, |acc, e| { + if let YamlData::Mapping(h) = e.data { + Ok(merge_mappings(acc, h)) + } else { + Err(YamlMergeKeyError::new(&e)) + } + }), + YamlData::Mapping(h) => Ok(merge_mappings(mapping, h)), + _ => Err(YamlMergeKeyError::new(&value)), + } +} + +fn merge_mappings<'a, 'b>( + mut mapping1: saphyr::AnnotatedMapping<'a, MarkedYaml<'b>>, + mapping2: saphyr::AnnotatedMapping>, +) -> saphyr::AnnotatedMapping<'a, MarkedYaml<'b>> { + for (key, value) in mapping2 { + mapping1.entry(key).or_insert(value); + } + mapping1 +} + +#[cfg(test)] +mod tests { + use super::*; + + mod process_merge_keys { + use crate::metadata::parse; + + use super::*; + + #[test] + fn should_error_if_merge_key_value_has_a_single_value_that_is_not_a_hash() { + let yaml = parse( + " +- &anchor1 test +- <<: *anchor1 + value: test-value-2", + ); + + let error_message = process_merge_keys(yaml).unwrap_err().to_string(); + + assert_eq!( + "invalid YAML merge key value at line 3 column 6: test", + error_message + ); + } + + #[test] + fn should_error_if_merge_key_value_is_an_array_of_non_hash_values() { + let yaml = parse( + " +- &anchor1 {key: test-key} +- &anchor2 test +- <<: [*anchor1, *anchor2] + value: test-value-2", + ); + + let error_message = process_merge_keys(yaml).unwrap_err().to_string(); + + assert_eq!( + "invalid YAML merge key value at line 4 column 17: test", + error_message + ); + } + } +} diff --git a/src/metadata/yaml/mod.rs b/src/metadata/yaml/mod.rs new file mode 100644 index 00000000..615b38b9 --- /dev/null +++ b/src/metadata/yaml/mod.rs @@ -0,0 +1,11 @@ +mod emit; +mod merge; +mod parse; + +pub use emit::{EmitYaml, YamlEmitter}; +pub use merge::process_merge_keys; +pub use parse::{ + TryFromYaml, YamlObjectType, as_mapping, get_required_string_value, get_slice_value, + get_string_value, get_strings_vec_value, get_u32_value, get_value, parse_condition, + to_unmarked_yaml, +}; diff --git a/src/metadata/yaml/parse.rs b/src/metadata/yaml/parse.rs new file mode 100644 index 00000000..88a5deca --- /dev/null +++ b/src/metadata/yaml/parse.rs @@ -0,0 +1,202 @@ +use std::str::FromStr; + +use loot_condition_interpreter::Expression; +use saphyr::{AnnotatedMapping, MarkedYaml, Marker, Scalar, Yaml, YamlData}; + +use super::super::error::{ExpectedType, MetadataParsingErrorReason, ParseMetadataError}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub enum YamlObjectType { + File, + Group, + Location, + Message, + MessageContent, + PluginCleaningData, + PluginMetadata, + Tag, + MetadataDocument, + BashTagsElement, +} + +impl std::fmt::Display for YamlObjectType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + YamlObjectType::File => write!(f, "file"), + YamlObjectType::Group => write!(f, "group"), + YamlObjectType::Location => write!(f, "location"), + YamlObjectType::Message => write!(f, "message"), + YamlObjectType::MessageContent => write!(f, "message content"), + YamlObjectType::PluginCleaningData => write!(f, "plugin cleaning data"), + YamlObjectType::PluginMetadata => write!(f, "plugin metadata"), + YamlObjectType::Tag => write!(f, "tag"), + YamlObjectType::MetadataDocument => write!(f, "metadata document"), + YamlObjectType::BashTagsElement => write!(f, "bash tags"), + } + } +} + +pub fn to_unmarked_yaml<'a>(yaml: &MarkedYaml<'a>) -> Yaml<'a> { + match &yaml.data { + YamlData::Value(Scalar::FloatingPoint(v)) => Yaml::Value(Scalar::FloatingPoint(*v)), + YamlData::Value(Scalar::Integer(v)) => Yaml::Value(Scalar::Integer(*v)), + YamlData::Value(Scalar::String(v)) => Yaml::Value(Scalar::String(v.clone())), + YamlData::Value(Scalar::Boolean(v)) => Yaml::Value(Scalar::Boolean(*v)), + YamlData::Value(Scalar::Null) => Yaml::Value(Scalar::Null), + YamlData::Sequence(v) => Yaml::Sequence(v.iter().map(to_unmarked_yaml).collect()), + YamlData::Mapping(v) => Yaml::Mapping( + v.iter() + .map(|(key, value)| (to_unmarked_yaml(key), to_unmarked_yaml(value))) + .collect(), + ), + YamlData::Alias(v) => Yaml::Alias(*v), + YamlData::BadValue => Yaml::BadValue, + YamlData::Representation(v, s, t) => Yaml::Representation(v.clone(), *s, t.clone()), + } +} + +pub fn get_value<'a, 'b>( + mapping: &'a AnnotatedMapping<'b, MarkedYaml<'b>>, + key: &'static str, +) -> Option<&'a MarkedYaml<'b>> { + mapping.get(&MarkedYaml::value_from_str(key)) +} + +pub fn get_string_value<'a>( + mapping: &'a AnnotatedMapping, + key: &'static str, + yaml_type: YamlObjectType, +) -> Result, ParseMetadataError> { + match get_value(mapping, key) { + Some(n) => match n.data.as_str() { + Some(s) => Ok(Some((n.span.start, s))), + None => Err(ParseMetadataError::unexpected_value_type( + n.span.start, + key, + yaml_type, + ExpectedType::String, + )), + }, + None => Ok(None), + } +} + +pub fn get_required_string_value<'a>( + marker: Marker, + mapping: &'a AnnotatedMapping, + key: &'static str, + yaml_type: YamlObjectType, +) -> Result<&'a str, ParseMetadataError> { + match get_string_value(mapping, key, yaml_type)? { + Some(n) => Ok(n.1), + None => Err(ParseMetadataError::missing_key(marker, key, yaml_type)), + } +} + +pub fn get_strings_vec_value<'a>( + mapping: &'a AnnotatedMapping, + key: &'static str, + yaml_type: YamlObjectType, +) -> Result, ParseMetadataError> { + match get_value(mapping, key) { + Some(n) => match n.data.as_vec() { + Some(n) => n + .iter() + .map(|e| match e.data.as_str() { + Some(s) => Ok(s), + None => Err(ParseMetadataError::unexpected_value_type( + e.span.start, + key, + yaml_type, + ExpectedType::String, + )), + }) + .collect(), + None => Err(ParseMetadataError::unexpected_value_type( + n.span.start, + key, + yaml_type, + ExpectedType::Array, + )), + }, + None => Ok(Vec::new()), + } +} + +pub fn as_mapping<'a, 'b>( + value: &'a MarkedYaml<'b>, + yaml_type: YamlObjectType, +) -> Result<&'a AnnotatedMapping<'a, MarkedYaml<'b>>, ParseMetadataError> { + match value.data.as_mapping() { + Some(h) => Ok(h), + None => Err(ParseMetadataError::unexpected_type( + value.span.start, + yaml_type, + ExpectedType::Map, + )), + } +} + +pub fn get_u32_value( + mapping: &AnnotatedMapping, + key: &'static str, + yaml_type: YamlObjectType, +) -> Result, ParseMetadataError> { + match get_value(mapping, key) { + Some(n) => match n.data.as_integer() { + Some(i) => i.try_into().map(Some).map_err(|_e| { + ParseMetadataError::new(n.span.start, MetadataParsingErrorReason::NonU32Number(i)) + }), + None => Err(ParseMetadataError::unexpected_value_type( + n.span.start, + key, + yaml_type, + ExpectedType::Number, + )), + }, + None => Ok(None), + } +} + +pub fn get_slice_value<'a>( + mapping: &'a saphyr::AnnotatedMapping, + key: &'static str, + yaml_type: YamlObjectType, +) -> Result<&'a [MarkedYaml<'a>], ParseMetadataError> { + if let Some(value) = get_value(mapping, key) { + match value.data.as_vec() { + Some(n) => Ok(n.as_slice()), + None => Err(ParseMetadataError::unexpected_value_type( + value.span.start, + key, + yaml_type, + ExpectedType::Array, + )), + } + } else { + Ok(&[]) + } +} + +pub fn parse_condition( + mapping: &saphyr::AnnotatedMapping, + key: &'static str, + yaml_type: YamlObjectType, +) -> Result>, ParseMetadataError> { + match get_string_value(mapping, key, yaml_type)? { + Some((marker, s)) => { + let s = s.to_owned(); + if let Err(e) = Expression::from_str(&s) { + return Err(ParseMetadataError::invalid_condition(marker, s, e)); + } + Ok(Some(s.into_boxed_str())) + } + None => Ok(None), + } +} + +/// This is effectively TryFrom<&MarkedYaml>, but implementing it doesn't make +/// MarkedYaml part of the crate's public API. +pub trait TryFromYaml: Sized { + fn try_from_yaml(value: &MarkedYaml) -> Result; +} diff --git a/src/plugin/error.rs b/src/plugin/error.rs new file mode 100644 index 00000000..2b2de98a --- /dev/null +++ b/src/plugin/error.rs @@ -0,0 +1,153 @@ +use std::path::PathBuf; + +use fancy_regex::Error as RegexImplError; + +use crate::escape_ascii; + +/// Represents an error that occurred while reading a parsed plugin's data. +#[derive(Debug)] +pub struct PluginDataError(esplugin::Error); + +impl PluginDataError { + pub(crate) fn plugin_not_loaded(&self) -> Option<&str> { + match &self.0 { + esplugin::Error::PluginMetadataNotFound(p) => Some(p.as_str()), + _ => None, + } + } +} + +impl std::fmt::Display for PluginDataError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "failed to read plugin data") + } +} + +impl std::error::Error for PluginDataError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(&self.0) + } +} + +impl From for PluginDataError { + fn from(value: esplugin::Error) -> Self { + PluginDataError(value) + } +} + +#[derive(Debug)] +#[non_exhaustive] +pub(crate) enum LoadPluginError { + InvalidFilename(InvalidFilenameReason), + IoError(std::io::Error), + ParsingError(esplugin::Error), + RegexError(Box), +} + +impl std::fmt::Display for LoadPluginError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidFilename(i) => i.fmt(f), + Self::IoError(_) => write!(f, "an I/O error occurred"), + Self::ParsingError(_) => write!(f, "failed to parse plugin data"), + Self::RegexError(_) => write!(f, "failed while using a regex"), + } + } +} + +impl std::error::Error for LoadPluginError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::InvalidFilename(_) => None, + Self::IoError(e) => Some(e), + Self::ParsingError(e) => Some(e), + Self::RegexError(e) => Some(e), + } + } +} + +impl From for LoadPluginError { + fn from(value: std::io::Error) -> Self { + LoadPluginError::IoError(value) + } +} + +impl From for LoadPluginError { + fn from(value: esplugin::Error) -> Self { + LoadPluginError::ParsingError(value) + } +} + +impl From> for LoadPluginError { + fn from(value: Box) -> Self { + LoadPluginError::RegexError(value) + } +} + +#[derive(Debug)] +#[non_exhaustive] +pub(crate) enum InvalidFilenameReason { + Empty, + NonUnicode, + NonUnique, + UnsupportedFileExtension, +} + +impl std::fmt::Display for InvalidFilenameReason { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Empty => write!(f, "is empty"), + Self::NonUnicode => write!(f, "cannot be represented in UTF-8"), + Self::NonUnique => write!(f, "is not unique"), + Self::UnsupportedFileExtension => { + write!(f, "does not have a supported plugin file extension") + } + } + } +} + +/// Represents an error that occurred when validating plugins before loading them. +#[derive(Debug)] +pub(crate) struct PluginValidationError { + path: PathBuf, + reason: PluginValidationErrorReason, +} + +impl PluginValidationError { + pub(crate) fn new(path: PathBuf, reason: PluginValidationErrorReason) -> Self { + Self { path, reason } + } + + pub(crate) fn invalid(path: PathBuf, reason: InvalidFilenameReason) -> Self { + Self { + path, + reason: PluginValidationErrorReason::InvalidFilename(reason), + } + } +} + +impl std::fmt::Display for PluginValidationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.reason { + PluginValidationErrorReason::InvalidFilename(i) => write!( + f, + "the path \"{}\" has a filename that {}", + escape_ascii(&self.path), + i + ), + PluginValidationErrorReason::InvalidPluginHeader => write!( + f, + "the file at \"{}\" does not have a valid plugin header", + escape_ascii(&self.path) + ), + } + } +} + +impl std::error::Error for PluginValidationError {} + +#[derive(Debug)] +pub(crate) enum PluginValidationErrorReason { + InvalidFilename(InvalidFilenameReason), + InvalidPluginHeader, +} diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs new file mode 100644 index 00000000..515e7553 --- /dev/null +++ b/src/plugin/mod.rs @@ -0,0 +1,1363 @@ +pub mod error; + +use std::{ + collections::{BTreeMap, BTreeSet}, + fs::File, + hash::Hasher, + io::{BufRead, BufReader}, + path::{Path, PathBuf}, + sync::LazyLock, +}; + +use esplugin::ParseOptions; +use fancy_regex::{Error as RegexImplError, Regex}; + +use crate::{ + GameType, + archive::{assets_in_archives, do_assets_overlap, find_associated_archives}, + case_insensitive_regex, escape_ascii, + game::GameCache, + logging, + metadata::plugin_metadata::trim_dot_ghost, +}; +use error::{ + InvalidFilenameReason, LoadPluginError, PluginDataError, PluginValidationError, + PluginValidationErrorReason, +}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub(crate) enum LoadScope { + HeaderOnly, + WholePlugin, +} + +impl std::fmt::Display for LoadScope { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + LoadScope::HeaderOnly => write!(f, "plugin header"), + LoadScope::WholePlugin => write!(f, "whole plugin"), + } + } +} + +/// Represents a plugin file that has been loaded. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Plugin { + name: String, + data: Option, + game_type: GameType, + crc: Option, + version: Option, + tags: Box<[String]>, + archive_paths: Box<[PathBuf]>, + archive_assets: BTreeMap>, +} + +impl Plugin { + pub(crate) fn new( + game_type: GameType, + game_cache: &GameCache, + plugin_path: &Path, + load_scope: LoadScope, + ) -> Result { + let name = name_string(game_type, plugin_path)?; + + let (parse_options, crc) = if load_scope == LoadScope::HeaderOnly { + (ParseOptions::header_only(), None) + } else { + let crc = calculate_crc(plugin_path)?; + (ParseOptions::whole_plugin(), Some(crc)) + }; + + let mut version = None; + let mut tags = Box::default(); + let mut archive_paths = Box::default(); + let mut archive_assets = BTreeMap::new(); + let plugin = + if game_type != GameType::OpenMW || !has_ascii_extension(plugin_path, "omwscripts") { + let mut plugin = esplugin::Plugin::new(game_type.into(), plugin_path); + plugin.parse_file(parse_options)?; + + if let Some(description) = plugin.description()? { + tags = extract_bash_tags(&description).into_boxed_slice(); + version = extract_version(&description)?; + } + + archive_paths = + find_associated_archives(game_type, game_cache, plugin_path).into_boxed_slice(); + + if load_scope == LoadScope::WholePlugin { + archive_assets = assets_in_archives(&archive_paths); + } + + Some(plugin) + } else { + None + }; + + Ok(Self { + name, + data: plugin, + game_type, + crc, + version, + tags, + archive_paths, + archive_assets, + }) + } + + /// Get the plugin's filename. + /// + /// If the plugin was ghosted when it was loaded, this filename will be + /// without the .ghost suffix, unless the game is OpenMW, in which case + /// ghosted plugins are not supported. + pub fn name(&self) -> &str { + &self.name + } + + /// Get the value of the version field in the `HEDR` subrecord of the + /// plugin's `TES4` record. + /// + /// Returns `None` if the `TES4` record does not exist (e.g. for Morrowind + /// and OpenMW) or if the `HEDR` subrecord could not be found, of if the + /// version field's value was `NaN`. + pub fn header_version(&self) -> Option { + self.data + .as_ref() + .and_then(esplugin::Plugin::header_version) + } + + /// Get the plugin's version number from its description field. + /// + /// The description field may not contain a version number, or libloot may + /// be unable to detect it. The description field parsing may fail to + /// extract the version number correctly, though it functions correctly in + /// all known cases. + pub fn version(&self) -> Option<&str> { + self.version.as_deref() + } + + /// Get the plugin's masters. + pub fn masters(&self) -> Result, PluginDataError> { + self.data + .as_ref() + .map_or_else(|| Ok(Vec::new()), |p| p.masters().map_err(Into::into)) + } + + /// Get any Bash Tags found in the plugin's description field. + pub fn bash_tags(&self) -> &[String] { + &self.tags + } + + /// Get the plugin's CRC-32 checksum. + /// + /// This will be `None` if the plugin is not fully loaded. + pub fn crc(&self) -> Option { + self.crc + } + + /// Check if the plugin is a master plugin. + /// + /// What causes a plugin to be a master plugin varies by game, but is + /// usually indicated by the plugin having its master flag set and/or by its + /// file extension. However, OpenMW uses neither for determining plugins' + /// load order so all OpenMW plugins are treated as non-masters. + /// + /// The term "master" is potentially confusing: a plugin A may not be a + /// *master plugin*, but may still be a *master of* another plugin by being + /// listed as such in that plugin's header record. Master plugins are + /// sometimes referred to as *master files* or simply *masters*, while the + /// other meaning is always referenced in relation to another plugin. + pub fn is_master(&self) -> bool { + if matches!( + self.game_type, + GameType::OpenMW | GameType::OblivionRemastered + ) { + false + } else { + self.data + .as_ref() + .is_some_and(esplugin::Plugin::is_master_file) + } + } + + /// Check if the plugin is a light plugin. + pub fn is_light_plugin(&self) -> bool { + self.data + .as_ref() + .is_some_and(esplugin::Plugin::is_light_plugin) + } + + /// Check if the plugin is a medium plugin. + pub fn is_medium_plugin(&self) -> bool { + self.data + .as_ref() + .is_some_and(esplugin::Plugin::is_medium_plugin) + } + + /// Check if the plugin is an update plugin. + pub fn is_update_plugin(&self) -> bool { + self.data + .as_ref() + .is_some_and(esplugin::Plugin::is_update_plugin) + } + + /// Check if the plugin is a blueprint plugin. + pub fn is_blueprint_plugin(&self) -> bool { + self.data + .as_ref() + .is_some_and(esplugin::Plugin::is_blueprint_plugin) + } + + /// Check if the plugin is or would be valid as a light plugin. + pub fn is_valid_as_light_plugin(&self) -> Result { + self.data.as_ref().map_or(Ok(false), |p| { + p.is_valid_as_light_plugin().map_err(Into::into) + }) + } + + /// Check if the plugin is or would be valid as a medium plugin. + pub fn is_valid_as_medium_plugin(&self) -> Result { + self.data.as_ref().map_or(Ok(false), |p| { + p.is_valid_as_medium_plugin().map_err(Into::into) + }) + } + + /// Check if the plugin is or would be valid as an update plugin. + pub fn is_valid_as_update_plugin(&self) -> Result { + self.data.as_ref().map_or(Ok(false), |p| { + p.is_valid_as_update_plugin().map_err(Into::into) + }) + } + + /// Check if the plugin contains any records other than its `TES3`/`TES4` + /// header. + pub fn is_empty(&self) -> bool { + self.data + .as_ref() + .and_then(esplugin::Plugin::record_and_group_count) + .unwrap_or(0) + == 0 + } + + /// Check if the plugin loads an archive (BSA/BA2 depending on the game). + pub fn loads_archive(&self) -> bool { + !self.archive_paths.is_empty() + } + + /// Check if two plugins contain a record with the same ID. + /// + /// FormIDs are compared for all games apart from Morrowind, which doesn't + /// have FormIDs and so has other identifying data compared. + pub fn do_records_overlap(&self, plugin: &Plugin) -> Result { + if let (Some(plugin), Some(other_plugin)) = (&self.data, &plugin.data) { + plugin.overlaps_with(other_plugin).map_err(Into::into) + } else { + Ok(false) + } + } + + pub(crate) fn override_record_count(&self) -> Result { + self.data + .as_ref() + .map_or(Ok(0), |p| p.count_override_records().map_err(Into::into)) + } + + pub(crate) fn asset_count(&self) -> usize { + self.archive_assets.values().fold(0, |acc, e| acc + e.len()) + } + + pub(crate) fn do_assets_overlap(&self, plugin: &Plugin) -> bool { + do_assets_overlap(&self.archive_assets, &plugin.archive_assets) + } + + pub(crate) fn resolve_record_ids( + &mut self, + plugins_metadata: &[esplugin::PluginMetadata], + ) -> Result<(), PluginDataError> { + if let Some(plugin) = &mut self.data { + plugin.resolve_record_ids(plugins_metadata)?; + } + Ok(()) + } +} + +pub(crate) fn validate_plugin_path_and_header( + game_type: GameType, + plugin_path: &Path, +) -> Result<(), PluginValidationError> { + if game_type == GameType::OpenMW && has_ascii_extension(plugin_path, "omwscripts") { + Ok(()) + } else if !has_plugin_file_extension(game_type, plugin_path) { + logging::debug!( + "The file \"{}\" is not a valid plugin", + escape_ascii(plugin_path) + ); + Err(PluginValidationError::invalid( + plugin_path.into(), + InvalidFilenameReason::UnsupportedFileExtension, + )) + } else if esplugin::Plugin::is_valid(game_type.into(), plugin_path, ParseOptions::header_only()) + { + Ok(()) + } else { + logging::debug!( + "The file \"{}\" is not a valid plugin", + escape_ascii(plugin_path) + ); + Err(PluginValidationError::new( + plugin_path.into(), + PluginValidationErrorReason::InvalidPluginHeader, + )) + } +} + +fn has_plugin_file_extension(game_type: GameType, plugin_path: &Path) -> bool { + let extension = if game_type != GameType::OpenMW && has_ascii_extension(plugin_path, "ghost") { + plugin_path + .file_stem() + .and_then(|s| Path::new(s).extension()) + } else { + plugin_path.extension() + }; + + if let Some(extension) = extension { + if extension.eq_ignore_ascii_case("esp") + || extension.eq_ignore_ascii_case("esm") + || (game_type == GameType::OpenMW + && (extension.eq_ignore_ascii_case("omwaddon") + || extension.eq_ignore_ascii_case("omwgame") + || extension.eq_ignore_ascii_case("omwscripts"))) + { + true + } else { + matches!( + game_type, + GameType::Fallout4 + | GameType::Fallout4VR + | GameType::SkyrimSE + | GameType::SkyrimVR + | GameType::Starfield + ) && extension.eq_ignore_ascii_case("esl") + } + } else { + false + } +} + +pub(crate) fn has_ascii_extension(path: &Path, extension: &str) -> bool { + path.extension() + .is_some_and(|e| e.eq_ignore_ascii_case(extension)) +} + +pub(crate) fn plugins_metadata( + plugins: &[&Plugin], +) -> Result, PluginDataError> { + let esplugins: Vec<_> = plugins.iter().filter_map(|p| p.data.as_ref()).collect(); + Ok(esplugin::plugins_metadata(&esplugins)?) +} + +fn name_string(game_type: GameType, path: &Path) -> Result { + match path.file_name() { + Some(f) => match f.to_str() { + Some(f) if game_type == GameType::OpenMW => Ok(f.to_owned()), + Some(f) => Ok(trim_dot_ghost(f).to_owned()), + None => Err(LoadPluginError::InvalidFilename( + InvalidFilenameReason::NonUnicode, + )), + }, + None => Err(LoadPluginError::InvalidFilename( + InvalidFilenameReason::Empty, + )), + } +} + +fn calculate_crc(path: &Path) -> std::io::Result { + let file = File::open(path)?; + let mut reader = BufReader::new(file); + let mut hasher = crc32fast::Hasher::new(); + + let mut buffer = reader.fill_buf()?; + while !buffer.is_empty() { + hasher.write(buffer); + let length = buffer.len(); + reader.consume(length); + + buffer = reader.fill_buf()?; + } + + Ok(hasher.finalize()) +} + +fn extract_bash_tags(description: &str) -> Vec { + if let Some((_, bash_tags)) = description.split_once("{{BASH:") { + if let Some((bash_tags, _)) = bash_tags.split_once("}}") { + return bash_tags.split(',').map(|s| s.trim().to_owned()).collect(); + } + } + Vec::new() +} + +fn extract_version(description: &str) -> Result, Box> { + #[expect( + clippy::expect_used, + reason = "Only panics if a hardcoded regex string is invalid" + )] + static VERSION_REGEXES: LazyLock> = LazyLock::new(|| { + // The string below matches the range of version strings supported by + // Pseudosem v1.0.1, excluding space separators, as they make version + // extraction from inside sentences very tricky and have not been seen "in + // the wild". The last non-capturing group prevents version numbers + // followed by a comma from matching. + let pseudosem_regex_str = r"(\d+(?:\.\d+)+(?:[-._:]?[A-Za-z0-9]+)*)(?:[^,]|$)"; + + /* There are a few different version formats that can appear in strings + together, and in order to extract the correct one, they must be searched + for in order of priority. */ + Box::new([ + /* The string below matches timestamps that use forwardslashes for date + separators. However, Pseudosem v1.0.1 will only compare the first + two digits as it does not recognise forwardslashes as separators. */ + case_insensitive_regex(r"(\d{1,2}/\d{1,2}/\d{1,4} \d{1,2}:\d{1,2}:\d{1,2})") + .expect("Hardcoded version timestamp regex should be valid"), + case_insensitive_regex(&format!(r"version:?\s{pseudosem_regex_str}")) + .expect("Hardcoded version-prefixed pseudosem version regex should be valid"), + case_insensitive_regex(&format!(r"(?:^|v|\s){pseudosem_regex_str}")) + .expect("Hardcoded pseudosem version regex should be valid"), + /* The string below matches a number containing one or more + digits found at the start of the search string or preceded by + 'v' or 'version:. */ + case_insensitive_regex(r"(?:^|v|version:\s*)(\d+)") + .expect("Hardcoded prefixed version number regex should be valid"), + ]) + }); + + for regex in &*VERSION_REGEXES { + let version = find_captured_text(regex, description)?; + + if version.is_some() { + return Ok(version); + } + } + + Ok(None) +} + +fn find_captured_text(regex: &Regex, text: &str) -> Result, Box> { + let captured_text = regex + .captures(text)? + .iter() + .flat_map(|captures| captures.iter()) + .flatten() + .skip(1) // Skip the first capture as that's the whole regex. + .map(|m| m.as_str().trim()) + .find(|v| !v.is_empty()) + .map(str::to_owned); + + Ok(captured_text) +} + +#[cfg(test)] +mod tests { + use super::*; + + use crate::tests::ALL_GAME_TYPES; + use parameterized_test::parameterized_test; + + mod plugin { + use std::io::Seek; + use std::io::Write; + + use tempfile::tempdir; + + use crate::tests::{ + BLANK_ESL, BLANK_ESM, BLANK_ESP, BLANK_FULL_ESM, BLANK_MASTER_DEPENDENT_ESM, + BLANK_MASTER_DEPENDENT_ESP, BLANK_MEDIUM_ESM, BLANK_OVERRIDE_ESP, NON_ASCII_ESM, + source_plugins_path, + }; + + use super::*; + + fn blank_esm(game_type: GameType) -> &'static str { + if game_type == GameType::Starfield { + BLANK_FULL_ESM + } else { + BLANK_ESM + } + } + + fn blank_master_dependent_esm(game_type: GameType) -> &'static str { + if game_type == GameType::Starfield { + "Blank - Override.full.esm" + } else { + BLANK_MASTER_DEPENDENT_ESM + } + } + + #[parameterized_test(ALL_GAME_TYPES)] + fn new_should_trim_ghost_extension_unless_game_is_openmw(game_type: GameType) { + let tmp_dir = tempdir().unwrap(); + let source_path = source_plugins_path(game_type).join(BLANK_ESP); + let ghosted_path = tmp_dir.path().join(BLANK_ESP.to_owned() + ".ghost"); + + std::fs::copy(source_path, &ghosted_path).unwrap(); + + let plugin = Plugin::new( + game_type, + &GameCache::default(), + &ghosted_path, + LoadScope::HeaderOnly, + ) + .unwrap(); + + if game_type == GameType::OpenMW { + assert_eq!(BLANK_ESP.to_owned() + ".ghost", plugin.name()); + } else { + assert_eq!(BLANK_ESP, plugin.name()); + } + } + + #[test] + fn new_should_handle_non_ascii_filenames_correctly() { + let tmp_dir = tempdir().unwrap(); + let source_path = source_plugins_path(GameType::Oblivion).join(BLANK_ESM); + let path = tmp_dir.path().join(NON_ASCII_ESM); + + std::fs::copy(source_path, &path).unwrap(); + } + + #[parameterized_test(ALL_GAME_TYPES)] + fn new_with_header_only_scope_should_read_header_data_only(game_type: GameType) { + let plugin_name = blank_master_dependent_esm(game_type); + let path = source_plugins_path(game_type).join(plugin_name); + + let plugin = Plugin::new( + game_type, + &GameCache::default(), + &path, + LoadScope::HeaderOnly, + ) + .unwrap(); + + let expected_masters = vec![blank_esm(game_type)]; + + assert_eq!(plugin_name, plugin.name()); + assert_eq!(expected_masters, plugin.masters().unwrap()); + if matches!(game_type, GameType::OpenMW | GameType::OblivionRemastered) { + assert!(!plugin.is_master()); + } else { + assert!(plugin.is_master()); + } + assert!(!plugin.is_empty()); + assert!(plugin.version().is_none()); + + #[expect(clippy::float_cmp, reason = "float values should be exactly equal")] + match game_type { + GameType::Morrowind | GameType::OpenMW => { + assert_eq!(1.2, plugin.header_version().unwrap()); + } + GameType::Oblivion | GameType::OblivionRemastered => { + assert_eq!(0.8, plugin.header_version().unwrap()); + } + GameType::Starfield => assert_eq!(0.96, plugin.header_version().unwrap()), + _ => assert_eq!(0.94, plugin.header_version().unwrap()), + } + + assert!(plugin.crc().is_none()); + assert!(!plugin.do_assets_overlap(&plugin)); + assert_eq!(0, plugin.asset_count()); + assert!(!plugin.do_records_overlap(&plugin).unwrap()); + assert_eq!(0, plugin.override_record_count().unwrap()); + } + + #[parameterized_test(ALL_GAME_TYPES)] + fn new_with_header_only_scope_should_read_version_from_header_description( + game_type: GameType, + ) { + let path = source_plugins_path(game_type).join(blank_esm(game_type)); + + let plugin = Plugin::new( + game_type, + &GameCache::default(), + &path, + LoadScope::HeaderOnly, + ) + .unwrap(); + + assert_eq!("5.0", plugin.version().unwrap()); + } + + #[parameterized_test(ALL_GAME_TYPES)] + fn new_with_header_only_scope_should_not_read_assets(game_type: GameType) { + let path = source_plugins_path(game_type).join(blank_esm(game_type)); + + let plugin = Plugin::new( + game_type, + &GameCache::default(), + &path, + LoadScope::HeaderOnly, + ) + .unwrap(); + + assert!(!plugin.do_assets_overlap(&plugin)); + assert_eq!(0, plugin.asset_count()); + } + + #[parameterized_test(ALL_GAME_TYPES)] + fn new_with_whole_plugin_scope_should_read_records(game_type: GameType) { + let plugin_name = blank_master_dependent_esm(game_type); + let path = source_plugins_path(game_type).join(plugin_name); + + let mut plugin = Plugin::new( + game_type, + &GameCache::default(), + &path, + LoadScope::WholePlugin, + ) + .unwrap(); + + let expected_masters = vec![blank_esm(game_type)]; + + assert_eq!(plugin_name, plugin.name()); + assert_eq!(expected_masters, plugin.masters().unwrap()); + if matches!(game_type, GameType::OpenMW | GameType::OblivionRemastered) { + assert!(!plugin.is_master()); + } else { + assert!(plugin.is_master()); + } + assert!(!plugin.is_empty()); + assert!(plugin.version().is_none()); + + #[expect(clippy::float_cmp, reason = "float values should be exactly equal")] + match game_type { + GameType::Morrowind | GameType::OpenMW => { + assert_eq!(1.2, plugin.header_version().unwrap()); + } + GameType::Oblivion | GameType::OblivionRemastered => { + assert_eq!(0.8, plugin.header_version().unwrap()); + } + GameType::Starfield => assert_eq!(0.96, plugin.header_version().unwrap()), + _ => assert_eq!(0.94, plugin.header_version().unwrap()), + } + + let expected_crc = match game_type { + GameType::Morrowind | GameType::OpenMW => 3_317_676_987, + GameType::Starfield => 1_422_425_298, + GameType::Oblivion | GameType::OblivionRemastered => 3_759_349_588, + _ => 3_000_242_590, + }; + + assert_eq!(expected_crc, plugin.crc().unwrap()); + assert!(!plugin.do_assets_overlap(&plugin)); + assert_eq!(0, plugin.asset_count()); + + if matches!(game_type, GameType::Morrowind | GameType::OpenMW) { + let master = Plugin::new( + game_type, + &GameCache::default(), + &source_plugins_path(game_type).join(BLANK_ESM), + LoadScope::WholePlugin, + ) + .unwrap(); + + let metadata = plugins_metadata(&[&master]).unwrap(); + + plugin.resolve_record_ids(&metadata).unwrap(); + + assert_eq!(4, plugin.override_record_count().unwrap()); + } else if game_type == GameType::Starfield { + let master = Plugin::new( + game_type, + &GameCache::default(), + &source_plugins_path(game_type).join(BLANK_FULL_ESM), + LoadScope::WholePlugin, + ) + .unwrap(); + + let metadata = plugins_metadata(&[&master]).unwrap(); + + plugin.resolve_record_ids(&metadata).unwrap(); + + assert_eq!(1, plugin.override_record_count().unwrap()); + } else { + assert_eq!(4, plugin.override_record_count().unwrap()); + } + assert!(plugin.do_records_overlap(&plugin).unwrap()); + } + + #[parameterized_test(ALL_GAME_TYPES)] + fn new_with_whole_plugin_scope_should_read_assets(game_type: GameType) { + let data_path = source_plugins_path(game_type); + let path = data_path.join(BLANK_ESP); + + let mut cache = GameCache::default(); + cache.set_archive_paths(vec![ + data_path.join("Blank.bsa"), + data_path.join("Blank - Main.ba2"), + ]); + + let plugin = Plugin::new(game_type, &cache, &path, LoadScope::WholePlugin).unwrap(); + + if matches!( + game_type, + GameType::Morrowind | GameType::OpenMW | GameType::Starfield + ) { + // The Starfield test data doesn't include a BA2 file. + assert!(!plugin.loads_archive()); + assert_eq!(0, plugin.asset_count()); + assert!(!plugin.do_assets_overlap(&plugin)); + } else { + assert!(plugin.loads_archive()); + assert_eq!(1, plugin.asset_count()); + assert!(plugin.do_assets_overlap(&plugin)); + } + } + + #[parameterized_test(ALL_GAME_TYPES)] + fn new_with_whole_plugin_scope_should_succeed_for_openmw_plugins(game_type: GameType) { + let tmp_dir = tempdir().unwrap(); + + let data_path = source_plugins_path(game_type); + let omwgame = tmp_dir.path().join("Blank.omwgame"); + let omwaddon = tmp_dir.path().join("Blank.omwaddon"); + let omwscripts = tmp_dir.path().join("Blank.omwscripts"); + + std::fs::copy(data_path.join(blank_esm(game_type)), &omwgame).unwrap(); + std::fs::copy(data_path.join(BLANK_ESP), &omwaddon).unwrap(); + File::create(&omwscripts).unwrap(); + + assert!( + Plugin::new( + game_type, + &GameCache::default(), + &omwgame, + LoadScope::WholePlugin + ) + .is_ok() + ); + assert!( + Plugin::new( + game_type, + &GameCache::default(), + &omwaddon, + LoadScope::WholePlugin + ) + .is_ok() + ); + + assert_eq!( + game_type == GameType::OpenMW, + Plugin::new( + game_type, + &GameCache::default(), + &omwscripts, + LoadScope::WholePlugin + ) + .is_ok() + ); + } + + #[test] + fn new_should_error_if_plugin_does_not_exist() { + let path = Path::new("missing.esp"); + assert!(!path.exists()); + + assert!( + Plugin::new( + GameType::Oblivion, + &GameCache::default(), + path, + LoadScope::HeaderOnly + ) + .is_err() + ); + } + + #[parameterized_test(ALL_GAME_TYPES)] + fn is_master_should_be_false_for_a_non_master_plugin(game_type: GameType) { + let path = source_plugins_path(game_type).join(BLANK_ESP); + let plugin = Plugin::new( + game_type, + &GameCache::default(), + &path, + LoadScope::HeaderOnly, + ) + .unwrap(); + + assert!(!plugin.is_master()); + } + + #[parameterized_test(ALL_GAME_TYPES)] + fn is_light_plugin_should_be_true_for_a_plugin_with_esl_extension_for_fo4_and_later( + game_type: GameType, + ) { + let tmp_dir = tempdir().unwrap(); + let data_path = source_plugins_path(game_type); + + let light_path = tmp_dir.path().join(BLANK_ESL); + std::fs::copy(data_path.join(BLANK_ESP), &light_path).unwrap(); + + let master = Plugin::new( + game_type, + &GameCache::default(), + &data_path.join(blank_esm(game_type)), + LoadScope::HeaderOnly, + ) + .unwrap(); + let plugin = Plugin::new( + game_type, + &GameCache::default(), + &data_path.join(BLANK_ESP), + LoadScope::HeaderOnly, + ) + .unwrap(); + let light = Plugin::new( + game_type, + &GameCache::default(), + &light_path, + LoadScope::HeaderOnly, + ) + .unwrap(); + + assert!(!master.is_light_plugin()); + assert!(!plugin.is_light_plugin()); + + if matches!( + game_type, + GameType::Fallout4 + | GameType::Fallout4VR + | GameType::SkyrimSE + | GameType::SkyrimVR + | GameType::Starfield + ) { + assert!(light.is_light_plugin()); + } else { + assert!(!light.is_light_plugin()); + } + } + + #[parameterized_test(ALL_GAME_TYPES)] + fn is_medium_plugin_should_be_true_for_a_medium_flagged_plugin_for_starfield( + game_type: GameType, + ) { + let tmp_dir = tempdir().unwrap(); + + let data_path = source_plugins_path(game_type); + let path = tmp_dir.path().join(BLANK_MEDIUM_ESM); + if game_type == GameType::Starfield { + std::fs::copy(data_path.join(BLANK_MEDIUM_ESM), &path).unwrap(); + } else { + std::fs::copy(data_path.join(BLANK_ESM), &path).unwrap(); + + let mut file = std::fs::File::options().write(true).open(&path).unwrap(); + file.seek(std::io::SeekFrom::Start(9)).unwrap(); + file.write_all(&[0x4]).unwrap(); + } + + let master = Plugin::new( + game_type, + &GameCache::default(), + &data_path.join(blank_esm(game_type)), + LoadScope::HeaderOnly, + ) + .unwrap(); + let plugin = Plugin::new( + game_type, + &GameCache::default(), + &path, + LoadScope::HeaderOnly, + ) + .unwrap(); + + assert!(!master.is_medium_plugin()); + assert_eq!(game_type == GameType::Starfield, plugin.is_medium_plugin()); + } + + #[parameterized_test(ALL_GAME_TYPES)] + fn is_update_plugin_should_be_true_for_an_update_plugin_for_starfield(game_type: GameType) { + let tmp_dir = tempdir().unwrap(); + + let source_name = if game_type == GameType::Starfield { + BLANK_OVERRIDE_ESP + } else { + BLANK_MASTER_DEPENDENT_ESP + }; + let data_path = source_plugins_path(game_type); + let path = tmp_dir.path().join("Blank - Update.esp"); + std::fs::copy(data_path.join(source_name), &path).unwrap(); + + let mut file = std::fs::File::options().write(true).open(&path).unwrap(); + file.seek(std::io::SeekFrom::Start(9)).unwrap(); + file.write_all(&[0x2]).unwrap(); + + let plugin = Plugin::new( + game_type, + &GameCache::default(), + &source_plugins_path(game_type).join(BLANK_ESP), + LoadScope::HeaderOnly, + ) + .unwrap(); + let update = Plugin::new( + game_type, + &GameCache::default(), + &path, + LoadScope::HeaderOnly, + ) + .unwrap(); + + assert!(!plugin.is_update_plugin()); + assert_eq!(game_type == GameType::Starfield, update.is_update_plugin()); + } + + #[parameterized_test(ALL_GAME_TYPES)] + fn is_blueprint_plugin_should_be_true_for_a_blueprint_plugin_for_starfield( + game_type: GameType, + ) { + let blueprint_plugin_name = if game_type == GameType::Starfield { + BLANK_OVERRIDE_ESP + } else { + BLANK_MASTER_DEPENDENT_ESP + }; + let data_path = source_plugins_path(game_type); + + let plugin = Plugin::new( + game_type, + &GameCache::default(), + &source_plugins_path(game_type).join(BLANK_ESP), + LoadScope::HeaderOnly, + ) + .unwrap(); + let update = Plugin::new( + game_type, + &GameCache::default(), + &data_path.join(blueprint_plugin_name), + LoadScope::HeaderOnly, + ) + .unwrap(); + + assert!(!plugin.is_update_plugin()); + assert_eq!(game_type == GameType::Starfield, update.is_update_plugin()); + } + + #[parameterized_test(ALL_GAME_TYPES)] + fn is_valid_as_light_plugin_should_be_true_only_for_a_skyrim_fallout4_or_starfield_plugin_with_new_formids_in_the_valid_range( + game_type: GameType, + ) { + let path = source_plugins_path(game_type).join(BLANK_ESP); + let mut plugin = Plugin::new( + game_type, + &GameCache::default(), + &path, + LoadScope::WholePlugin, + ) + .unwrap(); + + if game_type == GameType::Starfield { + let master = Plugin::new( + game_type, + &GameCache::default(), + &source_plugins_path(game_type).join(BLANK_FULL_ESM), + LoadScope::WholePlugin, + ) + .unwrap(); + + let metadata = plugins_metadata(&[&master]).unwrap(); + + plugin.resolve_record_ids(&metadata).unwrap(); + } + + let result = plugin.is_valid_as_light_plugin().unwrap(); + + if matches!( + game_type, + GameType::Fallout4 + | GameType::Fallout4VR + | GameType::SkyrimSE + | GameType::SkyrimVR + | GameType::Starfield + ) { + assert!(result); + } else { + assert!(!result); + } + } + + #[parameterized_test(ALL_GAME_TYPES)] + fn is_valid_as_medium_plugin_should_be_true_only_for_a_starfield_plugin_with_new_formids_in_the_valid_range( + game_type: GameType, + ) { + let path = source_plugins_path(game_type).join(BLANK_ESP); + let mut plugin = Plugin::new( + game_type, + &GameCache::default(), + &path, + LoadScope::WholePlugin, + ) + .unwrap(); + + if game_type == GameType::Starfield { + let master = Plugin::new( + game_type, + &GameCache::default(), + &source_plugins_path(game_type).join(BLANK_FULL_ESM), + LoadScope::WholePlugin, + ) + .unwrap(); + + let metadata = plugins_metadata(&[&master]).unwrap(); + + plugin.resolve_record_ids(&metadata).unwrap(); + } + + let result = plugin.is_valid_as_medium_plugin().unwrap(); + + if game_type == GameType::Starfield { + assert!(result); + } else { + assert!(!result); + } + } + + #[parameterized_test(ALL_GAME_TYPES)] + fn is_valid_as_update_plugin_should_be_true_only_for_a_starfield_plugin_with_no_new_records( + game_type: GameType, + ) { + let plugin_name = blank_master_dependent_esm(game_type); + let path = source_plugins_path(game_type).join(plugin_name); + let mut plugin = Plugin::new( + game_type, + &GameCache::default(), + &path, + LoadScope::WholePlugin, + ) + .unwrap(); + + if game_type == GameType::Starfield { + let master = Plugin::new( + game_type, + &GameCache::default(), + &source_plugins_path(game_type).join(BLANK_FULL_ESM), + LoadScope::WholePlugin, + ) + .unwrap(); + + let metadata = plugins_metadata(&[&master]).unwrap(); + + plugin.resolve_record_ids(&metadata).unwrap(); + } + + let result = plugin.is_valid_as_update_plugin().unwrap(); + + if game_type == GameType::Starfield { + assert!(result); + } else { + assert!(!result); + } + } + } + + mod has_plugin_file_extension { + use super::*; + + #[parameterized_test(ALL_GAME_TYPES)] + fn should_be_true_if_file_ends_in_dot_esp_or_dot_esm(game_type: GameType) { + assert!(has_plugin_file_extension(game_type, Path::new("file.esp"))); + assert!(has_plugin_file_extension(game_type, Path::new("file.esm"))); + assert!(!has_plugin_file_extension(game_type, Path::new("file.bsa"))); + } + + #[parameterized_test(ALL_GAME_TYPES)] + fn should_be_true_if_file_ends_in_dot_esl_and_game_is_fo4_or_later(game_type: GameType) { + let result = has_plugin_file_extension(game_type, Path::new("file.esl")); + if matches!( + game_type, + GameType::Fallout4 + | GameType::SkyrimSE + | GameType::Fallout4VR + | GameType::SkyrimVR + | GameType::Starfield + ) { + assert!(result); + } else { + assert!(!result); + } + } + + #[parameterized_test(ALL_GAME_TYPES)] + fn should_trim_ghost_extension_unless_game_is_openmw(game_type: GameType) { + if game_type == GameType::OpenMW { + assert!(!has_plugin_file_extension( + game_type, + Path::new("file.esp.ghost") + )); + assert!(!has_plugin_file_extension( + game_type, + Path::new("file.esm.ghost") + )); + } else { + assert!(has_plugin_file_extension( + game_type, + Path::new("file.esp.ghost") + )); + assert!(has_plugin_file_extension( + game_type, + Path::new("file.esm.ghost") + )); + } + assert!(!has_plugin_file_extension( + game_type, + Path::new("file.bsa.ghost") + )); + } + + #[parameterized_test(ALL_GAME_TYPES)] + fn should_recognise_openmw_plugin_extensions(game_type: GameType) { + if game_type == GameType::OpenMW { + assert!(has_plugin_file_extension( + game_type, + Path::new("file.omwgame") + )); + assert!(has_plugin_file_extension( + game_type, + Path::new("file.omwaddon") + )); + assert!(has_plugin_file_extension( + game_type, + Path::new("file.omwscripts") + )); + } else { + assert!(!has_plugin_file_extension( + game_type, + Path::new("file.omwgame") + )); + assert!(!has_plugin_file_extension( + game_type, + Path::new("file.omwaddon") + )); + assert!(!has_plugin_file_extension( + game_type, + Path::new("file.omwscripts") + )); + } + } + } + + #[test] + fn extract_bash_tags_should_extract_tags_from_plugin_description_text() { + let text = "Unofficial Skyrim Special Edition Patch + +A comprehensive bugfixing mod for The Elder Scrolls V: Skyrim - Special Edition + +Version: 4.1.4 + +Requires Skyrim Special Edition 1.5.39 or greater. + +{{BASH:C.Climate,C.Encounter,C.ImageSpace,C.Light,C.Location,C.Music,C.Name,C.Owner,C.Water,Delev,Graphics,Invent,Names,Relev,Sound,Stats}}"; + + let tags = extract_bash_tags(text); + + assert_eq!( + vec![ + "C.Climate".to_owned(), + "C.Encounter".into(), + "C.ImageSpace".into(), + "C.Light".into(), + "C.Location".into(), + "C.Music".into(), + "C.Name".into(), + "C.Owner".into(), + "C.Water".into(), + "Delev".into(), + "Graphics".into(), + "Invent".into(), + "Names".into(), + "Relev".into(), + "Sound".into(), + "Stats".into(), + ], + tags + ); + } + + mod extract_version { + use crate::plugin::extract_version; + + #[test] + fn should_extract_a_version_containing_a_single_digit() { + assert_eq!("5", extract_version("5").unwrap().unwrap()); + } + + #[test] + fn should_extract_a_version_containing_multiple_digits() { + assert_eq!("10", extract_version("10").unwrap().unwrap()); + } + + #[test] + fn should_extract_a_version_containing_multiple_numbers() { + assert_eq!( + "10.11.12.13", + extract_version("10.11.12.13").unwrap().unwrap() + ); + } + + #[test] + fn should_extract_a_semantic_version() { + assert_eq!( + "1.0.0-x.7.z.92", + extract_version("1.0.0-x.7.z.92+exp.sha.5114f85") + .unwrap() + .unwrap() + ); + } + + #[test] + fn should_extract_a_pseudosem_extended_version_stopping_at_the_first_space_separator() { + assert_eq!( + "01.0.0_alpha:1-2", + extract_version("01.0.0_alpha:1-2 3").unwrap().unwrap() + ); + } + + #[test] + fn should_extract_a_version_substring() { + assert_eq!("5.0", extract_version("v5.0").unwrap().unwrap()); + } + + #[test] + fn should_return_none_if_the_string_contains_no_version() { + assert!( + extract_version("The quick brown fox jumped over the lazy dog.") + .unwrap() + .is_none() + ); + } + + #[test] + fn should_extract_a_timestamp_with_forwardslash_date_separators() { + // Found in a Bashed Patch. Though the timestamp isn't useful to + // LOOT, it is semantically a version, and extracting it is far + // easier than trying to skip it and the number of records changed. + assert_eq!( + "10/09/2016 13:15:18", + extract_version("Updated: 10/09/2016 13:15:18\r\n\r\nRecords Changed: 43") + .unwrap() + .unwrap() + ); + } + + #[test] + fn should_not_extract_trailing_periods() { + // Found in . + assert_eq!("0.2", extract_version("Version 0.2.").unwrap().unwrap()); + } + + #[test] + fn should_extract_a_version_following_text_and_a_version_colon_string() { + // Found in . + assert_eq!( + "3.0.0", + extract_version("Legendary Edition\r\n\r\nVersion: 3.0.0") + .unwrap() + .unwrap() + ); + } + + #[test] + fn should_ignore_numbers_containing_commas() { + // Found in . + assert_eq!( + "3.5.3", + extract_version("fixing over 2,300 bugs so far! Version: 3.5.3") + .unwrap() + .unwrap() + ); + } + + #[test] + fn should_extract_a_version_before_text() { + // Found in . + assert_eq!( + "2.1", + extract_version("Version: 2.1 The Unofficial Fallout 3 Patch") + .unwrap() + .unwrap() + ); + } + + #[test] + fn should_extract_a_version_with_a_preceding_v() { + // Found in . + assert_eq!( + "2.11", + extract_version("V2.11\r\n\r\n{{BASH:Invent}}") + .unwrap() + .unwrap() + ); + } + + #[test] + fn should_extract_a_version_preceded_by_colon_period_whitespace() { + // Found in . + assert_eq!("1.09", extract_version("Version:. 1.09").unwrap().unwrap()); + } + + #[test] + fn should_extract_a_version_with_letters_immediately_after_numbers() { + // Found in . + assert_eq!("2.1.3b", extract_version("comprehensive bugfixing mod for The Elder Scrolls V: Skyrim\r\n\r\nVersion: 2.1.3b\r\n\r\n").unwrap().unwrap()); + } + + #[test] + fn should_extract_a_version_with_period_and_no_preceding_identifier() { + // Found in . + assert_eq!("5.1", extract_version("SkyUI 5.1").unwrap().unwrap()); + } + + #[test] + fn should_not_extract_a_single_digit_in_a_sentence() { + // Found in . + assert!( + extract_version( + "Adds 8 variants of Triss Merigold's outfit from \"The Witcher 2\"" + ) + .unwrap() + .is_none() + ); + } + + #[test] + fn should_prefer_version_prefixed_numbers_over_versions_in_sentence() { + // Found in + assert_eq!("2.0.0", extract_version("Requires Skyrim patch 1.9.32.0.8 or greater.\nRequires Unofficial Skyrim Legendary Edition Patch 3.0.0 or greater.\nVersion 2.0.0").unwrap().unwrap()); + } + + #[test] + fn should_extract_a_version_that_is_a_single_digit_preceded_by_v() { + // Found in + assert_eq!( + "8", + extract_version("Immersive Armors v8 Main Plugin") + .unwrap() + .unwrap() + ); + } + + #[test] + fn should_prefer_version_prefixed_numbers_over_v_prefixed_number() { + // Found in + assert_eq!("1.0", extract_version("Compatibility patch for AOS v2.5 and True Storms v1.5 (or later),\nPatch Version: 1.0").unwrap().unwrap()); + } + + #[test] + fn should_extract_a_version_that_is_a_single_digit_after_version_colon_space() { + // Found in + assert_eq!( + "2", + extract_version("Version: 2 {{BASH:C.Water}}") + .unwrap() + .unwrap() + ); + } + } +} diff --git a/src/sorting/dfs.rs b/src/sorting/dfs.rs new file mode 100644 index 00000000..0484756c --- /dev/null +++ b/src/sorting/dfs.rs @@ -0,0 +1,229 @@ +use std::collections::VecDeque; + +use petgraph::{ + Graph, + graph::{EdgeReference, NodeIndex}, + visit::EdgeRef, +}; +use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; + +use crate::{EdgeType, Vertex, logging}; + +pub trait DfsVisitor<'a> { + fn visit_tree_edge(&mut self, edge_ref: EdgeReference<'a, EdgeType>); + + fn visit_forward_or_cross_edge(&mut self, edge_ref: EdgeReference<'a, EdgeType>); + + fn visit_back_edge(&mut self, edge_ref: EdgeReference<'a, EdgeType>); + + fn discover_node(&mut self, node_index: NodeIndex); + + fn finish_node(&mut self, node_index: NodeIndex); +} + +pub trait BidirBfsVisitor { + fn visit_forward_bfs_edge(&mut self, source: NodeIndex, target: NodeIndex); + + fn visit_reverse_bfs_edge(&mut self, source: NodeIndex, target: NodeIndex); + + fn visit_intersection_node(&mut self, node: NodeIndex); +} + +pub fn bidirectional_bfs( + graph: &Graph, + from_index: NodeIndex, + to_index: NodeIndex, + visitor: &mut impl BidirBfsVisitor, +) -> bool { + let mut forward_queue = VecDeque::from([from_index]); + let mut reverse_queue = VecDeque::from([to_index]); + let mut forward_visited = HashSet::default(); + forward_visited.insert(from_index); + let mut reverse_visited = HashSet::default(); + reverse_visited.insert(to_index); + + while let (Some(forward_current), Some(reverse_current)) = + (forward_queue.pop_front(), reverse_queue.pop_front()) + { + if forward_current == to_index || reverse_visited.contains(&forward_current) { + visitor.visit_intersection_node(forward_current); + return true; + } + + for adjacent in graph.neighbors(forward_current) { + if !forward_visited.contains(&adjacent) { + visitor.visit_forward_bfs_edge(forward_current, adjacent); + + forward_visited.insert(adjacent); + forward_queue.push_back(adjacent); + } + } + + if reverse_current == from_index || forward_visited.contains(&reverse_current) { + visitor.visit_intersection_node(reverse_current); + return true; + } + + for adjacent in graph.neighbors_directed(reverse_current, petgraph::Direction::Incoming) { + if !reverse_visited.contains(&adjacent) { + visitor.visit_reverse_bfs_edge(adjacent, reverse_current); + + reverse_visited.insert(adjacent); + reverse_queue.push_back(adjacent); + } + } + } + + false +} + +// Petgraph has APIs for performing depth-first searches, but they don't give any information about the current edge, only its source and target nodes, which is a problem if the same pair of nodes can have multiple edges between them with different weights. As such, implement it myself. +pub fn find_cycle( + graph: &Graph, + node_mapper: impl FnMut(&N) -> String, +) -> Option> { + let mut cycle_detector = CycleDetector::new(graph, node_mapper); + + let mut colour_map = HashMap::default(); + + for node_index in graph.node_indices() { + depth_first_search(graph, &mut colour_map, node_index, &mut cycle_detector); + + if cycle_detector.found_cycle() { + return cycle_detector.into_cycle_path(); + } + } + + None +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[non_exhaustive] +pub enum Colour { + #[default] + White, + Grey, + Black, +} + +pub fn depth_first_search<'a, N>( + graph: &'a Graph, + colour_map: &mut HashMap, + start_node_index: NodeIndex, + visitor: &mut impl DfsVisitor<'a>, +) { + let mut stack = vec![(start_node_index, edges(graph, start_node_index))]; + + colour_map.insert(start_node_index, Colour::Grey); + visitor.discover_node(start_node_index); + + while let Some((current, unprocessed_edges)) = stack.last_mut() { + if let Some(edge) = unprocessed_edges.next() { + let target = edge.target(); + + match colour_map.get(&target).unwrap_or(&Colour::White) { + Colour::White => { + visitor.visit_tree_edge(edge); + + colour_map.insert(target, Colour::Grey); + visitor.discover_node(target); + + stack.push((target, edges(graph, target))); + } + Colour::Grey => visitor.visit_back_edge(edge), + Colour::Black => visitor.visit_forward_or_cross_edge(edge), + } + } else { + colour_map.insert(*current, Colour::Black); + visitor.finish_node(*current); + + stack.pop(); + } + } +} + +fn edges( + graph: &Graph, + node_index: NodeIndex, +) -> impl Iterator> { + // Petgraph produces edges in the reverse of the order that neighbouring + // nodes were added to the graph, but for backwards compatibility we want + // the opposite order. + // Unfortunately Petgraph's Edges iterator doesn't impl DoubleEndedIterator + // (though it probably could), so this needs to buffer the edges. + let mut edges: Vec<_> = graph.edges(node_index).collect(); + edges.reverse(); + edges.into_iter() +} + +#[derive(Clone, Debug)] +struct CycleDetector<'a, N, F: FnMut(&N) -> String> { + graph: &'a Graph, + get_node_name: F, + trail: Vec, + found_cycle: bool, +} + +impl<'a, N, F: FnMut(&N) -> String> CycleDetector<'a, N, F> { + fn new(graph: &'a Graph, get_node_name: F) -> Self { + CycleDetector { + graph, + get_node_name, + trail: Vec::new(), + found_cycle: false, + } + } + + fn found_cycle(&self) -> bool { + self.found_cycle + } + + fn into_cycle_path(self) -> Option> { + self.found_cycle.then_some(self.trail) + } +} + +impl<'a, N, F: FnMut(&N) -> String> DfsVisitor<'a> for CycleDetector<'a, N, F> { + fn visit_tree_edge(&mut self, edge_ref: EdgeReference<'a, EdgeType>) { + if self.found_cycle { + return; + } + + let source = edge_ref.source(); + let name = (self.get_node_name)(&self.graph[source]); + let edge_type = *edge_ref.weight(); + + let vertex = Vertex::new(name).with_out_edge_type(edge_type); + + self.trail.push(vertex); + } + + fn visit_forward_or_cross_edge(&mut self, _: EdgeReference<'a, EdgeType>) {} + + fn visit_back_edge(&mut self, edge_ref: EdgeReference<'a, EdgeType>) { + if self.found_cycle { + return; + } + + self.visit_tree_edge(edge_ref); + + let target_name = (self.get_node_name)(&self.graph[edge_ref.target()]); + + if let Some(pos) = self.trail.iter().position(|v| v.name() == target_name) { + self.trail.drain(..pos); + self.found_cycle = true; + } else { + logging::error!( + "The target of a back edge cannot be found in the current visitor trail" + ); + } + } + + fn discover_node(&mut self, _: NodeIndex) {} + + fn finish_node(&mut self, _: NodeIndex) { + if !self.found_cycle { + self.trail.pop(); + } + } +} diff --git a/src/sorting/error.rs b/src/sorting/error.rs new file mode 100644 index 00000000..4161eca8 --- /dev/null +++ b/src/sorting/error.rs @@ -0,0 +1,291 @@ +use std::fmt::Display; + +use crate::{Vertex, plugin::error::PluginDataError}; + +#[derive(Clone, Default, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct UndefinedGroupError { + group_name: String, +} + +impl UndefinedGroupError { + pub(crate) fn new(group_name: String) -> Self { + Self { group_name } + } + + pub(crate) fn into_group_name(self) -> String { + 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(Clone, Default, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct CyclicInteractionError { + cycle: Vec, +} + +impl CyclicInteractionError { + pub(crate) fn new(cycle: Vec) -> Self { + Self { cycle } + } + + pub(crate) fn into_cycle(self) -> Vec { + self.cycle + } +} + +impl Display for CyclicInteractionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let cycle = display_cycle(&self.cycle); + write!(f, "cyclic interaction detected: {cycle}") + } +} + +impl std::error::Error for CyclicInteractionError {} + +pub(crate) fn display_cycle(cycle: &[Vertex]) -> String { + cycle + .iter() + .map(|v| { + if let Some(edge_type) = v.out_edge_type() { + format!("{} --[{}]-> ", v.name(), edge_type) + } else { + v.name().to_owned() + } + }) + .chain(cycle.first().iter().map(|v| v.name().to_owned())) + .collect() +} + +/// Represents an error that occurred while trying to get the path between two +/// groups across the graph formed from group metadata. +#[derive(Debug)] +#[non_exhaustive] +pub enum GroupsPathError { + UndefinedGroup(String), + CycleFound(Vec), + PathfindingError(Box), +} + +impl Display for GroupsPathError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::UndefinedGroup(g) => write!(f, "the group \"{g}\" does not exist"), + Self::CycleFound(c) => write!(f, "found a cycle: {}", display_cycle(c)), + Self::PathfindingError(_) => write!(f, "failed to find a path in the groups graph"), + } + } +} + +impl std::error::Error for GroupsPathError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::PathfindingError(e) => Some(e.as_ref()), + _ => None, + } + } +} + +impl From for GroupsPathError { + fn from(value: BuildGroupsGraphError) -> Self { + match value { + BuildGroupsGraphError::CycleFound(e) => Self::CycleFound(e.into_cycle()), + BuildGroupsGraphError::UndefinedGroup(e) => Self::UndefinedGroup(e.into_group_name()), + } + } +} + +impl From for GroupsPathError { + fn from(value: UndefinedGroupError) -> Self { + Self::UndefinedGroup(value.into_group_name()) + } +} + +impl From for GroupsPathError { + fn from(value: PathfindingError) -> Self { + Self::PathfindingError(Box::new(value)) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub(crate) enum BuildGroupsGraphError { + UndefinedGroup(UndefinedGroupError), + CycleFound(CyclicInteractionError), +} + +impl Display for BuildGroupsGraphError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::UndefinedGroup(_) => write!(f, "encountered an undefined group"), + Self::CycleFound(_) => write!(f, "the groups graph is cyclic"), + } + } +} + +impl std::error::Error for BuildGroupsGraphError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::UndefinedGroup(e) => Some(e), + Self::CycleFound(e) => Some(e), + } + } +} + +impl From for BuildGroupsGraphError { + fn from(value: UndefinedGroupError) -> Self { + BuildGroupsGraphError::UndefinedGroup(value) + } +} + +impl From for BuildGroupsGraphError { + fn from(value: CyclicInteractionError) -> Self { + BuildGroupsGraphError::CycleFound(value) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub(crate) enum PathfindingError { + NegativeCycle, + PrecedingNodeNotFound(String), + FollowingNodeNotFound(String), + EdgeNotFound { + from_group: String, + to_group: String, + }, +} + +impl Display for PathfindingError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NegativeCycle => write!( + f, + "encountered a cycle of user-defined \"load after\" relationships" + ), + Self::PrecedingNodeNotFound(n) => write!( + f, + "unexpectedly could not find the node before \"{n}\" in the path that was found", + ), + Self::FollowingNodeNotFound(n) => write!( + f, + "unexpectedly could not find the node after \"{n}\" in the path that was found", + ), + Self::EdgeNotFound { + from_group, + to_group, + } => write!( + f, + "unexpectedly could not find the edge going from \"{from_group}\" to \"{to_group}\"", + ), + } + } +} + +impl std::error::Error for PathfindingError {} + +#[derive(Debug)] +pub(crate) enum PluginGraphValidationError { + CycleFound(CyclicInteractionError), + PluginDataError(PluginDataError), +} + +impl Display for PluginGraphValidationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::CycleFound(_) => write!(f, "found a cycle in the plugin graph"), + Self::PluginDataError(_) => write!(f, "failed to read plugin data"), + } + } +} + +impl std::error::Error for PluginGraphValidationError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::CycleFound(e) => Some(e), + Self::PluginDataError(e) => Some(e), + } + } +} + +impl From for PluginGraphValidationError { + fn from(value: CyclicInteractionError) -> Self { + PluginGraphValidationError::CycleFound(value) + } +} + +impl From for PluginGraphValidationError { + fn from(value: PluginDataError) -> Self { + PluginGraphValidationError::PluginDataError(value) + } +} + +#[derive(Debug)] +pub(crate) enum SortingError { + ValidationError(PluginGraphValidationError), + UndefinedGroup(UndefinedGroupError), + CycleFound(CyclicInteractionError), + CycleInvolving(String), + PluginDataError(PluginDataError), + PathfindingError(PathfindingError), +} + +impl Display for SortingError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::ValidationError(_) => write!(f, "plugin graph validation failed"), + Self::UndefinedGroup(_) => write!(f, "found an undefined group"), + Self::CycleFound(_) => write!(f, "found a cycle"), + Self::CycleInvolving(n) => write!(f, "found a cycle involving \"{n}\""), + Self::PluginDataError(_) => write!(f, "failed to read plugin data"), + Self::PathfindingError(_) => write!(f, "failed to find a path in the plugins graph"), + } + } +} + +impl std::error::Error for SortingError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::ValidationError(e) => Some(e), + Self::UndefinedGroup(e) => Some(e), + Self::CycleFound(e) => Some(e), + Self::CycleInvolving(_) => None, + Self::PluginDataError(e) => Some(e), + Self::PathfindingError(e) => Some(e), + } + } +} + +impl From for SortingError { + fn from(value: PluginGraphValidationError) -> Self { + SortingError::ValidationError(value) + } +} + +impl From for SortingError { + fn from(value: UndefinedGroupError) -> Self { + SortingError::UndefinedGroup(value) + } +} + +impl From for SortingError { + fn from(value: CyclicInteractionError) -> Self { + SortingError::CycleFound(value) + } +} + +impl From for SortingError { + fn from(value: PluginDataError) -> Self { + SortingError::PluginDataError(value) + } +} + +impl From for SortingError { + fn from(value: PathfindingError) -> Self { + SortingError::PathfindingError(value) + } +} diff --git a/src/sorting/groups.rs b/src/sorting/groups.rs new file mode 100644 index 00000000..25f5c992 --- /dev/null +++ b/src/sorting/groups.rs @@ -0,0 +1,486 @@ +use std::cmp::Reverse; + +use rustc_hash::FxHashMap as HashMap; + +use petgraph::{Graph, algo::bellman_ford, graph::NodeIndex}; + +use crate::{ + EdgeType, LogLevel, Vertex, + logging::{self, is_log_enabled}, + metadata::Group, + sorting::{ + dfs::find_cycle, + error::{ + BuildGroupsGraphError, CyclicInteractionError, PathfindingError, UndefinedGroupError, + }, + }, +}; + +use super::{ + dfs::{DfsVisitor, depth_first_search}, + error::GroupsPathError, +}; + +pub type GroupsGraph = Graph, EdgeType>; + +pub fn build_groups_graph( + masterlist_groups: &[Group], + userlist_groups: &[Group], +) -> Result { + let masterlist_groups = sorted_by_name(masterlist_groups); + let userlist_groups = sorted_by_name(userlist_groups); + + let mut graph = GroupsGraph::new(); + let mut group_nodes: HashMap<&str, NodeIndex> = HashMap::default(); + + logging::trace!("Adding masterlist groups to groups graph..."); + add_groups( + &mut graph, + &mut group_nodes, + &masterlist_groups, + EdgeType::MasterlistLoadAfter, + )?; + + logging::trace!("Adding user groups to groups graph..."); + add_groups( + &mut graph, + &mut group_nodes, + &userlist_groups, + EdgeType::UserLoadAfter, + )?; + + if let Some(cycle) = find_cycle(&graph, |node| node.clone().into_string()) { + Err(CyclicInteractionError::new(cycle).into()) + } else { + Ok(graph) + } +} + +fn sorted_by_name(groups: &[Group]) -> Vec<&Group> { + let mut groups: Vec<_> = groups.iter().collect(); + groups.sort_by_key(|a| a.name()); + + groups +} + +fn add_groups<'a>( + graph: &mut GroupsGraph, + group_nodes: &mut HashMap<&'a str, NodeIndex>, + groups: &[&'a Group], + edge_type: EdgeType, +) -> Result<(), UndefinedGroupError> { + for group in groups { + let key = group.name(); + if !group_nodes.contains_key(key) { + let node_index = graph.add_node(group.name().into()); + group_nodes.insert(key, node_index); + } + } + + for group in groups { + if is_log_enabled(LogLevel::Trace) { + logging::trace!( + "Group \"{}\" directly loads after groups \"{}\"", + group.name(), + group.after_groups().join(", ") + ); + } + + let Some(node_index) = group_nodes.get(group.name()) else { + logging::error!( + "Unexpectedly couldn't find node for group {}: it should have just been added to the graph", + group.name() + ); + return Err(UndefinedGroupError::new(group.name().to_owned())); + }; + + for other_group_name in sorted_clone(group.after_groups()) { + if let Some(other_index) = group_nodes.get(other_group_name) { + graph.update_edge(*other_index, *node_index, edge_type); + } else { + return Err(UndefinedGroupError::new(other_group_name.to_owned())); + } + } + } + + Ok(()) +} + +fn sorted_clone(strings: &[String]) -> Vec<&str> { + let mut strings: Vec<_> = strings.iter().map(String::as_str).collect(); + strings.sort_unstable(); + + strings +} + +pub fn find_path( + graph: &GroupsGraph, + from_group_name: &str, + to_group_name: &str, +) -> Result, GroupsPathError> { + let float_graph: Graph<&Box, f32> = graph.map( + |_, n| n, + |_, e| { + if *e == EdgeType::UserLoadAfter { + // A very small number so that user edges are practically always preferred. + -1_000_000.0 + } else { + 1.0 + } + }, + ); + + let from_vertex = find_node_by_weight(graph, from_group_name)?; + let to_vertex = find_node_by_weight(graph, to_group_name)?; + + let paths = + bellman_ford(&float_graph, from_vertex).map_err(|_e| PathfindingError::NegativeCycle)?; + + let mut path = vec![Vertex::new(graph[to_vertex].clone().into_string())]; + let mut current = to_vertex; + while current != from_vertex { + let preceding_vertex = match paths.predecessors.get(current.index()) { + Some(Some(v)) => v, + Some(None) => { + logging::info!( + "No path found from {} to {} while looking for path to {}", + graph[from_vertex], + graph[current], + graph[to_vertex] + ); + return Ok(Vec::new()); + } + _ => { + return Err(PathfindingError::PrecedingNodeNotFound( + graph[current].clone().into_string(), + ) + .into()); + } + }; + + if *preceding_vertex == current { + logging::error!( + "Unreachable vertex {} encountered while looking for vertex {}", + graph[current], + graph[from_vertex] + ); + return Ok(Vec::new()); + } + + let Some(edge) = graph.find_edge(*preceding_vertex, current) else { + return Err(PathfindingError::EdgeNotFound { + from_group: graph[*preceding_vertex].clone().into_string(), + to_group: graph[current].clone().into_string(), + } + .into()); + }; + + let vertex = Vertex::new(graph[*preceding_vertex].clone().into_string()) + .with_out_edge_type(graph[edge]); + path.push(vertex); + + current = *preceding_vertex; + } + + path.reverse(); + + Ok(path) +} + +fn find_node_by_weight( + graph: &Graph, EdgeType>, + weight: &str, +) -> Result { + if let Some(n) = graph + .node_indices() + .find(|i| graph.node_weight(*i).is_some_and(|w| w.as_ref() == weight)) + { + Ok(n) + } else { + logging::error!("Can't find group with name {weight}"); + Err(UndefinedGroupError::new(weight.to_owned())) + } +} + +/// Sort the group vertices so that root vertices come first, in order of +/// decreasing path length, but otherwise preserving the existing +/// (lexicographical) ordering. +pub fn sorted_group_nodes(graph: &GroupsGraph) -> Vec { + let mut nodes: Vec<(NodeIndex, bool, usize)> = graph + .node_indices() + .map(|n| { + if is_root_node(graph, n) { + let mut visitor = GroupsPathLengthVisitor::new(); + + depth_first_search(graph, &mut HashMap::default(), n, &mut visitor); + + (n, true, visitor.max_path_length()) + } else { + (n, false, 0) + } + }) + .collect(); + + nodes.sort_by_key(|a| Reverse((a.1, a.2))); + + nodes.into_iter().map(|n| n.0).collect() +} + +fn is_root_node(graph: &GroupsGraph, node_index: NodeIndex) -> bool { + graph + .neighbors_directed(node_index, petgraph::Direction::Incoming) + .next() + .is_none() +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +struct GroupsPathLengthVisitor { + max_path_length: usize, + current_path_length: usize, +} + +impl GroupsPathLengthVisitor { + fn new() -> Self { + GroupsPathLengthVisitor::default() + } + + fn max_path_length(&self) -> usize { + self.max_path_length + } +} + +impl<'a> DfsVisitor<'a> for GroupsPathLengthVisitor { + fn visit_tree_edge(&mut self, _: petgraph::graph::EdgeReference<'a, EdgeType>) {} + + fn visit_forward_or_cross_edge(&mut self, _: petgraph::graph::EdgeReference<'a, EdgeType>) {} + + fn visit_back_edge(&mut self, _: petgraph::graph::EdgeReference<'a, EdgeType>) {} + + fn discover_node(&mut self, _: NodeIndex) { + self.current_path_length += 1; + if self.current_path_length > self.max_path_length { + self.max_path_length = self.current_path_length; + } + } + + fn finish_node(&mut self, _: NodeIndex) { + self.current_path_length -= 1; + } +} + +pub fn get_default_group_node(graph: &GroupsGraph) -> Result { + graph + .node_indices() + .find(|n| graph[*n].as_ref() == Group::DEFAULT_NAME) + .ok_or_else(|| UndefinedGroupError::new(Group::DEFAULT_NAME.to_owned())) +} + +#[cfg(test)] +mod tests { + use super::*; + + mod build_groups_graph { + use super::*; + + #[test] + fn should_error_if_an_after_group_does_not_exist() { + let groups = &[Group::new("b".into()).with_after_groups(vec!["a".into()])]; + + match build_groups_graph(groups, &[]) { + Err(BuildGroupsGraphError::UndefinedGroup(e)) => { + assert_eq!("a", e.into_group_name()); + } + _ => panic!("Expected an undefined group error"), + } + } + + #[test] + fn should_error_if_masterlist_group_loads_after_user_group() { + let masterlist = &[Group::new("b".into()).with_after_groups(vec!["a".into()])]; + let userlist = &[Group::new("a".into())]; + + match build_groups_graph(masterlist, userlist) { + Err(BuildGroupsGraphError::UndefinedGroup(e)) => { + assert_eq!("a", e.into_group_name()); + } + _ => panic!("Expected an undefined group error"), + } + } + + #[test] + fn should_error_if_after_groups_are_cyclic() { + let masterlist = &[ + Group::new("a".into()), + Group::new("b".into()).with_after_groups(vec!["a".into()]), + ]; + let userlist = &[ + Group::new("a".into()).with_after_groups(vec!["c".into()]), + Group::new("c".into()).with_after_groups(vec!["b".into()]), + ]; + + match build_groups_graph(masterlist, userlist) { + Err(BuildGroupsGraphError::CycleFound(e)) => { + let cycle = e.into_cycle(); + + assert_eq!( + &[ + Vertex::new("a".into()) + .with_out_edge_type(EdgeType::MasterlistLoadAfter), + Vertex::new("b".into()).with_out_edge_type(EdgeType::UserLoadAfter), + Vertex::new("c".into()).with_out_edge_type(EdgeType::UserLoadAfter), + ], + cycle.as_slice() + ); + } + _ => panic!("Expected a cyclic interaction error"), + } + } + + #[test] + fn cyclic_interaction_error_should_only_include_groups_that_are_part_of_the_cycle() { + let masterlist = &[ + Group::new("a".into()).with_after_groups(vec!["b".into()]), + Group::new("b".into()).with_after_groups(vec!["a".into()]), + Group::new("c".into()).with_after_groups(vec!["b".into()]), + ]; + + match build_groups_graph(masterlist, &[]) { + Err(BuildGroupsGraphError::CycleFound(e)) => { + let cycle = e.into_cycle(); + + assert_eq!( + &[ + Vertex::new("a".into()) + .with_out_edge_type(EdgeType::MasterlistLoadAfter), + Vertex::new("b".into()) + .with_out_edge_type(EdgeType::MasterlistLoadAfter), + ], + cycle.as_slice() + ); + } + _ => panic!("Expected a cyclic interaction error"), + } + } + } + + mod find_path { + use super::*; + + #[test] + fn should_error_if_the_from_group_does_not_exist() { + let masterlist = &[ + Group::new("a".into()), + Group::new("b".into()).with_after_groups(vec!["a".into()]), + ]; + let graph = build_groups_graph(masterlist, &[]).unwrap(); + + assert!(find_path(&graph, "c", "a").is_err()); + } + + #[test] + fn should_error_if_the_to_group_does_not_exist() { + let masterlist = &[ + Group::new("a".into()), + Group::new("b".into()).with_after_groups(vec!["a".into()]), + ]; + let graph = build_groups_graph(masterlist, &[]).unwrap(); + + assert!(find_path(&graph, "a", "c").is_err()); + } + + #[test] + fn should_return_an_empty_vec_if_there_is_no_path() { + let masterlist = &[Group::new("a".into()), Group::new("b".into())]; + let graph = build_groups_graph(masterlist, &[]).unwrap(); + + let path = find_path(&graph, "a", "b").unwrap(); + + assert!(path.is_empty()); + } + + #[test] + fn should_find_the_shortest_path_if_there_is_no_user_metadata() { + let masterlist = &[ + Group::new("a".into()), + Group::new("b".into()).with_after_groups(vec!["a".into()]), + Group::new("c".into()).with_after_groups(vec!["a".into()]), + Group::new("d".into()).with_after_groups(vec!["c".into()]), + Group::new("e".into()).with_after_groups(vec!["b".into(), "d".into()]), + ]; + let graph = build_groups_graph(masterlist, &[]).unwrap(); + + let path = find_path(&graph, "a", "e").unwrap(); + + assert_eq!( + &[ + Vertex::new("a".into()).with_out_edge_type(EdgeType::MasterlistLoadAfter), + Vertex::new("b".into()).with_out_edge_type(EdgeType::MasterlistLoadAfter), + Vertex::new("e".into()) + ], + path.as_slice() + ); + } + + #[test] + fn should_find_the_shortest_path_involving_user_metadata_if_there_no_user_metadata() { + let masterlist = &[ + Group::new("a".into()), + Group::new("b".into()).with_after_groups(vec!["a".into()]), + Group::new("c".into()).with_after_groups(vec!["a".into()]), + Group::new("e".into()).with_after_groups(vec!["b".into()]), + ]; + let userlist = &[ + Group::new("d".into()).with_after_groups(vec!["c".into()]), + Group::new("e".into()).with_after_groups(vec!["d".into()]), + ]; + let graph = build_groups_graph(masterlist, userlist).unwrap(); + + let path = find_path(&graph, "a", "e").unwrap(); + + assert_eq!( + &[ + Vertex::new("a".into()).with_out_edge_type(EdgeType::MasterlistLoadAfter), + Vertex::new("c".into()).with_out_edge_type(EdgeType::UserLoadAfter), + Vertex::new("d".into()).with_out_edge_type(EdgeType::UserLoadAfter), + Vertex::new("e".into()) + ], + path.as_slice() + ); + } + + #[test] + fn should_not_depend_on_the_after_group_definition_order() { + let masterlists = &[ + &[ + Group::new("a".into()), + Group::new("b".into()).with_after_groups(vec!["a".into()]), + Group::new("c".into()).with_after_groups(vec!["a".into()]), + Group::new("d".into()).with_after_groups(vec!["b".into(), "c".into()]), + Group::new("e".into()).with_after_groups(vec!["d".into()]), + ], + &[ + Group::new("a".into()), + Group::new("b".into()).with_after_groups(vec!["a".into()]), + Group::new("c".into()).with_after_groups(vec!["a".into()]), + Group::new("d".into()).with_after_groups(vec!["c".into(), "b".into()]), + Group::new("e".into()).with_after_groups(vec!["d".into()]), + ], + ]; + + for masterlist in masterlists { + let graph = build_groups_graph(*masterlist, &[]).unwrap(); + + let path = find_path(&graph, "a", "e").unwrap(); + assert_eq!( + &[ + Vertex::new("a".into()).with_out_edge_type(EdgeType::MasterlistLoadAfter), + Vertex::new("b".into()).with_out_edge_type(EdgeType::MasterlistLoadAfter), + Vertex::new("d".into()).with_out_edge_type(EdgeType::MasterlistLoadAfter), + Vertex::new("e".into()) + ], + path.as_slice() + ); + } + } + } +} diff --git a/src/sorting/mod.rs b/src/sorting/mod.rs new file mode 100644 index 00000000..2eb7336b --- /dev/null +++ b/src/sorting/mod.rs @@ -0,0 +1,79 @@ +mod dfs; +pub mod error; +pub mod groups; +pub mod plugins; +mod validate; +pub mod vertex; + +#[cfg(test)] +mod test { + use super::plugins::SortingPlugin; + use crate::error::PluginDataError; + + #[derive(Default)] + pub struct TestPlugin { + name: String, + masters: Vec, + pub(super) is_master: bool, + pub(super) is_blueprint_plugin: bool, + pub(super) override_record_count: usize, + pub(super) asset_count: usize, + overlapping_record_plugins: Vec, + overlapping_asset_plugins: Vec, + } + + impl TestPlugin { + pub fn new(name: &str) -> Self { + Self { + name: name.to_owned(), + ..Default::default() + } + } + + pub fn add_master(&mut self, plugin_name: &str) { + self.masters.push(plugin_name.to_owned()); + } + + pub fn add_overlapping_records(&mut self, plugin_name: &str) { + self.overlapping_record_plugins.push(plugin_name.to_owned()); + } + + pub fn add_overlapping_assets(&mut self, plugin_name: &str) { + self.overlapping_asset_plugins.push(plugin_name.to_owned()); + } + } + + impl SortingPlugin for TestPlugin { + fn name(&self) -> &str { + &self.name + } + + fn is_master(&self) -> bool { + self.is_master + } + + fn is_blueprint_plugin(&self) -> bool { + self.is_blueprint_plugin + } + + fn masters(&self) -> Result, PluginDataError> { + Ok(self.masters.clone()) + } + + fn override_record_count(&self) -> Result { + Ok(self.override_record_count) + } + + fn asset_count(&self) -> usize { + self.asset_count + } + + fn do_records_overlap(&self, other: &Self) -> Result { + Ok(self.overlapping_record_plugins.contains(&other.name)) + } + + fn do_assets_overlap(&self, other: &Self) -> bool { + self.overlapping_asset_plugins.contains(&other.name) + } + } +} diff --git a/src/sorting/plugins.rs b/src/sorting/plugins.rs new file mode 100644 index 00000000..800d8ec4 --- /dev/null +++ b/src/sorting/plugins.rs @@ -0,0 +1,3855 @@ +use std::rc::Rc; + +use petgraph::{ + Graph, + graph::{EdgeReference, NodeIndex}, + visit::EdgeRef, +}; +use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; + +use crate::{ + EdgeType, LogLevel, Plugin, + logging::{self, is_log_enabled}, + metadata::{File, Group, PluginMetadata}, + plugin::error::PluginDataError, + sorting::{ + error::{CyclicInteractionError, PathfindingError, SortingError, UndefinedGroupError}, + groups::{get_default_group_node, sorted_group_nodes}, + }, +}; + +use super::{ + dfs::{BidirBfsVisitor, DfsVisitor, bidirectional_bfs, depth_first_search, find_cycle}, + groups::GroupsGraph, + validate::{validate_plugin_groups, validate_specific_and_hardcoded_edges}, +}; + +#[derive(Debug)] +pub struct PluginSortingData<'a, T: SortingPlugin> { + plugin: &'a T, + pub(super) is_master: bool, + override_record_count: usize, + + load_order_index: usize, + + pub(super) group: Box, + group_is_user_metadata: bool, + pub(crate) masterlist_load_after: Box<[String]>, + pub(crate) user_load_after: Box<[String]>, + pub(crate) masterlist_req: Box<[String]>, + pub(crate) user_req: Box<[String]>, +} + +impl<'a, T: SortingPlugin> PluginSortingData<'a, T> { + pub fn new( + plugin: &'a T, + masterlist_metadata: Option<&PluginMetadata>, + user_metadata: Option<&PluginMetadata>, + load_order_index: usize, + ) -> Result { + let override_record_count = plugin.override_record_count()?; + + Ok(Self { + plugin, + is_master: plugin.is_master(), + override_record_count, + load_order_index, + group: user_metadata + .and_then(|m| m.group()) + .or_else(|| masterlist_metadata.and_then(|m| m.group())) + .unwrap_or(Group::DEFAULT_NAME) + .into(), + group_is_user_metadata: user_metadata.and_then(|m| m.group()).is_some(), + masterlist_load_after: masterlist_metadata + .map(|m| to_filenames(m.load_after_files())) + .unwrap_or_default(), + user_load_after: user_metadata + .map(|m| to_filenames(m.load_after_files())) + .unwrap_or_default(), + masterlist_req: masterlist_metadata + .map(|m| to_filenames(m.requirements())) + .unwrap_or_default(), + user_req: user_metadata + .map(|m| to_filenames(m.requirements())) + .unwrap_or_default(), + }) + } + + pub(super) fn name(&self) -> &str { + self.plugin.name() + } + + fn is_blueprint_master(&self) -> bool { + self.is_master && self.plugin.is_blueprint_plugin() + } + + fn asset_count(&self) -> usize { + self.plugin.asset_count() + } + + pub(super) fn masters(&self) -> Result, PluginDataError> { + self.plugin.masters() + } + + fn do_records_overlap(&self, other: &Self) -> Result { + self.plugin.do_records_overlap(other.plugin) + } + + fn do_assets_overlap(&self, other: &Self) -> bool { + self.plugin.do_assets_overlap(other.plugin) + } +} + +pub trait SortingPlugin { + fn name(&self) -> &str; + fn is_master(&self) -> bool; + fn is_blueprint_plugin(&self) -> bool; + fn masters(&self) -> Result, PluginDataError>; + fn override_record_count(&self) -> Result; + fn asset_count(&self) -> usize; + fn do_records_overlap(&self, other: &Self) -> Result; + fn do_assets_overlap(&self, other: &Self) -> bool; +} + +impl SortingPlugin for Plugin { + fn name(&self) -> &str { + self.name() + } + fn is_master(&self) -> bool { + self.is_master() + } + + fn is_blueprint_plugin(&self) -> bool { + self.is_blueprint_plugin() + } + + fn masters(&self) -> Result, PluginDataError> { + self.masters() + } + fn override_record_count(&self) -> Result { + self.override_record_count() + } + fn asset_count(&self) -> usize { + self.asset_count() + } + + fn do_records_overlap(&self, other: &Self) -> Result { + self.do_records_overlap(other) + } + fn do_assets_overlap(&self, other: &Self) -> bool { + self.do_assets_overlap(other) + } +} + +fn to_filenames(files: &[File]) -> Box<[String]> { + files.iter().map(|f| f.name().as_str().to_owned()).collect() +} + +type InnerPluginsGraph<'a, T> = Graph>, EdgeType>; + +#[derive(Debug)] +struct PluginsGraph<'a, T: SortingPlugin> { + // Put the sorting data in Rc so that it can be held onto while mutating the graph. + inner: InnerPluginsGraph<'a, T>, + paths_cache: HashMap>, +} + +impl<'a, T: SortingPlugin> PluginsGraph<'a, T> { + fn new() -> Self { + PluginsGraph::default() + } + + fn add_node(&mut self, plugin: PluginSortingData<'a, T>) -> NodeIndex { + self.inner.add_node(Rc::new(plugin)) + } + + fn add_edge(&mut self, from: NodeIndex, to: NodeIndex, edge_type: EdgeType) { + if self.is_path_cached(from, to) { + return; + } + + logging::debug!( + "Adding {} edge from \"{}\" to \"{}\".", + edge_type, + self.inner[from].name(), + self.inner[to].name() + ); + + self.inner.add_edge(from, to, edge_type); + + self.cache_path(from, to); + } + + fn node_indices(&self) -> petgraph::graph::NodeIndices { + self.inner.node_indices() + } + + fn add_specific_edges(&mut self) -> Result<(), SortingError> { + logging::trace!("Adding edges based on plugin data and non-group metadata..."); + + let mut node_index_iter = self.node_indices(); + while let Some(node_index) = node_index_iter.next() { + let plugin = Rc::clone(&self[node_index]); + + // This loop should have no effect now that master-flagged and + // non-master-flagged plugins are sorted separately, but is kept + // as a safety net. + for other_node_index in node_index_iter.clone() { + let other_plugin = &self[other_node_index]; + + if plugin.is_master == other_plugin.is_master { + continue; + } + + if other_plugin.is_master { + self.add_edge(other_node_index, node_index, EdgeType::MasterFlag); + } else { + self.add_edge(node_index, other_node_index, EdgeType::MasterFlag); + } + } + + for master in plugin.masters()? { + if let Some(other_node_index) = self.node_index_by_name(&master) { + self.add_edge(other_node_index, node_index, EdgeType::Master); + } + } + + for file in &plugin.masterlist_req { + if let Some(other_node_index) = self.node_index_by_name(file) { + self.add_edge( + other_node_index, + node_index, + EdgeType::MasterlistRequirement, + ); + } + } + + for file in &plugin.user_req { + if let Some(other_node_index) = self.node_index_by_name(file) { + self.add_edge(other_node_index, node_index, EdgeType::UserRequirement); + } + } + + for file in &plugin.masterlist_load_after { + if let Some(other_node_index) = self.node_index_by_name(file) { + self.add_edge(other_node_index, node_index, EdgeType::MasterlistLoadAfter); + } + } + + for file in &plugin.user_load_after { + if let Some(other_node_index) = self.node_index_by_name(file) { + self.add_edge(other_node_index, node_index, EdgeType::UserLoadAfter); + } + } + } + + Ok(()) + } + + fn add_early_loading_plugin_edges(&mut self, early_loading_plugins: &[String]) { + logging::trace!( + "Adding edges for implicitly active plugins and plugins with hardcoded positions..." + ); + + if early_loading_plugins.is_empty() { + return; + } + + let mut early_loader_indices = Vec::new(); + let mut other_plugin_indices = Vec::new(); + for node_index in self.node_indices() { + let plugin = &self[node_index]; + if let Some(i) = early_loading_plugins + .iter() + .position(|e| unicase::eq(e.as_str(), plugin.name())) + { + early_loader_indices.push((i, node_index)); + } else { + other_plugin_indices.push(node_index); + } + } + + early_loader_indices.sort_by_key(|e| e.0); + + for window in early_loader_indices.windows(2) { + if let [(_, from_index), (_, to_index)] = *window { + self.add_edge(from_index, to_index, EdgeType::Hardcoded); + } + } + + if let Some((_, from_index)) = early_loader_indices.last() { + for to_index in other_plugin_indices { + self.add_edge(*from_index, to_index, EdgeType::Hardcoded); + } + } + } + + fn check_for_cycles(&mut self) -> Result<(), CyclicInteractionError> { + if let Some(cycle) = find_cycle(&self.inner, |node| node.name().to_owned()) { + Err(CyclicInteractionError::new(cycle)) + } else { + Ok(()) + } + } + + fn add_group_edges(&mut self, groups_graph: &GroupsGraph) -> Result<(), UndefinedGroupError> { + logging::trace!("Adding edges based on plugin group memberships..."); + + // First build a map from groups to the plugins in those groups. + let plugins_in_groups = get_plugins_in_groups(&self.inner); + + // Get the default group's vertex because it's needed for the DFSes. + let default_group_node = get_default_group_node(groups_graph)?; + + // Keep a record of which vertices have already been fully explored to avoid + // adding edges from their plugins more than once. + let mut finished_nodes = HashSet::default(); + // Now loop over the vertices in the groups graph. + // The vertex sort order prioritises resolving potential cycles in + // favour of earlier-loading groups. It does not guarantee that the + // longest paths will be walked first, because a root vertex may be in + // more than one path and the vertex sort order here does not influence + // which path the DFS takes. + for group_node in sorted_group_nodes(groups_graph) { + // Run a DFS from each vertex in the group graph, adding edges except from + // plugins in the default group. This could be run only on the root + // vertices, except that the DFS only visits each vertex once, so a branch + // and merge inside a given root's DAG would result in plugins from one of + // the branches not being carried forwards past the point at which the + // branches merge. + let mut visitor = GroupsPathVisitor::new( + self, + groups_graph, + &plugins_in_groups, + &mut finished_nodes, + Some(default_group_node), + ); + + depth_first_search( + groups_graph, + &mut HashMap::default(), + group_node, + &mut visitor, + ); + } + + // Now do one last DFS starting from the default group and not ignoring its + // plugins. + let mut visitor = GroupsPathVisitor::new( + self, + groups_graph, + &plugins_in_groups, + &mut finished_nodes, + None, + ); + + depth_first_search( + groups_graph, + &mut HashMap::default(), + default_group_node, + &mut visitor, + ); + + Ok(()) + } + + fn add_overlap_edges(&mut self) -> Result<(), SortingError> { + logging::trace!("Adding edges for overlapping plugins..."); + + let mut node_index_iter = self.node_indices(); + while let Some(node_index) = node_index_iter.next() { + let plugin = Rc::clone(&self[node_index]); + let plugin_asset_count = plugin.asset_count(); + + if plugin.override_record_count == 0 && plugin_asset_count == 0 { + logging::debug!( + "Skipping vertex for \"{}\": the plugin contains no override records and loads no assets", + plugin.name() + ); + continue; + } + + // This loop should have no effect now that master-flagged and + // non-master-flagged plugins are sorted separately, but is kept + // as a safety net. + for other_node_index in node_index_iter.clone() { + let other_plugin = &self[other_node_index]; + + // Don't add an edge between these two plugins if one already + // exists (only check direct edges and not paths for efficiency). + if self.inner.contains_edge(node_index, other_node_index) + || self.inner.contains_edge(other_node_index, node_index) + { + continue; + } + + // Two plugins can overlap due to overriding the same records, + // or by loading assets from BSAs/BA2s that have the same path. + // If records overlap, the plugin that overrides more records + // should load earlier. + // If assets overlap, the plugin that loads more assets should + // load earlier. + // If two plugins have overlapping records and assets and one + // overrides more records but loads fewer assets than the other, + // the fact it overrides more records should take precedence + // (records are more significant than assets). + // I.e. if two plugins don't have overlapping records, check their + // assets, otherwise only check their assets if their override + // record counts are equal. + + let outer_plugin_loads_first; + let edge_type; + + if plugin.override_record_count == other_plugin.override_record_count + || !plugin.do_records_overlap(other_plugin)? + { + // Records don't overlap, or override the same number of records, + // check assets. + // No records overlap, check assets. + let other_plugin_asset_count = other_plugin.asset_count(); + if plugin_asset_count == other_plugin_asset_count + || !plugin.do_assets_overlap(other_plugin) + { + // Assets don't overlap or both plugins load the same number of + // assets, don't add an edge. + continue; + } + + outer_plugin_loads_first = plugin_asset_count > other_plugin_asset_count; + edge_type = EdgeType::AssetOverlap; + } else { + // Records overlap and override different numbers of records. + // Load this plugin first if it overrides more records. + outer_plugin_loads_first = + plugin.override_record_count > other_plugin.override_record_count; + edge_type = EdgeType::RecordOverlap; + } + + let (from_index, to_index) = if outer_plugin_loads_first { + (node_index, other_node_index) + } else { + (other_node_index, node_index) + }; + + if !self.is_path_cached(from_index, to_index) { + if self.path_exists(to_index, from_index) { + logging::debug!( + "Skipping {} edge from \"{}\" to \"{}\" as it would create a cycle.", + edge_type, + self[from_index].name(), + self[to_index].name() + ); + } else { + self.add_edge(from_index, to_index, edge_type); + } + } + } + } + + Ok(()) + } + + fn add_tie_break_edges(&mut self) -> Result<(), PathfindingError> { + logging::trace!("Adding edges to break ties between plugins..."); + + // In order for the sort to be performed stably, there must be only one + // possible result. This can be enforced by adding edges between all vertices + // that aren't already linked. Use existing load order to decide the direction + // of these edges, and only add an edge if it won't cause a cycle. + // + // Brute-forcing this by adding an edge between every pair of vertices + // (unless it would cause a cycle) works but scales terribly, as before each + // edge is added a bidirectional search needs to be done for a path in the + // other direction (to detect a potential cycle). This search takes more time + // as the number of edges involves increases, so adding tie breaks gets slower + // as they get added. + // + // The point of adding these tie breaks is to ensure that there's a + // Hamiltonian path through the graph and therefore only one possible + // topological sort result. + // + // Instead of trying to brute-force this, iterate over the graph's vertices in + // their existing load order (each vertex represents a plugin, so the two + // terms are used interchangeably), and add an edge going from the earlier to + // the later for each consecutive pair of plugins (e.g. for [A, B, C], add + // edges A->B, B->C), unless adding the edge would cause a cycle. If sorting + // has made no changes to the load order, then it'll be possible to add all + // those edges and only N - 1 bidirectional searches will be needed when there + // are N vertices. + // + // If it's not possible to add such an edge for a pair of plugins [A, B], that + // means that LOOT thinks A needs to load after B, i.e. the sorted load order + // will be different. If the existing path between A and B is B -> C -> D -> A + // then walk back through the load order to find a plugin that B will load + // after without causing a cycle, and add an edge going from that plugin to B. + // Then do the same for each subsequent plugin in the path between A and B so + // that every plugin in the existing load order until A has a path to each of + // the plugins in the path from B to A, and that there is only one path that + // will visit all plugins until A. Keep a record of this path, because that's + // the load order that needs to be walked back through whenever the existing + // relative positions of plugins can't be used (if the existing load order was + // used, the process would miss out on plugins introduced in previous backward + // walks, and so you'd end up with multiple paths that don't necessarily touch + // all plugins). + + // Storage for the load order as it evolves. + let mut new_load_order: Vec = Vec::new(); + + // Holds nodes that have already been put into new_load_order. + let mut processed_nodes = HashSet::default(); + + // First get the graph vertices and sort them into the current load order. + let mut nodes: Vec<_> = self.node_indices().collect(); + nodes.sort_by_key(|a| self[*a].load_order_index); + + for window in nodes.windows(2) { + let [current, next] = *window else { + // This should never happen. + logging::error!("Unexpectedly encountered a window length that was not 2"); + continue; + }; + + match self.find_path(next, current)? { + None => { + // There's no path from next to current, so it's OK to add + // an edge going in the other direction, meaning that next can + // load after current. + self.add_edge(current, next, EdgeType::TieBreak); + + // next now loads after current. If current hasn't + // already been added to the load order, append it. It might have already + // been added if it was part of a path going from next and + // current in a previous loop (i.e. for different values of + // next and current). + if !processed_nodes.contains(¤t) { + new_load_order.push(current); + processed_nodes.insert(current); + + logging::debug!( + "The plugin \"{}\" loads at the end of the new load order so far.", + self[current].name() + ); + } else if new_load_order.last() != Some(¤t) { + logging::trace!( + "The plugin \"{}\" has already been processed and is not the last in the new load order, determining where to place \"{}\".", + self[current].name(), + self[next].name() + ); + + // If current was already processed and not the last vertex + // in new_load_order then next also needs to be pinned in place or + // it may not have a defined position relative to all the + // vertices following current in new_load_order undefined, so + // there wouldn't be a unique path through them. + // + // We're using new_load_order.rend() as the last iterator position because + // we don't know current's position. + self.pin_node_position(&mut processed_nodes, &mut new_load_order, next, 0); + } + } + Some(mut path_from_next_node) => { + // Each vertex in pathFromNextVertex (besides the last, which is + // currentVertex) needs to be positioned relative to a vertex that has + // already been iterated over (i.e. in what begins as the old load + // order) so that there is a single path between all vertices. + // + // If currentVertex is the first in the iteration order, then + // nextVertex is simply the earliest known plugin in the new load order + // so far. + if nodes.first() == Some(¤t) { + // Record the path as the start of the new load order. + // Don't need to add any edges because there's nothing for nextVertex + // to load after at this point. + if is_log_enabled(LogLevel::Debug) { + logging::debug!( + "The path ends with the first plugin checked, treating the following path as the start of the load order: {}", + path_to_string(&self.inner, &path_from_next_node) + ); + } + + for node in path_from_next_node { + new_load_order.push(node); + processed_nodes.insert(node); + } + continue; + } + + // Ignore the last vertex in the path because it's currentVertex and + // will just be appended to the load order so doesn't need special + // processing. + path_from_next_node.pop(); + + // This is used to keep track of when to stop searching for a + // vertex to load after, as a minor optimisation. + let mut range_start = 0; + + // Iterate over the path going from nextVertex towards currentVertex + // (which got chopped off the end of the path). + for node in path_from_next_node { + // Update reverseEndIt to reduce the scope of the search in the + // next loop (if there is one). + range_start = self.pin_node_position( + &mut processed_nodes, + &mut new_load_order, + node, + range_start, + ); + } + + // Add current to the end of the new_load_order - do this after processing the other vertices in the path so that involves less work. + if !processed_nodes.contains(¤t) { + new_load_order.push(current); + processed_nodes.insert(current); + } + } + } + } + + Ok(()) + } + + fn pin_node_position( + &mut self, + processed_nodes: &mut HashSet, + new_load_order: &mut Vec, + node_index: NodeIndex, + range_start: usize, + ) -> usize { + // It's possible that this vertex has already been pinned in place, + // e.g. because it was visited earlier in the old load order or + // as part of a path that was processed. In that case just skip it. + if processed_nodes.contains(&node_index) { + logging::debug!( + "The plugin \"{}\" has already been processed, skipping it.", + self[node_index].name() + ); + return range_start; + } + + // Otherwise, this vertex needs to be inserted into the path that includes + // all other vertices that have been processed so far. This can be done by + // searching for the last vertex in the "new load order" path for which + // there is not a path going from this vertex to that vertex. I.e. find the + // last plugin that this one can load after. We could instead find the last + // plugin that this one *must* load after, but it turns out that's + // significantly slower because it generally involves going further back + // along the "new load order" path. + let previous_node_position = new_load_order + .iter() + .skip(range_start) + .rposition(|ni| !self.path_exists(node_index, *ni)) + .map(|p| range_start + p); + + // Add an edge going from the found vertex to this one, in case it + // doesn't exist (we only know there's not a path going the other way). + if let Some(preceding_node_index) = + previous_node_position.and_then(|p| new_load_order.get(p)) + { + self.add_edge(*preceding_node_index, node_index, EdgeType::TieBreak); + } + + // Insert position is just after the found vertex, and a forward iterator + // points to the element one after the element pointed to by the + // corresponding reverse iterator. + let insert_position = previous_node_position.map_or(range_start, |i| i + 1); + + // Add an edge going from this vertex to the next one in the "new load + // order" path, in case there isn't already one. + if let Some(following_node_index) = new_load_order.get(insert_position) { + self.add_edge(node_index, *following_node_index, EdgeType::TieBreak); + } + + // Now update newLoadOrder with the vertex's new position. + new_load_order.insert(insert_position, node_index); + processed_nodes.insert(node_index); + + if is_log_enabled(LogLevel::Debug) { + if let Some(next_node_index) = new_load_order.get(insert_position + 1) { + logging::debug!( + "The plugin \"{}\" loads before \"{}\" in the new load order.", + self[node_index].name(), + self[*next_node_index].name() + ); + } else { + logging::debug!( + "The plugin \"{}\" loads at the end of the new load order so far.", + self[node_index].name() + ); + } + } + + // Return a new value for reverseEndIt, pointing to the newly + // inserted vertex, as if it was not the last vertex in a path + // being processed the next vertex in the path by definition + // cannot load before this one, so we can save an unnecessary + // check by using this new reverseEndIt value when pinning the + // next vertex. + insert_position + 1 + } + + fn topological_sort(&self) -> Result, SortingError> { + petgraph::algo::toposort(&self.inner, None) + .map_err(|e| SortingError::CycleInvolving(self[e.node_id()].name().to_owned())) + } + + /// Returns the first pair of consecutive nodes that don't have an edge joining them. + fn check_path_is_hamiltonian(&mut self, path: &[NodeIndex]) -> Option<(NodeIndex, NodeIndex)> { + use std::ops::Not; + + logging::trace!("Checking uniqueness of path through plugin graph..."); + + path.windows(2).find_map(|slice| match *slice { + [a, b] => self.inner.contains_edge(a, b).not().then_some((a, b)), + _ => None, + }) + } + + fn cache_path(&mut self, from: NodeIndex, to: NodeIndex) { + self.paths_cache.entry(from).or_default().insert(to); + } + + fn is_path_cached(&self, from: NodeIndex, to: NodeIndex) -> bool { + self.paths_cache.get(&from).is_some_and(|s| s.contains(&to)) + } + + fn node_index_by_name(&self, name: &str) -> Option { + self.node_indices() + .find(|i| unicase::eq(self[*i].name(), name)) + } + + fn path_exists(&mut self, from: NodeIndex, to: NodeIndex) -> bool { + if self.is_path_cached(from, to) { + return true; + } + + let mut visitor = PathCacher::new(&mut self.paths_cache, from, to); + + bidirectional_bfs(&self.inner, from, to, &mut visitor) + } + + fn find_path( + &mut self, + from: NodeIndex, + to: NodeIndex, + ) -> Result>, PathfindingError> { + let mut path_finder = PathFinder::new(&self.inner, &mut self.paths_cache, from, to); + + if bidirectional_bfs(&self.inner, from, to, &mut path_finder) { + path_finder.path() + } else { + Ok(None) + } + } +} + +// The derive macro for Default requires T: Default, but it's not actually necessary. +impl std::default::Default for PluginsGraph<'_, T> { + fn default() -> Self { + Self { + inner: Graph::default(), + paths_cache: HashMap::default(), + } + } +} + +impl<'a, T: SortingPlugin> std::ops::Index for PluginsGraph<'a, T> { + type Output = Rc>; + + fn index(&self, index: NodeIndex) -> &Self::Output { + &self.inner[index] + } +} + +pub fn sort_plugins( + mut plugins_sorting_data: Vec>, + groups_graph: &GroupsGraph, + early_loading_plugins: &[String], +) -> Result, SortingError> { + if plugins_sorting_data.is_empty() { + return Ok(Vec::new()); + } + + validate_plugin_groups(&plugins_sorting_data, groups_graph)?; + + // Sort the plugins according to the lexicographical order of their names. + // This ensures a consistent iteration order for vertices given the same + // input data. The vertex iteration order can affect what edges get added + // and so the final sorting result, so consistency is important. This order + // needs to be independent of any state (e.g. the current load order) so + // that sorting and applying the result doesn't then produce a different + // result if you then sort again. + plugins_sorting_data.sort_by(|a, b| a.name().cmp(b.name())); + + // Some parts of sorting are O(N^2) for N plugins, and master flags cause + // O(M*N) edges to be added for M masters and N non-masters, which can be + // two thirds of all edges added. The cost of each bidirectional search + // scales with the number of edges, so reducing edges makes searches + // faster. + // Similarly, blueprint plugins load after all others. + // As such, sort plugins using three separate graphs for masters, + // non-masters and blueprint plugins. This means that any edges that go from a + // non-master to a master are effectively ignored, so won't cause cyclic + // interaction errors. Edges going the other way will also effectively be + // ignored, but that shouldn't have a noticeable impact. + let (masters, non_masters): (Vec<_>, Vec<_>) = + plugins_sorting_data.into_iter().partition(|p| p.is_master); + + let (masters, blueprint_masters): (Vec<_>, Vec<_>) = + masters.into_iter().partition(|p| !p.is_blueprint_master()); + + validate_specific_and_hardcoded_edges( + &masters, + &blueprint_masters, + &non_masters, + early_loading_plugins, + )?; + + let mut masters_load_order = + sort_plugins_partition(masters, groups_graph, early_loading_plugins)?; + + let blueprint_masters_load_order = + sort_plugins_partition(blueprint_masters, groups_graph, early_loading_plugins)?; + + let non_masters_load_order = + sort_plugins_partition(non_masters, groups_graph, early_loading_plugins)?; + + masters_load_order.extend(non_masters_load_order); + masters_load_order.extend(blueprint_masters_load_order); + + Ok(masters_load_order) +} + +fn sort_plugins_partition( + plugins_sorting_data: Vec>, + groups_graph: &GroupsGraph, + early_loading_plugins: &[String], +) -> Result, SortingError> { + let mut graph = PluginsGraph::new(); + + for plugin in plugins_sorting_data { + graph.add_node(plugin); + } + + graph.add_specific_edges()?; + graph.add_early_loading_plugin_edges(early_loading_plugins); + + // Check for cycles now because from this point on edges are only added if + // they don't cause cycles, and adding overlap and tie-break edges is + // relatively slow, so checking now provides quicker feedback if there is an + // issue. + graph.check_for_cycles()?; + + graph.add_group_edges(groups_graph)?; + graph.add_overlap_edges()?; + graph.add_tie_break_edges()?; + + // Check for cycles again, just in case there's a bug that lets some occur. + // The check doesn't take a significant amount of time. + graph.check_for_cycles()?; + + let sorted_nodes = graph.topological_sort()?; + + if let Some((first, second)) = graph.check_path_is_hamiltonian(&sorted_nodes) { + logging::error!( + "The path is not unique. No edge exists between {} and {}", + graph[first].name(), + graph[second].name() + ); + } + + let sorted_plugin_names = sorted_nodes + .into_iter() + .map(|i| graph[i].name().to_owned()) + .collect(); + + Ok(sorted_plugin_names) +} + +fn path_to_string(graph: &InnerPluginsGraph, path: &[NodeIndex]) -> String { + path.iter() + .map(|i| graph[*i].name()) + .collect::>() + .join(", ") +} + +#[derive(Debug)] +struct PathFinder<'a, 'b, T: SortingPlugin> { + graph: &'a InnerPluginsGraph<'b, T>, + cache: &'a mut HashMap>, + from_node_index: NodeIndex, + to_node_index: NodeIndex, + forward_parents: HashMap, + reverse_children: HashMap, + intersection_node: Option, +} + +impl<'a, 'b, T: SortingPlugin> PathFinder<'a, 'b, T> { + fn new( + graph: &'a InnerPluginsGraph<'b, T>, + cache: &'a mut HashMap>, + from_node_index: NodeIndex, + to_node_index: NodeIndex, + ) -> Self { + Self { + graph, + cache, + from_node_index, + to_node_index, + forward_parents: HashMap::default(), + reverse_children: HashMap::default(), + intersection_node: None, + } + } + + fn cache_path(&mut self, from: NodeIndex, to: NodeIndex) { + self.cache.entry(from).or_default().insert(to); + } + + fn path(&self) -> Result>, PathfindingError> { + match self.intersection_node { + None => Ok(None), + Some(intersection_node) => { + let mut current_node = intersection_node; + let mut path = vec![current_node]; + + while current_node != self.from_node_index { + if let Some(next) = self.forward_parents.get(¤t_node) { + path.push(*next); + current_node = *next; + } else { + logging::error!( + "Could not find parent vertex of {}. Path so far is {}", + self.graph[current_node].name(), + path_to_string(self.graph, &path) + ); + return Err(PathfindingError::PrecedingNodeNotFound( + self.graph[current_node].name().to_owned(), + )); + } + } + + // The path currently runs backwards, so reverse it. + path.reverse(); + + current_node = intersection_node; + + while current_node != self.to_node_index { + if let Some(next) = self.reverse_children.get(¤t_node) { + path.push(*next); + current_node = *next; + } else { + logging::error!( + "Could not find child vertex of {}. Path so far is {}", + self.graph[current_node].name(), + path_to_string(self.graph, &path) + ); + return Err(PathfindingError::FollowingNodeNotFound( + self.graph[current_node].name().to_owned(), + )); + } + } + + Ok(Some(path)) + } + } + } +} + +impl BidirBfsVisitor for PathFinder<'_, '_, T> { + fn visit_forward_bfs_edge(&mut self, source: NodeIndex, target: NodeIndex) { + self.cache_path(self.from_node_index, target); + + self.forward_parents.insert(target, source); + } + + fn visit_reverse_bfs_edge(&mut self, source: NodeIndex, target: NodeIndex) { + self.cache_path(source, self.to_node_index); + + self.reverse_children.insert(source, target); + } + + fn visit_intersection_node(&mut self, node: NodeIndex) { + self.intersection_node = Some(node); + } +} + +#[derive(Debug)] +struct PathCacher<'a> { + cache: &'a mut HashMap>, + from_node_index: NodeIndex, + to_node_index: NodeIndex, +} + +fn get_plugins_in_groups( + graph: &InnerPluginsGraph, +) -> HashMap, Vec> { + let mut plugins_in_groups: HashMap, Vec> = HashMap::default(); + + for node in graph.node_indices() { + let group_name = graph[node].group.clone(); + + plugins_in_groups.entry(group_name).or_default().push(node); + } + + if is_log_enabled(LogLevel::Debug) { + logging::debug!("Found the following plugins in groups:"); + let mut keys = plugins_in_groups.keys().collect::>(); + keys.sort(); + for key in keys { + let plugin_names: Vec<_> = plugins_in_groups + .get(key) + .into_iter() + .flatten() + .map(|i| format!("\"{}\"", graph[*i].name())) + .collect(); + logging::debug!("\t{}: {}", key, plugin_names.join(", ")); + } + } + + plugins_in_groups +} + +impl<'a> PathCacher<'a> { + fn new( + cache: &'a mut HashMap>, + from_node_index: NodeIndex, + to_node_index: NodeIndex, + ) -> Self { + Self { + cache, + from_node_index, + to_node_index, + } + } + + fn cache_path(&mut self, from: NodeIndex, to: NodeIndex) { + self.cache.entry(from).or_default().insert(to); + } +} + +impl BidirBfsVisitor for PathCacher<'_> { + fn visit_forward_bfs_edge(&mut self, _: NodeIndex, target: NodeIndex) { + self.cache_path(self.from_node_index, target); + } + + fn visit_reverse_bfs_edge(&mut self, source: NodeIndex, _: NodeIndex) { + self.cache_path(source, self.to_node_index); + } + + fn visit_intersection_node(&mut self, _: NodeIndex) {} +} + +// Use type aliases to make intent clearer without the complications of introducing newtypes. +type PluginNodeIndex = NodeIndex; +type GroupNodeIndex = NodeIndex; + +struct GroupsPathVisitor<'a, 'b, 'c, 'd, 'e, T: SortingPlugin> { + plugins_graph: &'a mut PluginsGraph<'b, T>, + groups_graph: &'e GroupsGraph, + groups_plugins: &'c HashMap, Vec>, + finished_group_vertices: &'d mut HashSet, + group_node_to_ignore_as_source: Option, + edge_stack: Vec<(EdgeReference<'e, EdgeType>, &'c [PluginNodeIndex])>, + unfinishable_nodes: HashSet, +} + +impl<'a, 'b, 'c, 'd, 'e, T: SortingPlugin> GroupsPathVisitor<'a, 'b, 'c, 'd, 'e, T> { + fn new( + plugins_graph: &'a mut PluginsGraph<'b, T>, + groups_graph: &'e GroupsGraph, + groups_plugins: &'c HashMap, Vec>, + finished_group_vertices: &'d mut HashSet, + group_node_to_ignore_as_source: Option, + ) -> Self { + Self { + plugins_graph, + groups_graph, + groups_plugins, + finished_group_vertices, + group_node_to_ignore_as_source, + edge_stack: Vec::new(), + unfinishable_nodes: HashSet::default(), + } + } + + fn should_ignore_source_node(&self, node_index: GroupNodeIndex) -> bool { + self.group_node_to_ignore_as_source == Some(node_index) + || self.finished_group_vertices.contains(&node_index) + } + + fn find_plugins_in_group(&self, node_index: GroupNodeIndex) -> &'c [PluginNodeIndex] { + self.groups_plugins + .get(self.groups_graph[node_index].as_ref()) + .map(Vec::as_slice) + .unwrap_or_default() + } + + fn add_plugin_graph_edges( + &mut self, + edge_stack_index: usize, + target_plugins: &[PluginNodeIndex], + ) { + use std::fmt::Write; + + let Some([from_edge, edges @ ..]) = self.edge_stack.get(edge_stack_index..) else { + if is_log_enabled(LogLevel::Error) { + logging::error!( + "Unexpected invalid edge stack index {} for edge stack [{}]", + edge_stack_index, + self.edge_stack + .iter() + .map(|e| e.0.weight()) + .fold(String::new(), |mut a, b| if a.is_empty() { + b.to_string() + } else { + let _e = write!(a, ", {b}"); + a + }) + ); + } + return; + }; + + let path_involves_user_metadata = std::iter::once(from_edge) + .chain(edges.iter()) + .any(|p| *p.0.weight() == EdgeType::UserLoadAfter); + + for from_plugin in from_edge.1 { + self.add_edges_from_plugin(*from_plugin, target_plugins, path_involves_user_metadata); + } + } + + fn add_edges_from_plugin( + &mut self, + from_plugin: PluginNodeIndex, + to_plugins: &[PluginNodeIndex], + path_involves_user_metadata: bool, + ) { + if to_plugins.is_empty() { + return; + } + + for to_plugin in to_plugins { + if !self.plugins_graph.is_path_cached(from_plugin, *to_plugin) { + let involves_user_metadata = path_involves_user_metadata + || self.plugins_graph[from_plugin].group_is_user_metadata + || self.plugins_graph[*to_plugin].group_is_user_metadata; + + let edge_type = if involves_user_metadata { + EdgeType::UserGroup + } else { + EdgeType::MasterlistGroup + }; + + if self.plugins_graph.path_exists(*to_plugin, from_plugin) { + logging::debug!( + "Skipping a \"{}\" edge from \"{}\" to \"{}\" as it would create a cycle.", + edge_type, + self.plugins_graph[from_plugin].name(), + self.plugins_graph[*to_plugin].name() + ); + } else { + self.plugins_graph + .add_edge(from_plugin, *to_plugin, edge_type); + } + } + } + } +} + +impl<'e, T: SortingPlugin> DfsVisitor<'e> for GroupsPathVisitor<'_, '_, '_, '_, 'e, T> { + fn visit_tree_edge(&mut self, edge_ref: EdgeReference<'e, EdgeType>) { + let source = edge_ref.source(); + let target = edge_ref.target(); + + // Add the edge to the stack so that its providence can be taken into + // account when adding edges from this source group and previous groups' + // plugins. + // Also record the plugins in the edge's source group, unless the source + // group should be ignored (e.g. because the visitor has been configured + // to ignore the default group's plugins as sources). + let edge_plugins = if self.should_ignore_source_node(source) { + &[] + } else { + self.find_plugins_in_group(source) + }; + self.edge_stack.push((edge_ref, edge_plugins)); + + // Find the plugins in the target group. + let target_plugins = self.find_plugins_in_group(target); + + // Add edges going from all the plugins in the groups in the path being + // currently walked, to the plugins in the current target group's plugins. + for i in 0..self.edge_stack.len() { + self.add_plugin_graph_edges(i, target_plugins); + } + } + + fn visit_forward_or_cross_edge(&mut self, edge_ref: EdgeReference<'e, EdgeType>) { + // Mark the source vertex and all edges in the current stack as + // unfinishable, because none of the plugins in the path so far can have + // edges added to plugins past the target vertex. + + logging::debug!( + "Found groups graph forward or cross \"{}\" edge going from \"{}\" to \"{}\"", + edge_ref.weight(), + self.groups_graph[edge_ref.source()], + self.groups_graph[edge_ref.target()] + ); + + let iter = self + .edge_stack + .iter() + .map(|e| e.0.source()) + .chain(std::iter::once(edge_ref.source())); + + for source in iter { + let inserted = self.unfinishable_nodes.insert(source); + + if inserted { + logging::debug!("Treating \"{}\" as unfinishable", self.groups_graph[source]); + } + } + } + + fn visit_back_edge(&mut self, _: EdgeReference<'e, EdgeType>) {} + + fn discover_node(&mut self, _: GroupNodeIndex) {} + + fn finish_node(&mut self, node_index: GroupNodeIndex) { + // Now that this vertex's DFS-tree has been fully explored, mark it as + // finished so that it won't have edges added from its plugins again in a + // different DFS that uses the same finished vertices set. + if self.group_node_to_ignore_as_source != Some(node_index) + && !self.unfinishable_nodes.contains(&node_index) + { + let inserted = self.finished_group_vertices.insert(node_index); + if inserted { + logging::debug!( + "Recorded groups graph vertex \"{}\" as finished", + self.groups_graph[node_index] + ); + } + } + + // Since this vertex has been fully explored, pop the edge stack to remove + // the edge that has this vertex as its target. + self.edge_stack.pop(); + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::many_single_char_names)] + use super::*; + + use crate::sorting::{groups::build_groups_graph, test::TestPlugin}; + + const PLUGIN_A: &str = "A.esp"; + const PLUGIN_B: &str = "B.esp"; + + struct Fixture { + groups_graph: GroupsGraph, + plugins: HashMap, + } + + impl Fixture { + fn with_plugins(plugin_names: &[&str]) -> Self { + let masterlist = &[ + Group::new("A".into()), + Group::new("B".into()).with_after_groups(vec!["A".into()]), + Group::new("C".into()), + Group::new("default".into()).with_after_groups(vec!["C".into()]), + Group::new("E".into()).with_after_groups(vec!["default".into()]), + Group::new("F".into()).with_after_groups(vec!["E".into()]), + ]; + let userlist = &[Group::new("C".into()).with_after_groups(vec!["B".into()])]; + let groups_graph = build_groups_graph(masterlist, userlist).unwrap(); + + Self { + groups_graph, + plugins: plugin_names + .iter() + .enumerate() + .map(|(i, n)| ((*n).to_owned(), (TestPlugin::new(n), i))) + .collect(), + } + } + + fn get_plugin(&self, name: &str) -> &(TestPlugin, usize) { + &self.plugins[name] + } + + fn get_plugin_mut(&mut self, name: &str) -> &mut TestPlugin { + &mut self.plugins.get_mut(name).unwrap().0 + } + + fn sorting_data<'a>(&'a self, name: &str) -> PluginSortingData<'a, TestPlugin> { + let (plugin, index) = self.get_plugin(name); + + PluginSortingData::new(plugin, None, None, *index).unwrap() + } + + fn group_sorting_data<'a>( + &'a self, + name: &str, + group_name: &str, + ) -> PluginSortingData<'a, TestPlugin> { + let (plugin, index) = self.get_plugin(name); + + let mut metadata = PluginMetadata::new(name).unwrap(); + metadata.set_group(group_name.into()); + + PluginSortingData::new(plugin, Some(&metadata), None, *index).unwrap() + } + + fn user_group_sorting_data<'a>( + &'a self, + name: &str, + group_name: &str, + ) -> PluginSortingData<'a, TestPlugin> { + let (plugin, index) = self.get_plugin(name); + + let mut metadata = PluginMetadata::new(name).unwrap(); + metadata.set_group(group_name.into()); + + PluginSortingData::new(plugin, None, Some(&metadata), *index).unwrap() + } + } + + mod plugin_sorting_data { + use crate::tests::BLANK_ESM; + + use super::*; + + #[test] + fn is_blueprint_master_should_be_true_if_a_plugin_is_a_master_and_a_blueprint_plugin() { + let mut master = TestPlugin::new(BLANK_ESM); + master.is_master = true; + let mut blueprint_plugin = TestPlugin::new(BLANK_ESM); + blueprint_plugin.is_blueprint_plugin = true; + let mut blueprint_master = TestPlugin::new(BLANK_ESM); + blueprint_master.is_master = true; + blueprint_master.is_blueprint_plugin = true; + + let plugin = PluginSortingData::new(&master, None, None, 0).unwrap(); + assert!(!plugin.is_blueprint_master()); + + let plugin = PluginSortingData::new(&blueprint_plugin, None, None, 0).unwrap(); + assert!(!plugin.is_blueprint_master()); + + let plugin = PluginSortingData::new(&blueprint_master, None, None, 0).unwrap(); + assert!(plugin.is_blueprint_master()); + } + } + + mod plugins_graph { + use super::*; + + use crate::Vertex; + + const PLUGIN_C: &str = "C.esp"; + const PLUGIN_D: &str = "D.esp"; + const PLUGIN_E: &str = "E.esp"; + + fn edge_type( + graph: &PluginsGraph<'_, TestPlugin>, + from: NodeIndex, + to: NodeIndex, + ) -> EdgeType { + *graph + .inner + .edge_weight(graph.inner.find_edge(from, to).unwrap()) + .unwrap() + } + + mod check_for_cycles { + use super::*; + + #[test] + fn should_succeed_if_there_is_no_cycle() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let mut graph = PluginsGraph::new(); + let a = graph.add_node(fixture.sorting_data(PLUGIN_A)); + let b = graph.add_node(fixture.sorting_data(PLUGIN_B)); + + graph.add_edge(a, b, EdgeType::Master); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_error_if_there_is_a_cycle() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_C]); + + let mut graph = PluginsGraph::new(); + let a = graph.add_node(fixture.sorting_data(PLUGIN_A)); + let b = graph.add_node(fixture.sorting_data(PLUGIN_B)); + + graph.add_edge(a, b, EdgeType::Master); + graph.add_edge(b, a, EdgeType::Master); + + let cycle = graph.check_for_cycles().unwrap_err().into_cycle(); + + assert_eq!( + &[ + Vertex::new(PLUGIN_A.into()).with_out_edge_type(EdgeType::Master), + Vertex::new(PLUGIN_B.into()).with_out_edge_type(EdgeType::Master), + ], + cycle.as_slice() + ); + } + + #[test] + fn should_only_give_plugins_that_are_part_of_the_cycle() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_C]); + + let mut graph = PluginsGraph::new(); + let a = graph.add_node(fixture.sorting_data(PLUGIN_A)); + let b = graph.add_node(fixture.sorting_data(PLUGIN_B)); + let c = graph.add_node(fixture.sorting_data(PLUGIN_C)); + + graph.add_edge(a, b, EdgeType::Master); + graph.add_edge(b, c, EdgeType::Master); + graph.add_edge(b, a, EdgeType::MasterFlag); + + let cycle = graph.check_for_cycles().unwrap_err().into_cycle(); + + assert_eq!( + &[ + Vertex::new(PLUGIN_A.into()).with_out_edge_type(EdgeType::Master), + Vertex::new(PLUGIN_B.into()).with_out_edge_type(EdgeType::MasterFlag), + ], + cycle.as_slice() + ); + } + } + + #[test] + fn topological_sort_should_return_empty_list_if_there_are_no_plugins() { + let graph = PluginsGraph::::new(); + let sorted = graph.topological_sort().unwrap(); + + assert!(sorted.is_empty()); + } + + mod add_early_loading_plugin_edges { + use super::*; + + #[test] + fn should_add_no_edges_if_there_are_no_early_loading_plugins() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_C]); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.sorting_data(PLUGIN_A)); + let b = graph.add_node(fixture.sorting_data(PLUGIN_B)); + let c = graph.add_node(fixture.sorting_data(PLUGIN_C)); + + graph.add_early_loading_plugin_edges(&[]); + + assert!(!graph.inner.contains_edge(a, b)); + assert!(!graph.inner.contains_edge(a, c)); + assert!(!graph.inner.contains_edge(b, a)); + assert!(!graph.inner.contains_edge(b, c)); + assert!(!graph.inner.contains_edge(c, a)); + assert!(!graph.inner.contains_edge(c, b)); + } + + #[test] + fn should_add_edges_between_consecutive_early_loaders_skipping_missing_plugins() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_C, PLUGIN_D]); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.sorting_data(PLUGIN_A)); + let c = graph.add_node(fixture.sorting_data(PLUGIN_C)); + let d = graph.add_node(fixture.sorting_data(PLUGIN_D)); + + graph.add_early_loading_plugin_edges(&[ + PLUGIN_A.into(), + PLUGIN_B.into(), + PLUGIN_C.into(), + PLUGIN_D.into(), + ]); + + assert!(graph.inner.contains_edge(a, c)); + assert!(graph.inner.contains_edge(c, d)); + assert!(!graph.inner.contains_edge(a, d)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_add_edges_from_only_the_last_installed_early_loader_to_all_non_early_loader_plugins() + { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_D, PLUGIN_E]); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.sorting_data(PLUGIN_A)); + let b = graph.add_node(fixture.sorting_data(PLUGIN_B)); + let d = graph.add_node(fixture.sorting_data(PLUGIN_D)); + let e = graph.add_node(fixture.sorting_data(PLUGIN_E)); + + graph.add_early_loading_plugin_edges(&[ + PLUGIN_A.into(), + PLUGIN_B.into(), + PLUGIN_C.into(), + ]); + + assert!(graph.inner.contains_edge(a, b)); + assert!(graph.inner.contains_edge(b, d)); + assert!(graph.inner.contains_edge(b, e)); + assert!(!graph.inner.contains_edge(a, d)); + assert!(!graph.inner.contains_edge(a, e)); + + assert!(graph.check_for_cycles().is_ok()); + } + } + + mod add_group_edges { + use super::*; + + const PLUGIN_A1: &str = "A1.esp"; + const PLUGIN_A2: &str = "A2.esp"; + const PLUGIN_B1: &str = "B1.esp"; + const PLUGIN_B2: &str = "B2.esp"; + const PLUGIN_C1: &str = "C1.esp"; + const PLUGIN_C2: &str = "C2.esp"; + const PLUGIN_D1: &str = "D1.esp"; + const PLUGIN_D2: &str = "D2.esp"; + const PLUGIN_D3: &str = "D3.esp"; + const PLUGIN_F: &str = "F.esp"; + + #[test] + fn should_add_user_group_edge_if_source_plugin_is_in_group_due_to_user_metadata() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.user_group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + assert_eq!(EdgeType::UserGroup, edge_type(&graph, a, b)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_add_user_group_edge_if_target_plugin_is_in_group_due_to_user_metadata() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.user_group_sorting_data(PLUGIN_B, "B")); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + assert_eq!(EdgeType::UserGroup, edge_type(&graph, a, b)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_add_user_group_edge_if_group_path_starts_with_user_metadata() { + let fixture = Fixture::with_plugins(&[PLUGIN_B, PLUGIN_D]); + + let mut graph = PluginsGraph::::new(); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let d = graph.add_node(fixture.sorting_data(PLUGIN_D)); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + assert_eq!(EdgeType::UserGroup, edge_type(&graph, b, d)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_add_user_group_edge_if_group_path_ends_with_user_metadata() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_C]); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + assert_eq!(EdgeType::UserGroup, edge_type(&graph, a, c)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_add_user_group_edge_if_group_path_involves_user_metadata() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_D]); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let d = graph.add_node(fixture.sorting_data(PLUGIN_D)); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + assert_eq!(EdgeType::UserGroup, edge_type(&graph, a, d)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_add_masterlist_group_edge_if_no_user_metadata_is_involved() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + assert_eq!(EdgeType::MasterlistGroup, edge_type(&graph, a, b)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_add_edges_between_plugins_in_indirectly_connected_groups_when_an_intermediate_plugin_edge_is_skipped() + { + let fixture = Fixture::with_plugins(&[ + PLUGIN_A1, PLUGIN_A2, PLUGIN_B1, PLUGIN_B2, PLUGIN_C1, PLUGIN_C2, + ]); + + let mut graph = PluginsGraph::::new(); + let a1 = graph.add_node(fixture.group_sorting_data(PLUGIN_A1, "A")); + let a2 = graph.add_node(fixture.group_sorting_data(PLUGIN_A2, "A")); + let b1 = graph.add_node(fixture.group_sorting_data(PLUGIN_B1, "B")); + let b2 = graph.add_node(fixture.group_sorting_data(PLUGIN_B2, "B")); + let c1 = graph.add_node(fixture.group_sorting_data(PLUGIN_C1, "C")); + let c2 = graph.add_node(fixture.group_sorting_data(PLUGIN_C2, "C")); + + graph.add_edge(b1, a1, EdgeType::Master); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + // Should be A2.esp -> B1.esp -> A1.esp -> B2.esp -> C1.esp + // -> C2.esp + assert!(graph.inner.contains_edge(b1, a1)); + assert!(graph.inner.contains_edge(a1, b2)); + assert!(graph.inner.contains_edge(a2, b1)); + assert!(graph.inner.contains_edge(a2, b2)); + assert!(graph.inner.contains_edge(b1, c1)); + assert!(graph.inner.contains_edge(b1, c2)); + assert!(graph.inner.contains_edge(b2, c1)); + assert!(graph.inner.contains_edge(b2, c2)); + assert!(graph.inner.contains_edge(a1, c1)); + assert!(graph.inner.contains_edge(a1, c2)); + assert!(!graph.inner.contains_edge(c1, c2)); + assert!(!graph.inner.contains_edge(c2, c1)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_add_edges_across_empty_groups() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_C]); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + // Should be A.esp -> C.esp + assert!(graph.inner.contains_edge(a, c)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_add_edges_across_the_non_empty_default_group() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_D, PLUGIN_E]); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let d = graph.add_node(fixture.sorting_data(PLUGIN_D)); + let e = graph.add_node(fixture.group_sorting_data(PLUGIN_E, "E")); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + // Should be A.esp -> D.esp -> E.esp + // ----------> + assert!(graph.inner.contains_edge(a, d)); + assert!(graph.inner.contains_edge(d, e)); + assert!(graph.inner.contains_edge(a, e)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_skip_an_edge_that_would_cause_a_cycle() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_C]); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + + graph.add_edge(c, a, EdgeType::Master); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + assert!(graph.inner.contains_edge(c, a)); + assert!(!graph.inner.contains_edge(a, c)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_skip_an_edge_that_would_cause_a_cycle_involving_other_non_default_groups() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_C]); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + + graph.add_edge(c, a, EdgeType::Master); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + assert!(graph.inner.contains_edge(c, a)); + assert!(graph.inner.contains_edge(a, b)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_skip_only_edges_to_the_target_group_plugins_that_would_cause_a_cycle() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_C1, PLUGIN_C2]); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let c1 = graph.add_node(fixture.group_sorting_data(PLUGIN_C1, "C")); + let c2 = graph.add_node(fixture.group_sorting_data(PLUGIN_C2, "C")); + + graph.add_edge(c1, a, EdgeType::Master); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + // Should be C1.esp -> A.esp -> C2.esp + assert!(graph.inner.contains_edge(c1, a)); + assert!(graph.inner.contains_edge(a, c2)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_skip_only_edges_from_ancestors_to_the_target_group_plugins_that_would_cause_a_cycle() + { + let fixture = + Fixture::with_plugins(&[PLUGIN_B, PLUGIN_C, PLUGIN_D1, PLUGIN_D2, PLUGIN_D3]); + + let mut graph = PluginsGraph::::new(); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d1 = graph.add_node(fixture.sorting_data(PLUGIN_D1)); + let d2 = graph.add_node(fixture.sorting_data(PLUGIN_D2)); + let d3 = graph.add_node(fixture.sorting_data(PLUGIN_D3)); + + graph.add_edge(d1, b, EdgeType::Master); + graph.add_edge(d2, b, EdgeType::Master); + graph.add_edge(c, b, EdgeType::Master); + graph.add_edge(c, d2, EdgeType::Master); + graph.add_edge(c, d3, EdgeType::Master); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + // Should be: C.esp -> D2.esp -> B.esp -> D3.esp + // -> D1.esp -> + // --------------------> + // -----------> + assert!(graph.inner.contains_edge(d1, b)); + assert!(graph.inner.contains_edge(d2, b)); + assert!(graph.inner.contains_edge(c, b)); + assert!(graph.inner.contains_edge(c, d2)); + assert!(graph.inner.contains_edge(c, d3)); + assert!(graph.inner.contains_edge(b, d3)); + assert!(graph.inner.contains_edge(c, d1)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_add_plugin_edges_across_a_successor_if_at_least_one_edge_to_the_successor_group_was_skipped_with_successive_depths() + { + let fixture = Fixture::with_plugins(&[ + PLUGIN_A1, PLUGIN_A2, PLUGIN_B1, PLUGIN_B2, PLUGIN_C1, PLUGIN_C2, + ]); + + let mut graph = PluginsGraph::::new(); + let a1 = graph.add_node(fixture.group_sorting_data(PLUGIN_A1, "A")); + let a2 = graph.add_node(fixture.group_sorting_data(PLUGIN_A2, "A")); + let b1 = graph.add_node(fixture.group_sorting_data(PLUGIN_B1, "B")); + let b2 = graph.add_node(fixture.group_sorting_data(PLUGIN_B2, "B")); + let c1 = graph.add_node(fixture.group_sorting_data(PLUGIN_C1, "C")); + let c2 = graph.add_node(fixture.group_sorting_data(PLUGIN_C2, "C")); + + graph.add_edge(b1, a1, EdgeType::Master); + graph.add_edge(c1, b2, EdgeType::Master); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + // Should be A2.esp -> B1.esp -> A1.esp -> C1.esp -> B2.esp -> C2.esp + assert!(graph.inner.contains_edge(b1, a1)); + assert!(graph.inner.contains_edge(c1, b2)); + assert!(graph.inner.contains_edge(a1, b2)); + assert!(graph.inner.contains_edge(a1, c1)); + assert!(graph.inner.contains_edge(a1, c2)); + assert!(graph.inner.contains_edge(a2, b1)); + assert!(graph.inner.contains_edge(a2, b2)); + assert!(graph.inner.contains_edge(b1, c1)); + assert!(graph.inner.contains_edge(b1, c2)); + assert!(graph.inner.contains_edge(b2, c2)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_add_plugin_edges_across_a_successor_if_at_least_one_edge_to_the_successor_group_was_skipped_with_successive_depths_and_a_different_order() + { + let fixture = Fixture::with_plugins(&[ + PLUGIN_A1, PLUGIN_A2, PLUGIN_B1, PLUGIN_B2, PLUGIN_C1, PLUGIN_C2, + ]); + + let mut graph = PluginsGraph::::new(); + let a1 = graph.add_node(fixture.group_sorting_data(PLUGIN_A1, "A")); + let a2 = graph.add_node(fixture.group_sorting_data(PLUGIN_A2, "A")); + let b1 = graph.add_node(fixture.group_sorting_data(PLUGIN_B1, "B")); + let b2 = graph.add_node(fixture.group_sorting_data(PLUGIN_B2, "B")); + let c1 = graph.add_node(fixture.group_sorting_data(PLUGIN_C1, "C")); + let c2 = graph.add_node(fixture.group_sorting_data(PLUGIN_C2, "C")); + + graph.add_edge(b1, a1, EdgeType::Master); + graph.add_edge(c1, b1, EdgeType::Master); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + // Should be A2.esp -> C1.esp -> B1.esp -> A1.esp -> B2.esp -> C2.esp + assert!(graph.inner.contains_edge(b1, a1)); + assert!(graph.inner.contains_edge(c1, b1)); + assert!(graph.inner.contains_edge(a1, b2)); + assert!(graph.inner.contains_edge(a1, c2)); + assert!(graph.inner.contains_edge(a2, b1)); + assert!(graph.inner.contains_edge(a2, b2)); + assert!(graph.inner.contains_edge(a2, c1)); + assert!(graph.inner.contains_edge(b1, c2)); + assert!(graph.inner.contains_edge(b2, c2)); + assert!(!graph.inner.contains_edge(b2, c1)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_add_edge_from_ancestor_to_successor_if_none_of_a_groups_plugins_can() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B1, PLUGIN_B2, PLUGIN_C]); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b1 = graph.add_node(fixture.group_sorting_data(PLUGIN_B1, "B")); + let b2 = graph.add_node(fixture.group_sorting_data(PLUGIN_B2, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + + graph.add_edge(c, b1, EdgeType::Master); + graph.add_edge(c, b2, EdgeType::Master); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + // Should be A.esp -> C1.esp -> B1.esp + // -> B2.esp + assert!(graph.inner.contains_edge(a, b1)); + assert!(graph.inner.contains_edge(a, b2)); + assert!(graph.inner.contains_edge(c, b1)); + assert!(graph.inner.contains_edge(c, b2)); + assert!(graph.inner.contains_edge(a, c)); + assert!(!graph.inner.contains_edge(b1, b2)); + assert!(!graph.inner.contains_edge(b2, b1)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_add_edge_from_ancestor_to_successor_if_none_of_a_groups_plugins_can_with_edges_across_the_skipped_group() + { + let fixture = Fixture::with_plugins(&[ + PLUGIN_A1, PLUGIN_A2, PLUGIN_B1, PLUGIN_B2, PLUGIN_C1, PLUGIN_C2, PLUGIN_D1, + PLUGIN_D2, + ]); + + let mut graph = PluginsGraph::::new(); + let a1 = graph.add_node(fixture.group_sorting_data(PLUGIN_A1, "A")); + let a2 = graph.add_node(fixture.group_sorting_data(PLUGIN_A2, "A")); + let b1 = graph.add_node(fixture.group_sorting_data(PLUGIN_B1, "B")); + let b2 = graph.add_node(fixture.group_sorting_data(PLUGIN_B2, "B")); + let c1 = graph.add_node(fixture.group_sorting_data(PLUGIN_C1, "C")); + let c2 = graph.add_node(fixture.group_sorting_data(PLUGIN_C2, "C")); + let d1 = graph.add_node(fixture.sorting_data(PLUGIN_D1)); + let d2 = graph.add_node(fixture.sorting_data(PLUGIN_D2)); + + graph.add_edge(b1, a1, EdgeType::Master); + graph.add_edge(c1, b1, EdgeType::Master); + graph.add_edge(d1, c1, EdgeType::Master); + graph.add_edge(d2, c1, EdgeType::Master); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + // Should be: + // A2.esp -> D1.esp -> C1.esp -> B1.esp -> A1.esp -> B2.esp -> C2.esp + // -> D2.esp -> + assert!(graph.inner.contains_edge(b1, a1)); + assert!(graph.inner.contains_edge(c1, b1)); + assert!(graph.inner.contains_edge(d1, c1)); + assert!(graph.inner.contains_edge(d2, c1)); + assert!(graph.inner.contains_edge(a1, b2)); + assert!(graph.inner.contains_edge(a2, b1)); + assert!(graph.inner.contains_edge(a2, b2)); + assert!(graph.inner.contains_edge(a1, c2)); + assert!(graph.inner.contains_edge(b1, c2)); + assert!(graph.inner.contains_edge(a2, c1)); + assert!(graph.inner.contains_edge(a2, d1)); + assert!(graph.inner.contains_edge(a2, d2)); + assert!(!graph.inner.contains_edge(b2, c1)); + assert!(!graph.inner.contains_edge(d1, d2)); + assert!(!graph.inner.contains_edge(d2, d1)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_deprioritise_edges_from_default_group_plugins_with_default_last() { + let fixture = Fixture::with_plugins(&[PLUGIN_B, PLUGIN_C, PLUGIN_D]); + + let mut graph = PluginsGraph::::new(); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d = graph.add_node(fixture.sorting_data(PLUGIN_D)); + + graph.add_edge(d, b, EdgeType::Master); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + // Should be D.esp -> B.esp -> C.esp + assert!(graph.inner.contains_edge(b, c)); + assert!(graph.inner.contains_edge(d, b)); + assert!(!graph.inner.contains_edge(c, d)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_deprioritise_edges_from_default_group_plugins_with_default_first() { + let fixture = Fixture::with_plugins(&[PLUGIN_D, PLUGIN_E, PLUGIN_F]); + + let mut graph = PluginsGraph::::new(); + let d = graph.add_node(fixture.sorting_data(PLUGIN_D)); + let e = graph.add_node(fixture.group_sorting_data(PLUGIN_E, "E")); + let f = graph.add_node(fixture.group_sorting_data(PLUGIN_F, "F")); + + graph.add_edge(f, d, EdgeType::Master); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + // Should be E.esp -> F.esp -> D.esp + assert!(graph.inner.contains_edge(e, f)); + assert!(graph.inner.contains_edge(f, d)); + assert!(!graph.inner.contains_edge(d, e)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_deprioritise_edges_from_default_group_plugins_across_skipped_intermediate_groups() + { + let fixture = Fixture::with_plugins(&[PLUGIN_D, PLUGIN_E, PLUGIN_F]); + + let mut graph = PluginsGraph::::new(); + let d = graph.add_node(fixture.sorting_data(PLUGIN_D)); + let e = graph.add_node(fixture.group_sorting_data(PLUGIN_E, "E")); + let f = graph.add_node(fixture.group_sorting_data(PLUGIN_F, "F")); + + graph.add_edge(e, d, EdgeType::Master); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + // Should be E.esp -> D.esp -> F.esp + assert!(graph.inner.contains_edge(e, d)); + assert!(graph.inner.contains_edge(d, f)); + assert!(!graph.inner.contains_edge(f, e)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_deprioritise_edges_from_default_group_plugins_with_d1_first_d2_last() { + let fixture = Fixture::with_plugins(&[PLUGIN_D1, PLUGIN_D2, PLUGIN_E, PLUGIN_F]); + + let mut graph = PluginsGraph::::new(); + let d1 = graph.add_node(fixture.sorting_data(PLUGIN_D1)); + let d2 = graph.add_node(fixture.sorting_data(PLUGIN_D2)); + let e = graph.add_node(fixture.group_sorting_data(PLUGIN_E, "E")); + let f = graph.add_node(fixture.group_sorting_data(PLUGIN_F, "F")); + + graph.add_edge(f, d2, EdgeType::Master); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + // Should be D1.esp -> E.esp -> F.esp -> D2.esp + assert!(graph.inner.contains_edge(e, f)); + assert!(graph.inner.contains_edge(f, d2)); + assert!(graph.inner.contains_edge(d1, e)); + assert!(!graph.inner.contains_edge(d2, d1)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_deprioritise_edges_from_default_group_plugins_with_no_ideal_result() { + let fixture = + Fixture::with_plugins(&[PLUGIN_B, PLUGIN_C, PLUGIN_D, PLUGIN_E, PLUGIN_F]); + + let mut graph = PluginsGraph::::new(); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d = graph.add_node(fixture.sorting_data(PLUGIN_D)); + let e = graph.add_node(fixture.group_sorting_data(PLUGIN_E, "E")); + let f = graph.add_node(fixture.group_sorting_data(PLUGIN_F, "F")); + + graph.add_edge(d, b, EdgeType::Master); + graph.add_edge(f, d, EdgeType::Master); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + // No ideal result, expected is F.esp -> D.esp -> B.esp -> C.esp -> E.esp + assert!(graph.inner.contains_edge(f, d)); + assert!(graph.inner.contains_edge(d, b)); + assert!(graph.inner.contains_edge(b, c)); + assert!(graph.inner.contains_edge(c, e)); + assert!(!graph.inner.contains_edge(e, f)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_deprioritise_edges_from_default_group_plugins_with_default_in_middle_and_d_bookends() + { + let fixture = Fixture::with_plugins(&[ + PLUGIN_B, PLUGIN_C, PLUGIN_D1, PLUGIN_D2, PLUGIN_E, PLUGIN_F, + ]); + + let mut graph = PluginsGraph::::new(); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d1 = graph.add_node(fixture.sorting_data(PLUGIN_D1)); + let d2 = graph.add_node(fixture.sorting_data(PLUGIN_D2)); + let e = graph.add_node(fixture.group_sorting_data(PLUGIN_E, "E")); + let f = graph.add_node(fixture.group_sorting_data(PLUGIN_F, "F")); + + graph.add_edge(d2, b, EdgeType::Master); + graph.add_edge(f, d1, EdgeType::Master); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + // Should be D2.esp -> B.esp -> C.esp -> E.esp -> F.esp -> D1.esp + assert!(graph.inner.contains_edge(d2, b)); + assert!(graph.inner.contains_edge(b, c)); + assert!(graph.inner.contains_edge(c, e)); + assert!(graph.inner.contains_edge(e, f)); + assert!(graph.inner.contains_edge(f, d1)); + assert!(!graph.inner.contains_edge(d1, d2)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_deprioritise_edges_from_default_group_plugins_with_default_in_middle_and_d_throughout() + { + const PLUGIN_D4: &str = "D4.esp"; + let fixture = Fixture::with_plugins(&[ + PLUGIN_B, PLUGIN_C, PLUGIN_D1, PLUGIN_D2, PLUGIN_D3, PLUGIN_D4, PLUGIN_E, + PLUGIN_F, + ]); + + let mut graph = PluginsGraph::::new(); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d1 = graph.add_node(fixture.sorting_data(PLUGIN_D1)); + let d2 = graph.add_node(fixture.sorting_data(PLUGIN_D2)); + let d3 = graph.add_node(fixture.sorting_data(PLUGIN_D3)); + let d4 = graph.add_node(fixture.sorting_data(PLUGIN_D4)); + let e = graph.add_node(fixture.group_sorting_data(PLUGIN_E, "E")); + let f = graph.add_node(fixture.group_sorting_data(PLUGIN_F, "F")); + + graph.add_edge(d2, b, EdgeType::Master); + graph.add_edge(d4, c, EdgeType::Master); + graph.add_edge(f, d1, EdgeType::Master); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + // Should be: + // D2.esp -> B.esp -> D4.esp -> C.esp -> D3.esp -> E.esp -> F.esp -> D1.esp + assert!(graph.inner.contains_edge(d2, b)); + assert!(graph.inner.contains_edge(b, c)); + assert!(graph.inner.contains_edge(c, d3)); + assert!(graph.inner.contains_edge(c, e)); + assert!(graph.inner.contains_edge(d3, e)); + assert!(graph.inner.contains_edge(e, f)); + assert!(graph.inner.contains_edge(f, d1)); + assert!(graph.inner.contains_edge(d4, c)); + assert!(graph.inner.contains_edge(b, d4)); + assert!(!graph.inner.contains_edge(d1, d2)); + assert!(!graph.inner.contains_edge(d1, d3)); + assert!(!graph.inner.contains_edge(d1, d4)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_handle_asymmetric_branches_in_the_groups_graph() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_D]); + + let groups_graph = build_groups_graph( + &[ + Group::new("A".into()), + Group::new("B".into()).with_after_groups(vec!["A".into()]), + Group::new("C".into()).with_after_groups(vec!["B".into()]), + Group::new("D".into()).with_after_groups(vec!["A".into()]), + Group::default(), + ], + &[], + ) + .unwrap(); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d = graph.add_node(fixture.group_sorting_data(PLUGIN_D, "D")); + + graph.add_group_edges(&groups_graph).unwrap(); + + // Should be A.esp -> B.esp -> C.esp + // -> D.esp + assert!(graph.inner.contains_edge(a, b)); + assert!(graph.inner.contains_edge(b, c)); + assert!(graph.inner.contains_edge(a, d)); + assert!(!graph.inner.contains_edge(d, b)); + assert!(!graph.inner.contains_edge(d, c)); + assert!(!graph.inner.contains_edge(b, d)); + assert!(!graph.inner.contains_edge(c, d)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_handle_asymmetric_branches_in_the_groups_graph_that_merge() { + let fixture = + Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_D, PLUGIN_E]); + + let groups_graph = build_groups_graph( + &[ + Group::new("A".into()), + Group::new("B".into()).with_after_groups(vec!["A".into()]), + Group::new("C".into()).with_after_groups(vec!["B".into()]), + Group::new("D".into()).with_after_groups(vec!["A".into()]), + Group::new("E".into()).with_after_groups(vec!["C".into(), "D".into()]), + Group::default(), + ], + &[], + ) + .unwrap(); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d = graph.add_node(fixture.group_sorting_data(PLUGIN_D, "D")); + let e = graph.add_node(fixture.group_sorting_data(PLUGIN_E, "E")); + + graph.add_group_edges(&groups_graph).unwrap(); + + // Should be A.esp -> B.esp -> C.esp -> E.esp + // -> D.esp ----------> + assert!(graph.inner.contains_edge(a, b)); + assert!(graph.inner.contains_edge(b, c)); + assert!(graph.inner.contains_edge(c, e)); + assert!(graph.inner.contains_edge(a, d)); + assert!(graph.inner.contains_edge(d, e)); + assert!(!graph.inner.contains_edge(d, b)); + assert!(!graph.inner.contains_edge(d, c)); + assert!(!graph.inner.contains_edge(b, d)); + assert!(!graph.inner.contains_edge(c, d)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_handle_branches_in_the_groups_graph_that_form_a_diamond_pattern() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_D]); + + let groups_graph = build_groups_graph( + &[ + Group::new("A".into()), + Group::new("B".into()).with_after_groups(vec!["A".into()]), + Group::new("C".into()).with_after_groups(vec!["A".into()]), + Group::new("D".into()).with_after_groups(vec!["B".into(), "C".into()]), + Group::default(), + ], + &[], + ) + .unwrap(); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d = graph.add_node(fixture.group_sorting_data(PLUGIN_D, "D")); + + graph.add_group_edges(&groups_graph).unwrap(); + + // Should be A.esp -> B.esp -> D.esp + // -> C.esp -> + assert!(graph.inner.contains_edge(a, b)); + assert!(graph.inner.contains_edge(b, d)); + assert!(graph.inner.contains_edge(a, c)); + assert!(graph.inner.contains_edge(c, d)); + assert!(!graph.inner.contains_edge(b, c)); + assert!(!graph.inner.contains_edge(c, b)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_add_edges_across_the_merge_point_of_branches_in_the_groups_graph() { + let fixture = + Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_D, PLUGIN_E]); + + let groups_graph = build_groups_graph( + &[ + Group::new("A".into()), + Group::new("B".into()).with_after_groups(vec!["A".into()]), + Group::new("C".into()).with_after_groups(vec!["A".into()]), + Group::new("D".into()).with_after_groups(vec!["B".into(), "C".into()]), + Group::new("E".into()).with_after_groups(vec!["D".into()]), + Group::default(), + ], + &[], + ) + .unwrap(); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d = graph.add_node(fixture.group_sorting_data(PLUGIN_D, "D")); + let e = graph.add_node(fixture.group_sorting_data(PLUGIN_E, "E")); + + graph.add_edge(d, c, EdgeType::Master); + + graph.add_group_edges(&groups_graph).unwrap(); + + // Should be A.esp -> B.esp -> D.esp -> C.esp -> E.esp + assert!(graph.inner.contains_edge(d, c)); + assert!(graph.inner.contains_edge(a, b)); + assert!(graph.inner.contains_edge(b, d)); + assert!(graph.inner.contains_edge(d, e)); + assert!(graph.inner.contains_edge(a, c)); + assert!(graph.inner.contains_edge(c, e)); + assert!(!graph.inner.contains_edge(b, c)); + assert!(!graph.inner.contains_edge(c, b)); + assert!(!graph.inner.contains_edge(c, d)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_handle_a_groups_graph_with_multiple_successive_branches() { + const PLUGIN_G: &str = "G.esp"; + let fixture = Fixture::with_plugins(&[ + PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_D, PLUGIN_E, PLUGIN_F, PLUGIN_G, + ]); + + let groups_graph = build_groups_graph( + &[ + Group::new("A".into()), + Group::new("B".into()).with_after_groups(vec!["A".into()]), + Group::new("C".into()).with_after_groups(vec!["A".into()]), + Group::new("D".into()).with_after_groups(vec!["B".into(), "C".into()]), + Group::new("E".into()).with_after_groups(vec!["D".into()]), + Group::new("F".into()).with_after_groups(vec!["D".into()]), + Group::new("G".into()).with_after_groups(vec!["E".into(), "F".into()]), + Group::default(), + ], + &[], + ) + .unwrap(); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d = graph.add_node(fixture.group_sorting_data(PLUGIN_D, "D")); + let e = graph.add_node(fixture.group_sorting_data(PLUGIN_E, "E")); + let f = graph.add_node(fixture.group_sorting_data(PLUGIN_E, "F")); + let g = graph.add_node(fixture.group_sorting_data(PLUGIN_E, "G")); + + graph.add_group_edges(&groups_graph).unwrap(); + + // Should be: + // A.esp -> B.esp -> D.esp -> E.esp -> G.esp + // -> C.esp -> -> F.esp -> + assert!(graph.inner.contains_edge(a, b)); + assert!(graph.inner.contains_edge(a, c)); + assert!(graph.inner.contains_edge(b, d)); + assert!(graph.inner.contains_edge(c, d)); + assert!(graph.inner.contains_edge(d, e)); + assert!(graph.inner.contains_edge(d, f)); + assert!(graph.inner.contains_edge(e, g)); + assert!(graph.inner.contains_edge(f, g)); + assert!(!graph.inner.contains_edge(b, c)); + assert!(!graph.inner.contains_edge(c, b)); + assert!(!graph.inner.contains_edge(e, f)); + assert!(!graph.inner.contains_edge(f, e)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_find_all_groups_in_all_paths_between_two_groups_when_ignoring_a_plugin() { + let fixture = + Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_D, PLUGIN_E]); + + let groups_graph = build_groups_graph( + &[ + Group::new("A".into()), + Group::new("B".into()).with_after_groups(vec!["A".into()]), + Group::new("C".into()).with_after_groups(vec!["B".into()]), + Group::new("D".into()).with_after_groups(vec!["C".into()]), + Group::default().with_after_groups(vec!["B".into(), "D".into()]), + ], + &[], + ) + .unwrap(); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d = graph.add_node(fixture.group_sorting_data(PLUGIN_D, "D")); + let e = graph.add_node(fixture.sorting_data(PLUGIN_E)); + + graph.add_edge(e, a, EdgeType::Master); + + graph.add_group_edges(&groups_graph).unwrap(); + + // Should be: + // A.esp -> B.esp -> D.esp -> E.esp -> G.esp + // -> C.esp -> -> F.esp -> + assert!(graph.inner.contains_edge(a, b)); + assert!(graph.inner.contains_edge(b, c)); + assert!(graph.inner.contains_edge(c, d)); + + assert!(!graph.inner.contains_edge(a, e)); + assert!(!graph.inner.contains_edge(b, e)); + assert!(!graph.inner.contains_edge(c, e)); + assert!(!graph.inner.contains_edge(d, e)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_handle_isolated_groups() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_C]); + + let groups_graph = build_groups_graph( + &[ + Group::new("A".into()), + Group::new("B".into()).with_after_groups(vec!["A".into()]), + Group::new("C".into()), + Group::default(), + ], + &[], + ) + .unwrap(); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + + graph.add_group_edges(&groups_graph).unwrap(); + + // Should be A.esp -> B.esp + // C.esp + assert!(graph.inner.contains_edge(a, b)); + assert!(!graph.inner.contains_edge(a, c)); + assert!(!graph.inner.contains_edge(c, a)); + assert!(!graph.inner.contains_edge(b, c)); + assert!(!graph.inner.contains_edge(c, b)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_handle_disconnected_group_graphs() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_D]); + + let groups_graph = build_groups_graph( + &[ + Group::new("A".into()), + Group::new("B".into()).with_after_groups(vec!["A".into()]), + Group::new("C".into()), + Group::new("D".into()).with_after_groups(vec!["C".into()]), + Group::default(), + ], + &[], + ) + .unwrap(); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d = graph.add_node(fixture.group_sorting_data(PLUGIN_D, "D")); + + graph.add_group_edges(&groups_graph).unwrap(); + + // Should be A.esp -> B.esp + // C.esp -> D.esp + assert!(graph.inner.contains_edge(a, b)); + assert!(graph.inner.contains_edge(c, d)); + assert!(!graph.inner.contains_edge(a, c)); + assert!(!graph.inner.contains_edge(a, d)); + assert!(!graph.inner.contains_edge(b, c)); + assert!(!graph.inner.contains_edge(b, d)); + assert!(!graph.inner.contains_edge(c, a)); + assert!(!graph.inner.contains_edge(c, b)); + assert!(!graph.inner.contains_edge(d, a)); + assert!(!graph.inner.contains_edge(d, b)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_add_edges_across_the_merge_point_of_two_root_node_paths() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_D]); + + let groups_graph = build_groups_graph( + &[ + Group::new("A".into()), + Group::new("B".into()), + Group::new("C".into()).with_after_groups(vec!["A".into(), "B".into()]), + Group::new("D".into()).with_after_groups(vec!["C".into()]), + Group::default(), + ], + &[], + ) + .unwrap(); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d = graph.add_node(fixture.group_sorting_data(PLUGIN_D, "D")); + + graph.add_edge(c, b, EdgeType::Master); + + graph.add_group_edges(&groups_graph).unwrap(); + + // Should be A.esp -> C.esp -> D.esp + // B.esp ----------> + assert!(graph.inner.contains_edge(c, b)); + assert!(graph.inner.contains_edge(a, c)); + assert!(graph.inner.contains_edge(c, d)); + assert!(graph.inner.contains_edge(b, d)); + assert!(!graph.inner.contains_edge(a, b)); + assert!(!graph.inner.contains_edge(b, a)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_not_depend_on_group_definition_order_if_there_is_a_single_linear_path() { + let fixture = Fixture::with_plugins(&[PLUGIN_B, PLUGIN_C, PLUGIN_D]); + + let masterlists = &[ + [ + Group::new("B".into()), + Group::new("C".into()).with_after_groups(vec!["B".into()]), + Group::default().with_after_groups(vec!["C".into()]), + ], + [ + Group::new("C".into()).with_after_groups(vec!["B".into()]), + Group::new("B".into()), + Group::default().with_after_groups(vec!["C".into()]), + ], + ]; + + for masterlist in masterlists { + let groups_graph = build_groups_graph(masterlist, &[]).unwrap(); + + let mut graph = PluginsGraph::::new(); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d = graph.add_node(fixture.sorting_data(PLUGIN_D)); + + graph.add_edge(d, b, EdgeType::Master); + + graph.add_group_edges(&groups_graph).unwrap(); + + // Should be D.esp -> B.esp -> C.esp + assert!(graph.inner.contains_edge(b, c)); + assert!(graph.inner.contains_edge(d, b)); + assert!(!graph.inner.contains_edge(c, d)); + + assert!(graph.check_for_cycles().is_ok()); + } + } + + #[test] + fn should_not_depend_on_group_definition_order_if_there_are_multiple_roots() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_D]); + + let masterlists = &[ + [ + Group::new("A".into()), + Group::new("B".into()), + Group::new("C".into()).with_after_groups(vec!["A".into(), "B".into()]), + Group::new("D".into()).with_after_groups(vec!["C".into()]), + Group::default(), + ], + [ + Group::new("B".into()), + Group::new("A".into()), + Group::new("C".into()).with_after_groups(vec!["A".into(), "B".into()]), + Group::new("D".into()).with_after_groups(vec!["C".into()]), + Group::default(), + ], + ]; + + for masterlist in masterlists { + let groups_graph = build_groups_graph(masterlist, &[]).unwrap(); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d = graph.add_node(fixture.group_sorting_data(PLUGIN_D, "D")); + + graph.add_edge(d, a, EdgeType::Master); + + graph.add_group_edges(&groups_graph).unwrap(); + + // Should be B.esp -> D.esp -> A.esp -> C.esp + // B.esp -------------------> + assert!(graph.inner.contains_edge(d, a)); + assert!(graph.inner.contains_edge(a, c)); + assert!(graph.inner.contains_edge(b, c)); + assert!(graph.inner.contains_edge(b, d)); + assert!(!graph.inner.contains_edge(a, b)); + assert!(!graph.inner.contains_edge(b, a)); + assert!(!graph.inner.contains_edge(c, d)); + + assert!(graph.check_for_cycles().is_ok()); + } + } + + #[test] + fn should_not_depend_on_branching_group_definition_order() { + let fixture = + Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_D, PLUGIN_E]); + + let masterlists = &[ + [ + Group::new("A".into()), + Group::new("B".into()).with_after_groups(vec!["A".into()]), + Group::new("C".into()).with_after_groups(vec!["A".into()]), + Group::new("D".into()).with_after_groups(vec!["B".into(), "C".into()]), + Group::new("E".into()).with_after_groups(vec!["D".into()]), + Group::default(), + ], + [ + Group::new("A".into()), + Group::new("C".into()).with_after_groups(vec!["A".into()]), + Group::new("B".into()).with_after_groups(vec!["A".into()]), + Group::new("D".into()).with_after_groups(vec!["B".into(), "C".into()]), + Group::new("E".into()).with_after_groups(vec!["D".into()]), + Group::default(), + ], + ]; + + for masterlist in masterlists { + let groups_graph = build_groups_graph(masterlist, &[]).unwrap(); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d = graph.add_node(fixture.group_sorting_data(PLUGIN_D, "D")); + let e = graph.add_node(fixture.group_sorting_data(PLUGIN_E, "E")); + + graph.add_edge(e, c, EdgeType::Master); + + graph.add_group_edges(&groups_graph).unwrap(); + + // Should be A.esp -> B.esp -> D.esp -> E.esp -> C.esp + assert!(graph.inner.contains_edge(a, b)); + assert!(graph.inner.contains_edge(a, c)); + assert!(graph.inner.contains_edge(a, d)); + assert!(graph.inner.contains_edge(a, e)); + assert!(graph.inner.contains_edge(b, d)); + assert!(graph.inner.contains_edge(b, e)); + assert!(graph.inner.contains_edge(d, e)); + assert!(graph.inner.contains_edge(e, c)); + + assert!(!graph.inner.contains_edge(b, c)); + assert!(!graph.inner.contains_edge(c, b)); + assert!(!graph.inner.contains_edge(c, d)); + assert!(!graph.inner.contains_edge(c, e)); + assert!(!graph.inner.contains_edge(d, c)); + + assert!(graph.check_for_cycles().is_ok()); + } + } + + #[test] + fn should_not_depend_on_plugin_graph_node_order() { + let fixture = Fixture::with_plugins(&[PLUGIN_A1, PLUGIN_A2, PLUGIN_B, PLUGIN_C]); + + let groups_graph = build_groups_graph( + &[ + Group::new("A".into()), + Group::new("B".into()).with_after_groups(vec!["A".into()]), + Group::new("C".into()).with_after_groups(vec!["B".into()]), + Group::default(), + ], + &[], + ) + .unwrap(); + + let a1 = (PLUGIN_A1, "A"); + let a2 = (PLUGIN_A2, "A"); + let b = (PLUGIN_B, "B"); + let c = (PLUGIN_C, "C"); + + let variations = &[ + [a1, a2, b, c], + [a1, a2, c, b], + [a1, b, c, a2], + [a1, b, a2, c], + [a1, c, a2, b], + [a1, c, b, a2], + [a2, a1, b, c], + [a2, a1, c, b], + [a2, b, c, a1], + [a2, b, a1, c], + [a2, c, a1, b], + [a2, c, b, a1], + [b, a2, a1, c], + [b, a2, c, a1], + [b, a1, c, a2], + [b, a1, a2, c], + [b, c, a2, a1], + [b, c, a1, a2], + [c, a2, b, a1], + [c, a2, a1, b], + [c, b, a1, a2], + [c, b, a2, a1], + [c, a1, a2, b], + [c, a1, b, a2], + ]; + + for plugins in variations { + let mut graph = PluginsGraph::::new(); + + for plugin in plugins { + graph.add_node(fixture.group_sorting_data(plugin.0, plugin.1)); + } + + let a1 = graph.node_index_by_name(PLUGIN_A1).unwrap(); + let a2 = graph.node_index_by_name(PLUGIN_A2).unwrap(); + let b = graph.node_index_by_name(PLUGIN_B).unwrap(); + let c = graph.node_index_by_name(PLUGIN_C).unwrap(); + + graph.add_edge(c, a1, EdgeType::Master); + + graph.add_group_edges(&groups_graph).unwrap(); + + // Should be A2.esp -> C.esp -> A1.esp -> B.esp + // A2.esp --------------------> + assert!(graph.inner.contains_edge(c, a1)); + assert!(graph.inner.contains_edge(a1, b)); + assert!(graph.inner.contains_edge(a2, b)); + assert!(graph.inner.contains_edge(a2, c)); + assert!(!graph.inner.contains_edge(a1, a2)); + assert!(!graph.inner.contains_edge(a2, a1)); + assert!(!graph.inner.contains_edge(b, c)); + + assert!(graph.check_for_cycles().is_ok()); + } + } + + #[test] + fn should_start_searching_from_root_groups_before_going_in_lexicographical_order() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_D]); + + let groups_graph = build_groups_graph( + &[ + Group::new("D".into()), + Group::new("A".into()).with_after_groups(vec!["D".into()]), + Group::new("B".into()).with_after_groups(vec!["A".into()]), + Group::new("C".into()).with_after_groups(vec!["B".into()]), + Group::default(), + ], + &[], + ) + .unwrap(); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d = graph.add_node(fixture.group_sorting_data(PLUGIN_D, "D")); + + graph.add_edge(c, d, EdgeType::Master); + + graph.add_group_edges(&groups_graph).unwrap(); + + // Should be C.esp -> D.esp -> A.esp -> B.esp + // Processing groups lexicographically would give: + // A.esp -> B.esp -> C.esp -> D.esp + assert!(graph.inner.contains_edge(c, d)); + assert!(graph.inner.contains_edge(d, a)); + assert!(graph.inner.contains_edge(d, b)); + assert!(graph.inner.contains_edge(a, b)); + + assert!(!graph.inner.contains_edge(a, c)); + assert!(!graph.inner.contains_edge(a, d)); + assert!(!graph.inner.contains_edge(b, a)); + assert!(!graph.inner.contains_edge(b, c)); + assert!(!graph.inner.contains_edge(b, d)); + assert!(!graph.inner.contains_edge(d, c)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_start_searching_from_the_root_group_with_the_longest_path() { + let fixture = Fixture::with_plugins(&[ + PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_D, PLUGIN_E, PLUGIN_F, + ]); + + let groups_graph = build_groups_graph( + &[ + Group::new("D".into()), + Group::new("B".into()).with_after_groups(vec!["D".into()]), + Group::new("C".into()).with_after_groups(vec!["B".into()]), + Group::new("A".into()), + Group::new("E".into()).with_after_groups(vec!["C".into(), "A".into()]), + Group::new("F".into()).with_after_groups(vec!["E".into()]), + Group::default(), + ], + &[], + ) + .unwrap(); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d = graph.add_node(fixture.group_sorting_data(PLUGIN_D, "D")); + let e = graph.add_node(fixture.group_sorting_data(PLUGIN_E, "E")); + let f = graph.add_node(fixture.group_sorting_data(PLUGIN_F, "F")); + + graph.add_edge(f, b, EdgeType::Master); + + graph.add_group_edges(&groups_graph).unwrap(); + + // Should be D.esp -> B.esp -> C.esp -> E.esp + // A.esp -> F.esp ----------> B.esp + // A.esp -------------------------------------> E.esp + assert!(graph.inner.contains_edge(d, b)); + assert!(graph.inner.contains_edge(b, c)); + assert!(graph.inner.contains_edge(c, e)); + assert!(graph.inner.contains_edge(a, f)); + assert!(graph.inner.contains_edge(a, e)); + assert!(graph.inner.contains_edge(f, b)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn does_not_start_searching_with_the_longest_path() { + let fixture = + Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_D, PLUGIN_E]); + + let groups_graph = build_groups_graph( + &[ + Group::new("A".into()), + Group::new("B".into()).with_after_groups(vec!["A".into()]), + Group::new("C".into()).with_after_groups(vec!["A".into()]), + Group::new("D".into()).with_after_groups(vec!["C".into()]), + Group::new("E".into()).with_after_groups(vec!["B".into(), "D".into()]), + Group::default(), + ], + &[], + ) + .unwrap(); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d = graph.add_node(fixture.group_sorting_data(PLUGIN_D, "D")); + let e = graph.add_node(fixture.group_sorting_data(PLUGIN_E, "E")); + + graph.add_edge(e, c, EdgeType::Master); + + graph.add_group_edges(&groups_graph).unwrap(); + + // Should be A.esp -> B.esp -> E.esp -> C.esp -> D.esp + assert!(graph.inner.contains_edge(a, b)); + assert!(graph.inner.contains_edge(b, e)); + assert!(graph.inner.contains_edge(e, c)); + assert!(graph.inner.contains_edge(c, d)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_mark_nodes_as_unfinishable_if_a_node_in_their_subtree_is_unfinishable() { + let fixture = Fixture::with_plugins(&[ + PLUGIN_A, PLUGIN_B, PLUGIN_B1, PLUGIN_B2, PLUGIN_C, PLUGIN_D, + ]); + + let groups_graph = build_groups_graph( + &[ + Group::new("A".into()), + Group::new("B".into()).with_after_groups(vec!["A".into()]), + Group::new("C".into()).with_after_groups(vec!["B".into()]), + Group::new("D".into()).with_after_groups(vec!["C".into()]), + Group::default(), + ], + &[ + Group::new("BU1".into()).with_after_groups(vec!["B".into()]), + Group::new("BU2".into()).with_after_groups(vec!["BU1".into()]), + Group::new("C".into()).with_after_groups(vec!["BU2".into()]), + ], + ) + .unwrap(); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let b1 = graph.add_node(fixture.group_sorting_data(PLUGIN_B1, "BU1")); + let b2 = graph.add_node(fixture.group_sorting_data(PLUGIN_B2, "BU2")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d = graph.add_node(fixture.group_sorting_data(PLUGIN_D, "D")); + + graph.add_group_edges(&groups_graph).unwrap(); + + // Should be A.esp -> B.esp -----------------------> C.esp -> D.esp + // -> BU1.esp -> BU2.esp -> + assert!(graph.inner.contains_edge(a, b)); + assert!(graph.inner.contains_edge(a, c)); + assert!(graph.inner.contains_edge(a, d)); + assert!(graph.inner.contains_edge(b, c)); + assert!(graph.inner.contains_edge(b, d)); + assert!(graph.inner.contains_edge(b, b1)); + assert!(graph.inner.contains_edge(b, b2)); + assert!(graph.inner.contains_edge(b1, b2)); + assert!(graph.inner.contains_edge(b1, c)); + assert!(graph.inner.contains_edge(b1, d)); + assert!(graph.inner.contains_edge(b2, c)); + assert!(graph.inner.contains_edge(b2, c)); + assert!(graph.inner.contains_edge(c, d)); + + assert!(graph.check_for_cycles().is_ok()); + } + } + + mod add_overlap_edges { + use super::*; + + #[test] + fn should_not_add_edges_between_non_overlapping_plugins() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.sorting_data(PLUGIN_A)); + let b = graph.add_node(fixture.sorting_data(PLUGIN_B)); + + graph.add_overlap_edges().unwrap(); + + assert!(!graph.inner.contains_edge(a, b)); + assert!(!graph.inner.contains_edge(b, a)); + } + + #[test] + fn should_not_add_edges_between_overlapping_plugins_with_equal_override_counts() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let a = fixture.get_plugin_mut(PLUGIN_A); + a.override_record_count = 1; + a.add_overlapping_records(PLUGIN_B); + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.override_record_count = 1; + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.sorting_data(PLUGIN_A)); + let b = graph.add_node(fixture.sorting_data(PLUGIN_B)); + + graph.add_overlap_edges().unwrap(); + + assert!(!graph.inner.contains_edge(a, b)); + assert!(!graph.inner.contains_edge(b, a)); + } + + #[test] + fn should_add_edge_between_overlapping_plugins_with_unequal_override_counts() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let a = fixture.get_plugin_mut(PLUGIN_A); + a.override_record_count = 2; + a.add_overlapping_records(PLUGIN_B); + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.override_record_count = 1; + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.sorting_data(PLUGIN_A)); + let b = graph.add_node(fixture.sorting_data(PLUGIN_B)); + + graph.add_overlap_edges().unwrap(); + + assert_eq!(EdgeType::RecordOverlap, edge_type(&graph, a, b)); + assert!(!graph.inner.contains_edge(b, a)); + } + + #[test] + fn should_not_add_edge_between_non_overlapping_plugins_with_unequal_override_counts() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let a = fixture.get_plugin_mut(PLUGIN_A); + a.override_record_count = 2; + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.override_record_count = 1; + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.sorting_data(PLUGIN_A)); + let b = graph.add_node(fixture.sorting_data(PLUGIN_B)); + + graph.add_overlap_edges().unwrap(); + + assert!(!graph.inner.contains_edge(a, b)); + assert!(!graph.inner.contains_edge(b, a)); + } + + #[test] + fn should_not_add_edge_between_plugins_with_asset_overlap_and_equal_asset_counts() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let a = fixture.get_plugin_mut(PLUGIN_A); + a.asset_count = 1; + a.add_overlapping_assets(PLUGIN_B); + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.asset_count = 1; + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.sorting_data(PLUGIN_A)); + let b = graph.add_node(fixture.sorting_data(PLUGIN_B)); + + graph.add_overlap_edges().unwrap(); + + assert!(!graph.inner.contains_edge(a, b)); + assert!(!graph.inner.contains_edge(b, a)); + } + + #[test] + fn should_not_add_edge_between_plugins_with_no_asset_overlap_and_unequal_asset_counts() + { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let a = fixture.get_plugin_mut(PLUGIN_A); + a.asset_count = 2; + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.asset_count = 1; + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.sorting_data(PLUGIN_A)); + let b = graph.add_node(fixture.sorting_data(PLUGIN_B)); + + graph.add_overlap_edges().unwrap(); + + assert!(!graph.inner.contains_edge(a, b)); + assert!(!graph.inner.contains_edge(b, a)); + } + + #[test] + fn should_add_edge_between_plugins_with_asset_overlap_and_unequal_asset_counts() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let a = fixture.get_plugin_mut(PLUGIN_A); + a.asset_count = 2; + a.add_overlapping_assets(PLUGIN_B); + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.asset_count = 1; + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.sorting_data(PLUGIN_A)); + let b = graph.add_node(fixture.sorting_data(PLUGIN_B)); + + graph.add_overlap_edges().unwrap(); + + assert_eq!(EdgeType::AssetOverlap, edge_type(&graph, a, b)); + assert!(!graph.inner.contains_edge(b, a)); + } + + #[test] + fn should_add_edge_between_overlapping_plugins_with_asset_overlap_and_equal_override_count_and_unequal_asset_counts() + { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let a = fixture.get_plugin_mut(PLUGIN_A); + a.asset_count = 2; + a.add_overlapping_records(PLUGIN_B); + a.add_overlapping_assets(PLUGIN_B); + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.asset_count = 1; + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.sorting_data(PLUGIN_A)); + let b = graph.add_node(fixture.sorting_data(PLUGIN_B)); + + graph.add_overlap_edges().unwrap(); + + assert_eq!(EdgeType::AssetOverlap, edge_type(&graph, a, b)); + assert!(!graph.inner.contains_edge(b, a)); + } + + #[test] + fn should_add_edge_between_plugins_with_asset_overlap_and_unequal_override_count_and_unequal_asset_counts() + { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let a = fixture.get_plugin_mut(PLUGIN_A); + a.override_record_count = 1; + a.asset_count = 2; + a.add_overlapping_assets(PLUGIN_B); + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.override_record_count = 2; + b.asset_count = 1; + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.sorting_data(PLUGIN_A)); + let b = graph.add_node(fixture.sorting_data(PLUGIN_B)); + + graph.add_overlap_edges().unwrap(); + + assert_eq!(EdgeType::AssetOverlap, edge_type(&graph, a, b)); + assert!(!graph.inner.contains_edge(b, a)); + } + + #[test] + fn should_choose_record_overlap_over_asset_overlap() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let a = fixture.get_plugin_mut(PLUGIN_A); + a.override_record_count = 2; + a.asset_count = 1; + a.add_overlapping_records(PLUGIN_B); + a.add_overlapping_assets(PLUGIN_B); + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.override_record_count = 1; + b.asset_count = 2; + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.sorting_data(PLUGIN_A)); + let b = graph.add_node(fixture.sorting_data(PLUGIN_B)); + + graph.add_overlap_edges().unwrap(); + + assert_eq!(EdgeType::RecordOverlap, edge_type(&graph, a, b)); + assert!(!graph.inner.contains_edge(b, a)); + } + } + + mod add_tie_break_edges { + use super::*; + + const PLUGIN_F: &str = "F.esp"; + const PLUGIN_G: &str = "G.esp"; + const PLUGIN_H: &str = "H.esp"; + const PLUGIN_I: &str = "I.esp"; + const PLUGIN_J: &str = "J.esp"; + + #[test] + fn should_not_error_on_a_graph_with_one_node() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let mut graph = PluginsGraph::::new(); + graph.add_node(fixture.sorting_data(PLUGIN_A)); + + assert!(graph.add_tie_break_edges().is_ok()); + } + + #[test] + fn should_result_in_a_sort_order_equal_to_vertex_creation_order_if_there_are_no_other_edges() + { + let fixture = + Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_D, PLUGIN_E]); + + let mut graph = PluginsGraph::::new(); + graph.add_node(fixture.sorting_data(PLUGIN_A)); + graph.add_node(fixture.sorting_data(PLUGIN_B)); + graph.add_node(fixture.sorting_data(PLUGIN_C)); + graph.add_node(fixture.sorting_data(PLUGIN_D)); + graph.add_node(fixture.sorting_data(PLUGIN_E)); + + graph.add_tie_break_edges().unwrap(); + + let sorted = graph.topological_sort().unwrap(); + + assert!(graph.check_path_is_hamiltonian(&sorted).is_none()); + + let sorted_plugin_names: Vec<_> = sorted + .into_iter() + .map(|i| graph[i].name().to_owned()) + .collect(); + + assert_eq!( + &[PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_D, PLUGIN_E], + sorted_plugin_names.as_slice() + ); + } + + #[test] + fn should_pin_paths_that_prevent_the_vertex_creation_order_from_being_used() { + let fixture = Fixture::with_plugins(&[ + PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_D, PLUGIN_E, PLUGIN_F, PLUGIN_G, PLUGIN_H, + PLUGIN_I, PLUGIN_J, + ]); + + let mut graph = PluginsGraph::::new(); + graph.add_node(fixture.sorting_data(PLUGIN_A)); + graph.add_node(fixture.sorting_data(PLUGIN_B)); + graph.add_node(fixture.sorting_data(PLUGIN_C)); + let d = graph.add_node(fixture.sorting_data(PLUGIN_D)); + let e = graph.add_node(fixture.sorting_data(PLUGIN_E)); + let f = graph.add_node(fixture.sorting_data(PLUGIN_F)); + let g = graph.add_node(fixture.sorting_data(PLUGIN_G)); + let h = graph.add_node(fixture.sorting_data(PLUGIN_H)); + let i = graph.add_node(fixture.sorting_data(PLUGIN_I)); + graph.add_node(fixture.sorting_data(PLUGIN_J)); + + // Add a path g -> h -> i -> f + graph.add_edge(g, h, EdgeType::RecordOverlap); + graph.add_edge(h, i, EdgeType::RecordOverlap); + graph.add_edge(i, f, EdgeType::RecordOverlap); + + // Also add g -> d and i -> e + graph.add_edge(g, d, EdgeType::RecordOverlap); + graph.add_edge(i, e, EdgeType::RecordOverlap); + + graph.add_tie_break_edges().unwrap(); + + let sorted = graph.topological_sort().unwrap(); + + assert!(graph.check_path_is_hamiltonian(&sorted).is_none()); + + let sorted_plugin_names: Vec<_> = sorted + .into_iter() + .map(|i| graph[i].name().to_owned()) + .collect(); + + assert_eq!( + &[ + PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_G, PLUGIN_D, PLUGIN_H, PLUGIN_I, + PLUGIN_E, PLUGIN_F, PLUGIN_J + ], + sorted_plugin_names.as_slice() + ); + } + + #[test] + fn should_prefix_path_to_new_load_order_if_the_first_pair_of_nodes_cannot_be_used_in_creation_order() + { + let fixture = Fixture::with_plugins(&[ + PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_D, PLUGIN_E, PLUGIN_F, PLUGIN_G, PLUGIN_H, + PLUGIN_I, PLUGIN_J, + ]); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.sorting_data(PLUGIN_A)); + let b = graph.add_node(fixture.sorting_data(PLUGIN_B)); + let c = graph.add_node(fixture.sorting_data(PLUGIN_C)); + let d = graph.add_node(fixture.sorting_data(PLUGIN_D)); + graph.add_node(fixture.sorting_data(PLUGIN_E)); + graph.add_node(fixture.sorting_data(PLUGIN_F)); + graph.add_node(fixture.sorting_data(PLUGIN_G)); + graph.add_node(fixture.sorting_data(PLUGIN_H)); + graph.add_node(fixture.sorting_data(PLUGIN_I)); + graph.add_node(fixture.sorting_data(PLUGIN_J)); + + // Add a path b -> c -> d -> a + graph.add_edge(b, c, EdgeType::RecordOverlap); + graph.add_edge(c, d, EdgeType::RecordOverlap); + graph.add_edge(d, a, EdgeType::RecordOverlap); + + graph.add_tie_break_edges().unwrap(); + + let sorted = graph.topological_sort().unwrap(); + + assert!(graph.check_path_is_hamiltonian(&sorted).is_none()); + + let sorted_plugin_names: Vec<_> = sorted + .into_iter() + .map(|i| graph[i].name().to_owned()) + .collect(); + + assert_eq!( + &[ + PLUGIN_B, PLUGIN_C, PLUGIN_D, PLUGIN_A, PLUGIN_E, PLUGIN_F, PLUGIN_G, + PLUGIN_H, PLUGIN_I, PLUGIN_J + ], + sorted_plugin_names.as_slice() + ); + } + } + } + + mod sort_plugins { + use crate::{Vertex, sorting::error::PluginGraphValidationError}; + + use super::*; + + #[test] + fn should_not_change_the_result_if_given_its_own_output() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let expected = &[PLUGIN_A, PLUGIN_B]; + + let sorted = sort_plugins( + vec![ + fixture.sorting_data(PLUGIN_B), + fixture.sorting_data(PLUGIN_A), + ], + &fixture.groups_graph, + &[], + ) + .unwrap(); + + assert_eq!(expected, sorted.as_slice()); + + let sorted = sort_plugins( + vec![ + fixture.sorting_data(PLUGIN_A), + fixture.sorting_data(PLUGIN_B), + ], + &fixture.groups_graph, + &[], + ) + .unwrap(); + + assert_eq!(expected, sorted.as_slice()); + } + + #[test] + fn should_use_group_metadata_when_deciding_relative_plugin_positions() { + let fixture = Fixture::with_plugins(&[PLUGIN_B, PLUGIN_A]); + + let data = vec![ + fixture.group_sorting_data(PLUGIN_A, "A"), + fixture.group_sorting_data(PLUGIN_B, "B"), + ]; + + let expected = &[PLUGIN_A, PLUGIN_B]; + + let sorted = sort_plugins(data, &fixture.groups_graph, &[]).unwrap(); + + assert_eq!(expected, sorted.as_slice()); + } + + #[test] + fn should_use_load_after_metadata_when_deciding_relative_plugin_positions() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let mut a = fixture.sorting_data(PLUGIN_A); + a.masterlist_load_after = Box::new([PLUGIN_B.into()]); + + let data = vec![a, fixture.sorting_data(PLUGIN_B)]; + + let expected = &[PLUGIN_B, PLUGIN_A]; + + let sorted = sort_plugins(data, &fixture.groups_graph, &[]).unwrap(); + + assert_eq!(expected, sorted.as_slice()); + } + + #[test] + fn should_use_requirement_metadata_when_deciding_relative_plugin_positions() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let mut a = fixture.sorting_data(PLUGIN_A); + a.masterlist_req = Box::new([PLUGIN_B.into()]); + + let data = vec![a, fixture.sorting_data(PLUGIN_B)]; + + let expected = &[PLUGIN_B, PLUGIN_A]; + + let sorted = sort_plugins(data, &fixture.groups_graph, &[]).unwrap(); + + assert_eq!(expected, sorted.as_slice()); + } + + #[test] + fn should_use_early_loader_positions_when_deciding_relative_plugin_positions() { + let fixture = Fixture::with_plugins(&[PLUGIN_B, PLUGIN_A]); + + let data = vec![ + fixture.sorting_data(PLUGIN_A), + fixture.sorting_data(PLUGIN_B), + ]; + + let expected = &[PLUGIN_A, PLUGIN_B]; + + let sorted = sort_plugins(data, &fixture.groups_graph, &[PLUGIN_A.into()]).unwrap(); + + assert_eq!(expected, sorted.as_slice()); + } + + #[test] + fn should_error_if_a_plugin_has_a_group_that_does_not_exist() { + let fixture = Fixture::with_plugins(&[PLUGIN_A]); + + let data = vec![fixture.group_sorting_data(PLUGIN_A, "missing")]; + + assert!(sort_plugins(data, &fixture.groups_graph, &[]).is_err()); + } + + #[test] + fn should_error_if_a_cyclic_interaction_is_encountered() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + fixture.get_plugin_mut(PLUGIN_A).add_master(PLUGIN_B); + fixture.get_plugin_mut(PLUGIN_B).add_master(PLUGIN_A); + + let data = vec![ + fixture.sorting_data(PLUGIN_A), + fixture.sorting_data(PLUGIN_B), + ]; + + match sort_plugins(data, &fixture.groups_graph, &[]) { + Err(SortingError::CycleFound(e)) => { + assert_eq!( + &[ + Vertex::new(PLUGIN_A.into()).with_out_edge_type(EdgeType::Master), + Vertex::new(PLUGIN_B.into()).with_out_edge_type(EdgeType::Master), + ], + e.into_cycle().as_slice() + ); + } + _ => panic!("Expected to find a cycle"), + } + } + + #[test] + fn should_error_if_a_master_edge_would_contradict_master_flags() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let a = fixture.get_plugin_mut(PLUGIN_A); + a.is_master = true; + a.add_master(PLUGIN_B); + + let data = vec![ + fixture.sorting_data(PLUGIN_A), + fixture.sorting_data(PLUGIN_B), + ]; + + match sort_plugins(data, &fixture.groups_graph, &[]) { + Err(SortingError::ValidationError(PluginGraphValidationError::CycleFound(e))) => { + assert_eq!( + &[ + Vertex::new(PLUGIN_B.into()).with_out_edge_type(EdgeType::Master), + Vertex::new(PLUGIN_A.into()).with_out_edge_type(EdgeType::MasterFlag), + ], + e.into_cycle().as_slice() + ); + } + _ => panic!("Expected to find a cycle"), + } + } + + #[test] + fn should_error_if_a_masterlist_load_after_contradicts_master_flags() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + fixture.get_plugin_mut(PLUGIN_A).is_master = true; + + let mut a = fixture.sorting_data(PLUGIN_A); + a.masterlist_load_after = Box::new([PLUGIN_B.into()]); + + let data = vec![a, fixture.sorting_data(PLUGIN_B)]; + + match sort_plugins(data, &fixture.groups_graph, &[]) { + Err(SortingError::ValidationError(PluginGraphValidationError::CycleFound(e))) => { + assert_eq!( + &[ + Vertex::new(PLUGIN_B.into()) + .with_out_edge_type(EdgeType::MasterlistLoadAfter), + Vertex::new(PLUGIN_A.into()).with_out_edge_type(EdgeType::MasterFlag), + ], + e.into_cycle().as_slice() + ); + } + _ => panic!("Expected to find a cycle"), + } + } + + #[test] + fn should_error_if_a_user_load_after_contradicts_master_flags() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + fixture.get_plugin_mut(PLUGIN_A).is_master = true; + + let mut a = fixture.sorting_data(PLUGIN_A); + a.user_load_after = Box::new([PLUGIN_B.into()]); + + let data = vec![a, fixture.sorting_data(PLUGIN_B)]; + + match sort_plugins(data, &fixture.groups_graph, &[]) { + Err(SortingError::ValidationError(PluginGraphValidationError::CycleFound(e))) => { + assert_eq!( + &[ + Vertex::new(PLUGIN_B.into()) + .with_out_edge_type(EdgeType::UserLoadAfter), + Vertex::new(PLUGIN_A.into()).with_out_edge_type(EdgeType::MasterFlag), + ], + e.into_cycle().as_slice() + ); + } + _ => panic!("Expected to find a cycle"), + } + } + + #[test] + fn should_error_if_a_masterlist_requirement_contradicts_master_flags() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + fixture.get_plugin_mut(PLUGIN_A).is_master = true; + + let mut a = fixture.sorting_data(PLUGIN_A); + a.masterlist_req = Box::new([PLUGIN_B.into()]); + + let data = vec![a, fixture.sorting_data(PLUGIN_B)]; + + match sort_plugins(data, &fixture.groups_graph, &[]) { + Err(SortingError::ValidationError(PluginGraphValidationError::CycleFound(e))) => { + assert_eq!( + &[ + Vertex::new(PLUGIN_B.into()) + .with_out_edge_type(EdgeType::MasterlistRequirement), + Vertex::new(PLUGIN_A.into()).with_out_edge_type(EdgeType::MasterFlag), + ], + e.into_cycle().as_slice() + ); + } + _ => panic!("Expected to find a cycle"), + } + } + + #[test] + fn should_error_if_a_user_requirement_contradicts_master_flags() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + fixture.get_plugin_mut(PLUGIN_A).is_master = true; + + let mut a = fixture.sorting_data(PLUGIN_A); + a.user_req = Box::new([PLUGIN_B.into()]); + + let data = vec![a, fixture.sorting_data(PLUGIN_B)]; + + match sort_plugins(data, &fixture.groups_graph, &[]) { + Err(SortingError::ValidationError(PluginGraphValidationError::CycleFound(e))) => { + assert_eq!( + &[ + Vertex::new(PLUGIN_B.into()) + .with_out_edge_type(EdgeType::UserRequirement), + Vertex::new(PLUGIN_A.into()).with_out_edge_type(EdgeType::MasterFlag), + ], + e.into_cycle().as_slice() + ); + } + _ => panic!("Expected to find a cycle"), + } + } + + #[test] + fn should_error_if_an_early_loader_contradicts_master_flags() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + fixture.get_plugin_mut(PLUGIN_A).is_master = true; + + let data = vec![ + fixture.sorting_data(PLUGIN_A), + fixture.sorting_data(PLUGIN_B), + ]; + + match sort_plugins(data, &fixture.groups_graph, &[PLUGIN_B.into()]) { + Err(SortingError::ValidationError(PluginGraphValidationError::CycleFound(e))) => { + assert_eq!( + &[ + Vertex::new(PLUGIN_B.into()).with_out_edge_type(EdgeType::Hardcoded), + Vertex::new(PLUGIN_A.into()).with_out_edge_type(EdgeType::MasterFlag), + ], + e.into_cycle().as_slice() + ); + } + _ => panic!("Expected to find a cycle"), + } + } + + #[test] + fn should_not_error_if_a_master_edge_would_put_a_blueprint_master_before_a_master() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let a = fixture.get_plugin_mut(PLUGIN_A); + a.is_master = true; + a.is_blueprint_plugin = true; + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.is_master = true; + b.add_master(PLUGIN_A); + + let data = vec![ + fixture.sorting_data(PLUGIN_A), + fixture.sorting_data(PLUGIN_B), + ]; + + let expected = &[PLUGIN_B, PLUGIN_A]; + + let sorted = sort_plugins(data, &fixture.groups_graph, &[]).unwrap(); + + assert_eq!(expected, sorted.as_slice()); + } + + #[test] + fn should_not_error_if_a_master_edge_would_put_a_blueprint_master_before_a_non_master() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let a = fixture.get_plugin_mut(PLUGIN_A); + a.is_master = true; + a.is_blueprint_plugin = true; + + fixture.get_plugin_mut(PLUGIN_B).add_master(PLUGIN_A); + + let data = vec![ + fixture.sorting_data(PLUGIN_A), + fixture.sorting_data(PLUGIN_B), + ]; + + let expected = &[PLUGIN_B, PLUGIN_A]; + + let sorted = sort_plugins(data, &fixture.groups_graph, &[]).unwrap(); + + assert_eq!(expected, sorted.as_slice()); + } + + #[test] + fn should_error_if_a_masterlist_load_after_would_put_a_blueprint_master_before_a_master() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + fixture.get_plugin_mut(PLUGIN_A).is_master = true; + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.is_master = true; + b.is_blueprint_plugin = true; + + let mut a = fixture.sorting_data(PLUGIN_A); + a.masterlist_load_after = Box::new([PLUGIN_B.into()]); + + let data = vec![a, fixture.sorting_data(PLUGIN_B)]; + + match sort_plugins(data, &fixture.groups_graph, &[]) { + Err(SortingError::ValidationError(PluginGraphValidationError::CycleFound(e))) => { + assert_eq!( + &[ + Vertex::new(PLUGIN_B.into()) + .with_out_edge_type(EdgeType::MasterlistLoadAfter), + Vertex::new(PLUGIN_A.into()) + .with_out_edge_type(EdgeType::BlueprintMaster), + ], + e.into_cycle().as_slice() + ); + } + _ => panic!("Expected to find a cycle"), + } + } + + #[test] + fn should_error_if_a_masterlist_load_after_would_put_a_blueprint_master_before_a_non_master() + { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.is_master = true; + b.is_blueprint_plugin = true; + + let mut a = fixture.sorting_data(PLUGIN_A); + a.masterlist_load_after = Box::new([PLUGIN_B.into()]); + + let data = vec![a, fixture.sorting_data(PLUGIN_B)]; + + match sort_plugins(data, &fixture.groups_graph, &[]) { + Err(SortingError::ValidationError(PluginGraphValidationError::CycleFound(e))) => { + assert_eq!( + &[ + Vertex::new(PLUGIN_B.into()) + .with_out_edge_type(EdgeType::MasterlistLoadAfter), + Vertex::new(PLUGIN_A.into()) + .with_out_edge_type(EdgeType::BlueprintMaster), + ], + e.into_cycle().as_slice() + ); + } + _ => panic!("Expected to find a cycle"), + } + } + + #[test] + fn should_error_if_a_user_load_after_would_put_a_blueprint_master_before_a_master() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + fixture.get_plugin_mut(PLUGIN_A).is_master = true; + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.is_master = true; + b.is_blueprint_plugin = true; + + let mut a = fixture.sorting_data(PLUGIN_A); + a.user_load_after = Box::new([PLUGIN_B.into()]); + + let data = vec![a, fixture.sorting_data(PLUGIN_B)]; + + match sort_plugins(data, &fixture.groups_graph, &[]) { + Err(SortingError::ValidationError(PluginGraphValidationError::CycleFound(e))) => { + assert_eq!( + &[ + Vertex::new(PLUGIN_B.into()) + .with_out_edge_type(EdgeType::UserLoadAfter), + Vertex::new(PLUGIN_A.into()) + .with_out_edge_type(EdgeType::BlueprintMaster), + ], + e.into_cycle().as_slice() + ); + } + _ => panic!("Expected to find a cycle"), + } + } + + #[test] + fn should_error_if_a_user_load_after_would_put_a_blueprint_master_before_a_non_master() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.is_master = true; + b.is_blueprint_plugin = true; + + let mut a = fixture.sorting_data(PLUGIN_A); + a.user_load_after = Box::new([PLUGIN_B.into()]); + + let data = vec![a, fixture.sorting_data(PLUGIN_B)]; + + match sort_plugins(data, &fixture.groups_graph, &[]) { + Err(SortingError::ValidationError(PluginGraphValidationError::CycleFound(e))) => { + assert_eq!( + &[ + Vertex::new(PLUGIN_B.into()) + .with_out_edge_type(EdgeType::UserLoadAfter), + Vertex::new(PLUGIN_A.into()) + .with_out_edge_type(EdgeType::BlueprintMaster), + ], + e.into_cycle().as_slice() + ); + } + _ => panic!("Expected to find a cycle"), + } + } + + #[test] + fn should_error_if_a_masterlist_requirement_would_put_a_blueprint_master_before_a_master() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + fixture.get_plugin_mut(PLUGIN_A).is_master = true; + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.is_master = true; + b.is_blueprint_plugin = true; + + let mut a = fixture.sorting_data(PLUGIN_A); + a.masterlist_req = Box::new([PLUGIN_B.into()]); + + let data = vec![a, fixture.sorting_data(PLUGIN_B)]; + + match sort_plugins(data, &fixture.groups_graph, &[]) { + Err(SortingError::ValidationError(PluginGraphValidationError::CycleFound(e))) => { + assert_eq!( + &[ + Vertex::new(PLUGIN_B.into()) + .with_out_edge_type(EdgeType::MasterlistRequirement), + Vertex::new(PLUGIN_A.into()) + .with_out_edge_type(EdgeType::BlueprintMaster), + ], + e.into_cycle().as_slice() + ); + } + _ => panic!("Expected to find a cycle"), + } + } + + #[test] + fn should_error_if_a_masterlist_requirement_would_put_a_blueprint_master_before_a_non_master() + { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.is_master = true; + b.is_blueprint_plugin = true; + + let mut a = fixture.sorting_data(PLUGIN_A); + a.masterlist_req = Box::new([PLUGIN_B.into()]); + + let data = vec![a, fixture.sorting_data(PLUGIN_B)]; + + match sort_plugins(data, &fixture.groups_graph, &[]) { + Err(SortingError::ValidationError(PluginGraphValidationError::CycleFound(e))) => { + assert_eq!( + &[ + Vertex::new(PLUGIN_B.into()) + .with_out_edge_type(EdgeType::MasterlistRequirement), + Vertex::new(PLUGIN_A.into()) + .with_out_edge_type(EdgeType::BlueprintMaster), + ], + e.into_cycle().as_slice() + ); + } + _ => panic!("Expected to find a cycle"), + } + } + + #[test] + fn should_error_if_a_user_requirement_would_put_a_blueprint_master_before_a_master() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + fixture.get_plugin_mut(PLUGIN_A).is_master = true; + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.is_master = true; + b.is_blueprint_plugin = true; + + let mut a = fixture.sorting_data(PLUGIN_A); + a.user_req = Box::new([PLUGIN_B.into()]); + + let data = vec![a, fixture.sorting_data(PLUGIN_B)]; + + match sort_plugins(data, &fixture.groups_graph, &[]) { + Err(SortingError::ValidationError(PluginGraphValidationError::CycleFound(e))) => { + assert_eq!( + &[ + Vertex::new(PLUGIN_B.into()) + .with_out_edge_type(EdgeType::UserRequirement), + Vertex::new(PLUGIN_A.into()) + .with_out_edge_type(EdgeType::BlueprintMaster), + ], + e.into_cycle().as_slice() + ); + } + _ => panic!("Expected to find a cycle"), + } + } + + #[test] + fn should_error_if_a_user_requirement_would_put_a_blueprint_master_before_a_non_master() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.is_master = true; + b.is_blueprint_plugin = true; + + let mut a = fixture.sorting_data(PLUGIN_A); + a.user_req = Box::new([PLUGIN_B.into()]); + + let data = vec![a, fixture.sorting_data(PLUGIN_B)]; + + match sort_plugins(data, &fixture.groups_graph, &[]) { + Err(SortingError::ValidationError(PluginGraphValidationError::CycleFound(e))) => { + assert_eq!( + &[ + Vertex::new(PLUGIN_B.into()) + .with_out_edge_type(EdgeType::UserRequirement), + Vertex::new(PLUGIN_A.into()) + .with_out_edge_type(EdgeType::BlueprintMaster), + ], + e.into_cycle().as_slice() + ); + } + _ => panic!("Expected to find a cycle"), + } + } + + #[test] + fn should_not_error_if_an_early_loader_would_put_a_blueprint_master_before_a_master() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + fixture.get_plugin_mut(PLUGIN_A).is_master = true; + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.is_master = true; + b.is_blueprint_plugin = true; + + let data = vec![ + fixture.sorting_data(PLUGIN_A), + fixture.sorting_data(PLUGIN_B), + ]; + + let expected = &[PLUGIN_A, PLUGIN_B]; + + let sorted = sort_plugins(data, &fixture.groups_graph, &[PLUGIN_B.into()]).unwrap(); + + assert_eq!(expected, sorted.as_slice()); + } + + #[test] + fn should_not_error_if_an_early_loader_would_put_a_blueprint_master_before_a_non_master() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.is_master = true; + b.is_blueprint_plugin = true; + + let data = vec![ + fixture.sorting_data(PLUGIN_A), + fixture.sorting_data(PLUGIN_B), + ]; + + let expected = &[PLUGIN_A, PLUGIN_B]; + + let sorted = sort_plugins(data, &fixture.groups_graph, &[PLUGIN_B.into()]).unwrap(); + + assert_eq!(expected, sorted.as_slice()); + } + } +} diff --git a/src/sorting/validate.rs b/src/sorting/validate.rs new file mode 100644 index 00000000..5e74df8c --- /dev/null +++ b/src/sorting/validate.rs @@ -0,0 +1,201 @@ +use std::collections::HashSet; + +use unicase::UniCase; + +use crate::{ + EdgeType, Vertex, logging, + sorting::error::{CyclicInteractionError, PluginGraphValidationError, UndefinedGroupError}, +}; + +use super::{ + groups::GroupsGraph, + plugins::{PluginSortingData, SortingPlugin}, +}; + +pub fn validate_plugin_groups( + plugins_sorting_data: &[PluginSortingData<'_, T>], + groups_graph: &GroupsGraph, +) -> Result<(), UndefinedGroupError> { + let group_names: HashSet<&str> = groups_graph + .node_indices() + .map(|i| groups_graph[i].as_ref()) + .collect(); + + for plugin in plugins_sorting_data { + if !group_names.contains(plugin.group.as_ref()) { + return Err(UndefinedGroupError::new(plugin.group.clone().into_string())); + } + } + + Ok(()) +} + +pub fn validate_specific_and_hardcoded_edges( + masters: &[PluginSortingData<'_, T>], + blueprint_masters: &[PluginSortingData<'_, T>], + non_masters: &[PluginSortingData<'_, T>], + early_loading_plugins: &[String], +) -> Result<(), PluginGraphValidationError> { + logging::trace!("Validating specific and early-loading plugin edges..."); + + let non_masters_set: HashSet> = + non_masters.iter().map(|p| UniCase::new(p.name())).collect(); + let blueprint_masters_set: HashSet> = blueprint_masters + .iter() + .map(|p| UniCase::new(p.name())) + .collect(); + + validate_masters(masters, &non_masters_set, &blueprint_masters_set)?; + + validate_non_masters(non_masters, &blueprint_masters_set)?; + + // There's at least one master, check that there are no hardcoded + // non-masters. + validate_early_loading_plugins(early_loading_plugins, masters, &non_masters_set)?; + + Ok(()) +} + +fn validate_masters( + masters: &[PluginSortingData<'_, T>], + non_masters: &HashSet>, + blueprint_masters: &HashSet>, +) -> Result<(), PluginGraphValidationError> { + logging::trace!( + "Validating specific and early-loading plugin edges for non-blueprint master files..." + ); + masters + .iter() + .try_for_each(|m| validate_plugin(m, non_masters, blueprint_masters)) +} + +fn validate_non_masters( + non_masters: &[PluginSortingData<'_, T>], + blueprint_masters: &HashSet>, +) -> Result<(), PluginGraphValidationError> { + logging::trace!("Validating specific and early-loading plugin edges for non-master files..."); + + // Pass an empty set of non-masters so that the non-masters don't get validated against themselves. + let empty_set = HashSet::new(); + + non_masters + .iter() + .try_for_each(|p| validate_plugin(p, &empty_set, blueprint_masters)) +} + +fn validate_plugin( + plugin: &PluginSortingData<'_, T>, + non_masters: &HashSet>, + blueprint_masters: &HashSet>, +) -> Result<(), PluginGraphValidationError> { + for master in plugin.masters()? { + let key = UniCase::new(master.as_str()); + if non_masters.contains(&key) { + return Err(CyclicInteractionError::new(vec![ + Vertex::new(master).with_out_edge_type(EdgeType::Master), + Vertex::new(plugin.name().to_owned()).with_out_edge_type(EdgeType::MasterFlag), + ]) + .into()); + } + + if blueprint_masters.contains(&key) { + // Log a warning instead of throwing an exception because the game will + // just ignore this master, and the issue can't be fixed without + // editing the plugin and the blueprint master may not actually have + // any of its records overridden. + let plugin_type = if plugin.is_master { + "master" + } else { + "non-master" + }; + logging::warn!( + "The {} plugin \"{}\" has the blueprint master \"{}\" as one of its masters", + plugin_type, + plugin.name(), + master + ); + } + } + + validate_files( + &plugin.masterlist_req, + plugin.name(), + non_masters, + blueprint_masters, + EdgeType::MasterlistRequirement, + )?; + + validate_files( + &plugin.user_req, + plugin.name(), + non_masters, + blueprint_masters, + EdgeType::UserRequirement, + )?; + + validate_files( + &plugin.masterlist_load_after, + plugin.name(), + non_masters, + blueprint_masters, + EdgeType::MasterlistLoadAfter, + )?; + + validate_files( + &plugin.user_load_after, + plugin.name(), + non_masters, + blueprint_masters, + EdgeType::UserLoadAfter, + )?; + + Ok(()) +} + +fn validate_files( + files: &[String], + plugin_name: &str, + non_masters: &HashSet>, + blueprint_masters: &HashSet>, + edge_type: EdgeType, +) -> Result<(), CyclicInteractionError> { + for file in files { + let key = UniCase::new(file.as_str()); + if non_masters.contains(&key) { + return Err(CyclicInteractionError::new(vec![ + Vertex::new(file.clone()).with_out_edge_type(edge_type), + Vertex::new(plugin_name.to_owned()).with_out_edge_type(EdgeType::MasterFlag), + ])); + } + + if blueprint_masters.contains(&key) { + return Err(CyclicInteractionError::new(vec![ + Vertex::new(file.clone()).with_out_edge_type(edge_type), + Vertex::new(plugin_name.to_owned()).with_out_edge_type(EdgeType::BlueprintMaster), + ])); + } + } + + Ok(()) +} + +fn validate_early_loading_plugins( + early_loading_plugins: &[String], + masters: &[PluginSortingData<'_, T>], + non_masters: &HashSet>, +) -> Result<(), CyclicInteractionError> { + if let Some(master) = masters.first() { + for plugin in early_loading_plugins { + let key = UniCase::new(plugin.as_str()); + if non_masters.contains(&key) { + // Just report the cycle to the first master. + return Err(CyclicInteractionError::new(vec![ + Vertex::new(plugin.clone()).with_out_edge_type(EdgeType::Hardcoded), + Vertex::new(master.name().to_owned()).with_out_edge_type(EdgeType::MasterFlag), + ])); + } + } + } + + Ok(()) +} diff --git a/src/sorting/vertex.rs b/src/sorting/vertex.rs new file mode 100644 index 00000000..0c4b8f38 --- /dev/null +++ b/src/sorting/vertex.rs @@ -0,0 +1,75 @@ +/// An enum representing the different possible types of interactions between +/// plugins or groups. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[non_exhaustive] +pub enum EdgeType { + Hardcoded, + MasterFlag, + Master, + MasterlistRequirement, + UserRequirement, + MasterlistLoadAfter, + UserLoadAfter, + MasterlistGroup, + UserGroup, + RecordOverlap, + AssetOverlap, + TieBreak, + BlueprintMaster, +} + +impl std::fmt::Display for EdgeType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + EdgeType::Hardcoded => write!(f, "Hardcoded"), + EdgeType::MasterFlag => write!(f, "Master Flag"), + EdgeType::Master => write!(f, "Master"), + EdgeType::MasterlistRequirement => write!(f, "Masterlist Requirement"), + EdgeType::UserRequirement => write!(f, "User Requirement"), + EdgeType::MasterlistLoadAfter => write!(f, "Masterlist Load After"), + EdgeType::UserLoadAfter => write!(f, "User Load After"), + EdgeType::MasterlistGroup => write!(f, "Masterlist Group"), + EdgeType::UserGroup => write!(f, "User Group"), + EdgeType::RecordOverlap => write!(f, "Record Overlap"), + EdgeType::AssetOverlap => write!(f, "Asset Overlap"), + EdgeType::TieBreak => write!(f, "Tie Break"), + EdgeType::BlueprintMaster => write!(f, "Blueprint Master"), + } + } +} + +/// Represents a plugin or group vertex in a path, and the type of the edge to +/// the next vertex in the path if one exists. +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct Vertex { + name: String, + out_edge_type: Option, +} + +impl Vertex { + /// Construct a Vertex with the given name and no out edge. + #[must_use] + pub fn new(name: String) -> Self { + Self { + name, + ..Default::default() + } + } + + /// Set the type of the edge going from this vertex to the next in the path. + #[must_use] + pub fn with_out_edge_type(mut self, out_edge_type: EdgeType) -> Self { + self.out_edge_type = Some(out_edge_type); + self + } + + /// Get the name of the plugin or group that the vertex represents. + pub fn name(&self) -> &str { + &self.name + } + + /// Get the type of the edge going from this vertex to the next in the path. + pub fn out_edge_type(&self) -> Option { + self.out_edge_type + } +} diff --git a/src/tests.rs b/src/tests.rs new file mode 100644 index 00000000..96ecef30 --- /dev/null +++ b/src/tests.rs @@ -0,0 +1,376 @@ +use std::{ + fs::{File, copy, create_dir_all}, + path::{Path, PathBuf, absolute}, + time::{Duration, SystemTime}, +}; + +use crate::GameType; +use parameterized_test::test_parameter; +use tempfile::TempDir; + +pub const BLANK_ESM: &str = "Blank.esm"; +pub const BLANK_DIFFERENT_ESM: &str = "Blank - Different.esm"; +pub const BLANK_MASTER_DEPENDENT_ESM: &str = "Blank - Master Dependent.esm"; +const BLANK_DIFFERENT_MASTER_DEPENDENT_ESM: &str = "Blank - Different Master Dependent.esm"; +pub const BLANK_ESP: &str = "Blank.esp"; +pub const BLANK_DIFFERENT_ESP: &str = "Blank - Different.esp"; +pub const BLANK_MASTER_DEPENDENT_ESP: &str = "Blank - Master Dependent.esp"; +const BLANK_DIFFERENT_MASTER_DEPENDENT_ESP: &str = "Blank - Different Master Dependent.esp"; +const BLANK_PLUGIN_DEPENDENT_ESP: &str = "Blank - Plugin Dependent.esp"; +const BLANK_DIFFERENT_PLUGIN_DEPENDENT_ESP: &str = "Blank - Different Plugin Dependent.esp"; + +pub const BLANK_FULL_ESM: &str = "Blank.full.esm"; +pub const BLANK_MEDIUM_ESM: &str = "Blank.medium.esm"; +pub const BLANK_OVERRIDE_ESP: &str = "Blank - Override.esp"; +pub const BLANK_ESL: &str = "Blank.esl"; +pub const NON_PLUGIN_FILE: &str = "NotAPlugin.esm"; +pub const NON_ASCII_ESM: &str = "non\u{00C1}scii.esm"; + +pub fn source_plugins_path(game_type: GameType) -> PathBuf { + match game_type { + GameType::Morrowind | GameType::OpenMW => { + absolute("./testing-plugins/Morrowind/Data Files") + } + GameType::Oblivion | GameType::OblivionRemastered => { + absolute("./testing-plugins/Oblivion/Data") + } + GameType::Starfield => absolute("./testing-plugins/Starfield/Data"), + GameType::Fallout3 | GameType::FalloutNV | GameType::Skyrim => { + absolute("./testing-plugins/Skyrim/Data") + } + _ => absolute("./testing-plugins/SkyrimSE/Data"), + } + .unwrap() +} + +fn master_file(game_type: GameType) -> &'static str { + match game_type { + GameType::Morrowind | GameType::OpenMW => "Morrowind.esm", + GameType::Oblivion | GameType::OblivionRemastered => "Oblivion.esm", + GameType::Skyrim | GameType::SkyrimSE | GameType::SkyrimVR => "Skyrim.esm", + GameType::Fallout3 => "Fallout3.esm", + GameType::FalloutNV => "FalloutNV.esm", + GameType::Fallout4 | GameType::Fallout4VR => "Fallout4.esm", + GameType::Starfield => "Starfield.esm", + } +} + +pub fn copy_file(source_dir: &Path, dest_dir: &Path, filename: &str) { + copy(source_dir.join(filename), dest_dir.join(filename)).unwrap(); +} + +fn touch(file_path: &Path) { + std::fs::File::create(file_path).unwrap(); +} + +fn supports_light_plugins(game_type: GameType) -> bool { + matches!( + game_type, + GameType::SkyrimSE + | GameType::SkyrimVR + | GameType::Fallout4 + | GameType::Fallout4VR + | GameType::Starfield + ) +} + +fn is_load_order_timestamp_based(game_type: GameType) -> bool { + matches!( + game_type, + GameType::Morrowind | GameType::Oblivion | GameType::Fallout3 | GameType::FalloutNV + ) +} + +pub fn initial_load_order(game_type: GameType) -> Vec<(&'static str, bool)> { + if game_type == GameType::Starfield { + vec![ + (master_file(game_type), true), + (BLANK_ESM, true), + (BLANK_DIFFERENT_ESM, false), + (BLANK_FULL_ESM, false), + (BLANK_MASTER_DEPENDENT_ESM, false), + (BLANK_MEDIUM_ESM, false), + (BLANK_ESL, false), + (BLANK_ESP, false), + (BLANK_DIFFERENT_ESP, false), + (BLANK_MASTER_DEPENDENT_ESP, false), + ] + } else { + let mut load_order = vec![ + (master_file(game_type), true), + (BLANK_ESM, true), + (BLANK_DIFFERENT_ESM, false), + (BLANK_MASTER_DEPENDENT_ESM, false), + (BLANK_DIFFERENT_MASTER_DEPENDENT_ESM, false), + (BLANK_ESP, false), + (BLANK_DIFFERENT_ESP, false), + (BLANK_MASTER_DEPENDENT_ESP, false), + (BLANK_DIFFERENT_MASTER_DEPENDENT_ESP, true), + (BLANK_PLUGIN_DEPENDENT_ESP, false), + (BLANK_DIFFERENT_PLUGIN_DEPENDENT_ESP, false), + ]; + + if supports_light_plugins(game_type) { + load_order.insert(5, (BLANK_ESL, false)); + } + + load_order + } +} + +fn set_load_order( + game_type: GameType, + data_path: &Path, + local_path: &Path, + load_order: &[(&'static str, bool)], +) { + use std::io::Write; + + match game_type { + GameType::Morrowind | GameType::OpenMW => {} + _ => { + let mut file = File::create(local_path.join("Plugins.txt")).unwrap(); + for (plugin, is_active) in load_order { + if supports_light_plugins(game_type) { + if *is_active { + write!(file, "*").unwrap(); + } + } else if !is_active { + continue; + } + + writeln!(file, "{plugin}").unwrap(); + } + } + } + + if is_load_order_timestamp_based(game_type) { + let mut mod_time = SystemTime::now(); + for (plugin, _) in load_order { + let ghosted_path = data_path.join(format!("{plugin}.ghost")); + let file = if ghosted_path.exists() { + File::options().write(true).open(ghosted_path) + } else { + File::options().write(true).open(data_path.join(plugin)) + }; + file.unwrap().set_modified(mod_time).unwrap(); + + mod_time += Duration::from_secs(60); + } + } else if matches!(game_type, GameType::Skyrim | GameType::OblivionRemastered) { + let mut file = File::create(local_path.join("loadorder.txt")).unwrap(); + for (plugin, _) in load_order { + writeln!(file, "{plugin}").unwrap(); + } + } +} + +fn data_path(game_type: GameType, game_path: &Path) -> PathBuf { + match game_type { + GameType::OpenMW => game_path.join("resources/vfs"), + GameType::Morrowind => game_path.join("Data Files"), + GameType::OblivionRemastered => { + game_path.join("OblivionRemastered/Content/Dev/ObvData/Data") + } + _ => game_path.join("Data"), + } +} + +pub struct Fixture { + _temp_dir: TempDir, + pub(crate) game_type: GameType, + pub(crate) game_path: PathBuf, + pub(crate) local_path: PathBuf, +} + +impl Fixture { + pub fn new(game_type: GameType) -> Self { + let temp_dir = tempfile::Builder::new() + .prefix("libloot-t\u{00E9}st-") + .tempdir() + .unwrap(); + + Self::with_tempdir(game_type, temp_dir) + } + + pub fn in_path(game_type: GameType, in_path: &Path) -> Fixture { + let temp_dir = tempfile::Builder::new() + .prefix("libloot-t\u{00E9}st-") + .tempdir_in(in_path) + .unwrap(); + + Self::with_tempdir(game_type, temp_dir) + } + + fn with_tempdir(game_type: GameType, temp_dir: TempDir) -> Fixture { + let root_path = temp_dir.path(); + let game_path = root_path.join("games/game"); + let local_path = root_path.join("local/game"); + let data_path = data_path(game_type, &game_path); + + create_dir_all(&data_path).unwrap(); + create_dir_all(&local_path).unwrap(); + + let source_plugins_path = source_plugins_path(game_type); + + if game_type == GameType::Starfield { + copy_file(&source_plugins_path, &data_path, BLANK_FULL_ESM); + copy_file(&source_plugins_path, &data_path, BLANK_MEDIUM_ESM); + + copy( + source_plugins_path.join(BLANK_FULL_ESM), + data_path.join(BLANK_ESM), + ) + .unwrap(); + copy( + source_plugins_path.join(BLANK_FULL_ESM), + data_path.join(BLANK_DIFFERENT_ESM), + ) + .unwrap(); + copy( + source_plugins_path.join("Blank - Override.full.esm"), + data_path.join(BLANK_MASTER_DEPENDENT_ESM), + ) + .unwrap(); + copy_file(&source_plugins_path, &data_path, BLANK_ESP); + copy( + source_plugins_path.join(BLANK_ESP), + data_path.join(BLANK_DIFFERENT_ESP), + ) + .unwrap(); + copy( + source_plugins_path.join(BLANK_OVERRIDE_ESP), + data_path.join(BLANK_MASTER_DEPENDENT_ESP), + ) + .unwrap(); + } else { + copy_file(&source_plugins_path, &data_path, BLANK_ESM); + copy_file(&source_plugins_path, &data_path, BLANK_DIFFERENT_ESM); + copy_file(&source_plugins_path, &data_path, BLANK_MASTER_DEPENDENT_ESM); + copy_file( + &source_plugins_path, + &data_path, + BLANK_DIFFERENT_MASTER_DEPENDENT_ESM, + ); + copy_file(&source_plugins_path, &data_path, BLANK_ESP); + copy_file(&source_plugins_path, &data_path, BLANK_DIFFERENT_ESP); + copy_file(&source_plugins_path, &data_path, BLANK_MASTER_DEPENDENT_ESP); + copy_file( + &source_plugins_path, + &data_path, + BLANK_DIFFERENT_MASTER_DEPENDENT_ESP, + ); + copy_file(&source_plugins_path, &data_path, BLANK_PLUGIN_DEPENDENT_ESP); + copy_file( + &source_plugins_path, + &data_path, + BLANK_DIFFERENT_PLUGIN_DEPENDENT_ESP, + ); + } + + if supports_light_plugins(game_type) { + if game_type == GameType::Starfield { + copy( + source_plugins_path.join("Blank.small.esm"), + data_path.join(BLANK_ESL), + ) + .unwrap(); + } else { + copy_file(&source_plugins_path, &data_path, BLANK_ESL); + } + } + + let master_file = master_file(game_type); + copy(data_path.join(BLANK_ESM), data_path.join(master_file)).unwrap(); + + set_load_order( + game_type, + &data_path, + &local_path, + &initial_load_order(game_type), + ); + + if game_type == GameType::OpenMW { + touch(&game_path.join("openmw.cfg")); + } else { + std::fs::rename( + data_path.join(BLANK_MASTER_DEPENDENT_ESM), + data_path.join(BLANK_MASTER_DEPENDENT_ESM.to_owned() + ".ghost"), + ) + .unwrap(); + } + + std::fs::write( + data_path.join(NON_PLUGIN_FILE), + "This isn't a valid plugin file.", + ) + .unwrap(); + + Self { + _temp_dir: temp_dir, + game_type, + game_path, + local_path, + } + } + + pub fn data_path(&self) -> PathBuf { + data_path(self.game_type, &self.game_path) + } +} + +#[test_parameter] +pub const ALL_GAME_TYPES: [GameType; 12] = [ + GameType::Oblivion, + GameType::Skyrim, + GameType::Fallout3, + GameType::FalloutNV, + GameType::Fallout4, + GameType::SkyrimSE, + GameType::Fallout4VR, + GameType::SkyrimVR, + GameType::Morrowind, + GameType::Starfield, + GameType::OpenMW, + GameType::OblivionRemastered, +]; + +mod unicase { + #[test] + fn eq_should_be_case_insensitive() { + assert!(unicase::eq("i", "I")); + assert!(!unicase::eq("i", "\u{0130}")); + assert!(!unicase::eq("i", "\u{0131}")); + assert!(!unicase::eq("i", "\u{0307}")); + assert!(!unicase::eq("i", "\u{03a1}")); + assert!(!unicase::eq("i", "\u{03c1}")); + assert!(!unicase::eq("i", "\u{03f1}")); + + assert!(!unicase::eq("I", "\u{0130}")); + assert!(!unicase::eq("I", "\u{0131}")); + assert!(!unicase::eq("I", "\u{0307}")); + assert!(!unicase::eq("I", "\u{03a1}")); + assert!(!unicase::eq("I", "\u{03c1}")); + assert!(!unicase::eq("I", "\u{03f1}")); + + assert!(!unicase::eq("\u{0130}", "\u{0131}")); + assert!(!unicase::eq("\u{0130}", "\u{0307}")); + assert!(!unicase::eq("\u{0130}", "\u{03a1}")); + assert!(!unicase::eq("\u{0130}", "\u{03c1}")); + assert!(!unicase::eq("\u{0130}", "\u{03f1}")); + + assert!(!unicase::eq("\u{0131}", "\u{0307}")); + assert!(!unicase::eq("\u{0131}", "\u{03a1}")); + assert!(!unicase::eq("\u{0131}", "\u{03c1}")); + assert!(!unicase::eq("\u{0131}", "\u{03f1}")); + + assert!(!unicase::eq("\u{0307}", "\u{03a1}")); + assert!(!unicase::eq("\u{0307}", "\u{03c1}")); + assert!(!unicase::eq("\u{0307}", "\u{03f1}")); + + assert!(unicase::eq("\u{03a1}", "\u{03c1}")); + assert!(unicase::eq("\u{03a1}", "\u{03f1}")); + + assert!(unicase::eq("\u{03c1}", "\u{03f1}")); + } +} diff --git a/src/tests/api/internals/bsa_test.h b/src/tests/api/internals/bsa_test.h deleted file mode 100644 index 5197e080..00000000 --- a/src/tests/api/internals/bsa_test.h +++ /dev/null @@ -1,244 +0,0 @@ -/* LOOT - -A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and -Fallout: New Vegas. - -Copyright (C) 2014-2016 WrinklyNinja - -This file is part of LOOT. - -LOOT is free software: you can redistribute -it and/or modify it under the terms of the GNU General Public License -as published by the Free Software Foundation, either version 3 of -the License, or (at your option) any later version. - -LOOT is distributed in the hope that it will -be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with LOOT. If not, see -. -*/ - -#ifndef LOOT_TESTS_API_INTERNALS_BSA_TEST -#define LOOT_TESTS_API_INTERNALS_BSA_TEST - -#include - -#include - -#include "api/bsa.h" -#include "tests/test_helpers.h" - -namespace loot::test { -TEST(GetAssetsInBethesdaArchive, shouldSupportV103BSAs) { - const auto path = getSourceArchivesPath(GameType::tes4) / "Blank.bsa"; - - const auto assets = GetAssetsInBethesdaArchive(path); - - size_t filesCount = 0; - for (const auto& folder : assets) { - filesCount += folder.second.size(); - } - - EXPECT_EQ(1, assets.size()); - EXPECT_EQ(1, filesCount); - EXPECT_EQ(0, assets.begin()->first); - EXPECT_EQ(1, assets.at(0).size()); - EXPECT_EQ(0x4670B6836C077365, *assets.at(0).begin()); -} - -TEST(GetAssetsInBethesdaArchive, shouldSupportV104BSAs) { - const auto path = getSourceArchivesPath(GameType::tes5) / "Blank.bsa"; - - const auto assets = GetAssetsInBethesdaArchive(path); - - size_t filesCount = 0; - for (const auto& folder : assets) { - filesCount += folder.second.size(); - } - - EXPECT_EQ(1, assets.size()); - EXPECT_EQ(1, filesCount); - EXPECT_EQ(0x2E01002E, assets.begin()->first); - EXPECT_EQ(1, assets.at(0x2E01002E).size()); - EXPECT_EQ(0x4670B6836C077365, *assets.at(0x2E01002E).begin()); -} - -TEST(GetAssetsInBethesdaArchive, shouldSupportV105BSAs) { - const auto path = getSourceArchivesPath(GameType::tes5se) / "Blank.bsa"; - - const auto assets = GetAssetsInBethesdaArchive(path); - - size_t filesCount = 0; - for (const auto& folder : assets) { - filesCount += folder.second.size(); - } - - EXPECT_EQ(1, assets.size()); - EXPECT_EQ(1, filesCount); - EXPECT_EQ(0xB68102C964176E73, assets.begin()->first); - EXPECT_EQ(1, assets.at(0xB68102C964176E73).size()); - EXPECT_EQ(0x4670B6836C077365, *assets.at(0xB68102C964176E73).begin()); -} - -TEST(GetAssetsInBethesdaArchive, shouldThrowIfFileCannotBeOpened) { - const auto path = std::filesystem::u8path("invalid.bsa"); - - EXPECT_THROW(GetAssetsInBethesdaArchive(path), std::runtime_error); -} - -TEST(GetAssetsInBethesdaArchive, shouldSupportGeneralBA2s) { - const auto path = getSourceArchivesPath(GameType::fo4) / "Blank - Main.ba2"; - const uint64_t folderHash = - std::hash{}("dev\\git\\testing-plugins"); - const uint64_t fileHash = std::hash{}("license.txt"); - - const auto assets = GetAssetsInBethesdaArchive(path); - - size_t filesCount = 0; - for (const auto& folder : assets) { - filesCount += folder.second.size(); - } - - EXPECT_EQ(1, assets.size()); - EXPECT_EQ(1, filesCount); - - ASSERT_EQ(1, assets.count(folderHash)); - - EXPECT_EQ(1, assets.find(folderHash)->second.size()); - EXPECT_EQ(1, assets.find(folderHash)->second.count(fileHash)); -} - -TEST(GetAssetsInBethesdaArchive, shouldSupportTextureBA2s) { - const auto path = - getSourceArchivesPath(GameType::fo4) / "Blank - Textures.ba2"; - const uint64_t folderHash = - std::hash{}("dev\\git\\testing-plugins"); - const uint64_t fileHash = std::hash{}("blank.dds"); - - const auto assets = GetAssetsInBethesdaArchive(path); - - size_t filesCount = 0; - for (const auto& folder : assets) { - filesCount += folder.second.size(); - } - - EXPECT_EQ(1, assets.size()); - EXPECT_EQ(1, filesCount); - - ASSERT_EQ(1, assets.count(folderHash)); - - EXPECT_EQ(1, assets.find(folderHash)->second.size()); - EXPECT_EQ(1, assets.find(folderHash)->second.count(fileHash)); -} - -class GetAssetsInBethesdaArchive_BA2Version - : public ::testing::TestWithParam { -protected: - GetAssetsInBethesdaArchive_BA2Version() : path(GetArchivePath()) { - std::filesystem::create_directories(path.parent_path()); - - const auto sourcePath = - getSourceArchivesPath(GameType::fo4) / "Blank - Main.ba2"; - std::filesystem::copy(sourcePath, path); - - std::fstream stream( - path, std::ios_base::binary | std::ios_base::in | std::ios_base::out); - stream.seekp(4); - stream.put(GetParam()); - stream.close(); - } - - void TearDown() override { std::filesystem::remove_all(path.parent_path()); } - - const std::filesystem::path path; - -private: - std::filesystem::path GetArchivePath() { - return getRootTestPath() / "test.ba2"; - } -}; - -// Pass an empty first argument, as it's a prefix for the test instantation, -// but we only have the one so no prefix is necessary. -INSTANTIATE_TEST_SUITE_P(, - GetAssetsInBethesdaArchive_BA2Version, - ::testing::Values(1, 2, 3, 7, 8)); - -TEST_P(GetAssetsInBethesdaArchive_BA2Version, shouldSupportBA2Version) { - const auto assets = GetAssetsInBethesdaArchive(path); - - EXPECT_FALSE(assets.empty()); -} - -TEST(GetAssetsInBethesdaArchives, shouldSkipFilesThatCannotBeRead) { - std::vector paths( - {std::filesystem::u8path("invalid.bsa"), - getSourceArchivesPath(GameType::tes5) / "Blank.bsa"}); - - const auto assets = GetAssetsInBethesdaArchives(paths); - - size_t filesCount = 0; - for (const auto& folder : assets) { - filesCount += folder.second.size(); - } - - EXPECT_EQ(1, assets.size()); - EXPECT_EQ(1, filesCount); - EXPECT_EQ(0x2E01002E, assets.begin()->first); - EXPECT_EQ(1, assets.begin()->second.size()); - EXPECT_EQ(0x4670B6836C077365, *assets.begin()->second.begin()); -} - -TEST(GetAssetsInBethesdaArchives, shouldCombineAssetsFromEachLoadedArchive) { - std::vector paths( - {getSourceArchivesPath(GameType::tes4) / "Blank.bsa", - getSourceArchivesPath(GameType::tes5) / "Blank.bsa", - getSourceArchivesPath(GameType::tes5se) / "Blank.bsa"}); - - const auto assets = GetAssetsInBethesdaArchives(paths); - - size_t filesCount = 0; - for (const auto& folder : assets) { - filesCount += folder.second.size(); - } - - EXPECT_EQ(3, assets.size()); - EXPECT_EQ(3, filesCount); - - EXPECT_EQ(1, assets.at(0).size()); - EXPECT_EQ(0x4670B6836C077365, *assets.at(0).begin()); - - EXPECT_EQ(1, assets.at(0x2E01002E).size()); - EXPECT_EQ(0x4670B6836C077365, *assets.at(0x2E01002E).begin()); - - EXPECT_EQ(1, assets.at(0xB68102C964176E73).size()); - EXPECT_EQ(0x4670B6836C077365, *assets.at(0xB68102C964176E73).begin()); -} - -TEST(DoAssetsIntersect, shouldReturnTrueIfTheSameFileExistsInTheSameFolder) { - const auto path = getSourceArchivesPath(GameType::tes4) / "Blank.bsa"; - - const auto assets = GetAssetsInBethesdaArchive(path); - - EXPECT_TRUE(DoAssetsIntersect(assets, assets)); -} - -TEST(DoAssetsIntersect, - shouldReturnFalseIfTheSameFileExistsInDifferentFolders) { - const auto path1 = getSourceArchivesPath(GameType::tes4) / "Blank.bsa"; - const auto assets1 = GetAssetsInBethesdaArchive(path1); - - const auto path2 = getSourceArchivesPath(GameType::tes5) / "Blank.bsa"; - const auto assets2 = GetAssetsInBethesdaArchive(path2); - - EXPECT_EQ(*assets2.at(0x2E01002E).begin(), *assets1.at(0).begin()); - - EXPECT_FALSE(DoAssetsIntersect(assets1, assets2)); -} -} - -#endif diff --git a/src/tests/api/internals/game/game_cache_test.h b/src/tests/api/internals/game/game_cache_test.h deleted file mode 100644 index b5b37e2c..00000000 --- a/src/tests/api/internals/game/game_cache_test.h +++ /dev/null @@ -1,126 +0,0 @@ -/* LOOT - -A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and -Fallout: New Vegas. - -Copyright (C) 2014-2016 WrinklyNinja - -This file is part of LOOT. - -LOOT is free software: you can redistribute -it and/or modify it under the terms of the GNU General Public License -as published by the Free Software Foundation, either version 3 of -the License, or (at your option) any later version. - -LOOT is distributed in the hope that it will -be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with LOOT. If not, see -. -*/ - -#ifndef LOOT_TESTS_API_INTERNALS_GAME_GAME_CACHE_TEST -#define LOOT_TESTS_API_INTERNALS_GAME_GAME_CACHE_TEST - -#include "api/game/game.h" -#include "api/game/game_cache.h" -#include "tests/common_game_test_fixture.h" - -namespace loot { -namespace test { -class GameCacheTest : public CommonGameTestFixture { -protected: - GameCacheTest() : - CommonGameTestFixture(GameType::tes5), - game_(GameType::tes5, gamePath, localPath), - condition("Condition"), - conditionLowercase("condition") {} - - Game game_; - GameCache cache_; - - const std::string condition; - const std::string conditionLowercase; -}; - -TEST_F(GameCacheTest, addingAPluginThatDoesNotExistShouldSucceed) { - cache_.AddPlugin( - Plugin(game_.GetType(), GameCache(), game_.DataPath() / blankEsm, true)); - EXPECT_EQ(blankEsm, cache_.GetPlugin(blankEsm)->GetName()); -} - -TEST_F(GameCacheTest, - addingAPluginThatIsAlreadyCachedShouldOverwriteExistingEntry) { - cache_.AddPlugin( - Plugin(game_.GetType(), GameCache(), game_.DataPath() / blankEsm, true)); - EXPECT_FALSE(cache_.GetPlugin(blankEsm)->GetCRC()); - - cache_.AddPlugin( - Plugin(game_.GetType(), GameCache(), game_.DataPath() / blankEsm, false)); - EXPECT_EQ(blankEsmCrc, cache_.GetPlugin(blankEsm)->GetCRC().value()); -} - -TEST_F(GameCacheTest, gettingAPluginThatIsNotCachedShouldReturnANullPointer) { - EXPECT_FALSE(cache_.GetPlugin(blankEsm)); -} - -TEST_F(GameCacheTest, gettingAPluginShouldBeCaseInsensitive) { - cache_.AddPlugin( - Plugin(game_.GetType(), GameCache(), game_.DataPath() / blankEsm, true)); - EXPECT_EQ(blankEsm, cache_.GetPlugin(blankEsm)->GetName()); -} - -TEST_F(GameCacheTest, - gettingPluginsShouldReturnAnEmptySetIfNoPluginsHaveBeenCached) { - EXPECT_TRUE(cache_.GetPlugins().empty()); -} - -TEST_F(GameCacheTest, - gettingPluginsShouldReturnASetOfCachedPluginsIfPluginsHaveBeenCached) { - cache_.AddPlugin( - Plugin(game_.GetType(), GameCache(), game_.DataPath() / blankEsm, true)); - cache_.AddPlugin( - Plugin(game_.GetType(), - GameCache(), - game_.DataPath() / (blankMasterDependentEsm + ".ghost"), - true)); - - EXPECT_FALSE(cache_.GetPlugins().empty()); -} - -TEST_F(GameCacheTest, - gettingArchivePathsShouldReturnAnEmptySetIfNoPathsHaveBeenCached) { - EXPECT_TRUE(cache_.GetArchivePaths().empty()); -} - -TEST_F(GameCacheTest, - gettingArchivePathsShouldReturnASetOfPathsIfPathsHaveBeenCached) { - cache_.CacheArchivePaths({game_.DataPath() / blankEsm, - game_.DataPath() / blankMasterDependentEsm}); - - auto expected = std::set({ - game_.DataPath() / blankEsm, - game_.DataPath() / blankMasterDependentEsm, - }); - - EXPECT_EQ(expected, cache_.GetArchivePaths()); -} - -TEST_F(GameCacheTest, clearingCachedPluginsShouldNotThrowIfNoPluginsAreCached) { - EXPECT_NO_THROW(cache_.ClearCachedPlugins()); -} - -TEST_F(GameCacheTest, clearingCachedPluginsShouldClearAnyCachedPlugins) { - cache_.AddPlugin( - Plugin(game_.GetType(), GameCache(), game_.DataPath() / blankEsm, true)); - cache_.ClearCachedPlugins(); - - EXPECT_TRUE(cache_.GetPlugins().empty()); -} -} -} - -#endif diff --git a/src/tests/api/internals/game/game_test.h b/src/tests/api/internals/game/game_test.h deleted file mode 100644 index 7377c2ce..00000000 --- a/src/tests/api/internals/game/game_test.h +++ /dev/null @@ -1,114 +0,0 @@ -/* LOOT - -A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and -Fallout: New Vegas. - -Copyright (C) 2014-2016 WrinklyNinja - -This file is part of LOOT. - -LOOT is free software: you can redistribute -it and/or modify it under the terms of the GNU General Public License -as published by the Free Software Foundation, either version 3 of -the License, or (at your option) any later version. - -LOOT is distributed in the hope that it will -be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with LOOT. If not, see -. -*/ - -#ifndef LOOT_TESTS_API_INTERNALS_GAME_GAME_TEST -#define LOOT_TESTS_API_INTERNALS_GAME_GAME_TEST - -#include "api/game/game.h" -#include "tests/common_game_test_fixture.h" - -namespace loot { -namespace test { -class GameTest : public CommonGameTestFixture, - public testing::WithParamInterface { -protected: - GameTest() : - CommonGameTestFixture(GetParam()), - blankArchive("Blank" + GetArchiveFileExtension(GetParam())) { - touch(dataPath / blankArchive); - } - - void loadInstalledPlugins(Game& game, bool headersOnly) { - const auto plugins = GetInstalledPlugins(); - game.LoadPlugins(plugins, headersOnly); - } - - const std::string blankArchive; -}; - -// Pass an empty first argument, as it's a prefix for the test instantation, -// but we only have the one so no prefix is necessary. -INSTANTIATE_TEST_SUITE_P(, GameTest, ::testing::ValuesIn(ALL_GAME_TYPES)); - -TEST_P(GameTest, constructingShouldStoreTheGivenValues) { - Game game = Game(GetParam(), gamePath, localPath); - - EXPECT_EQ(GetParam(), game.GetType()); - EXPECT_EQ(dataPath, game.DataPath()); -} - -TEST_P( - GameTest, - loadPluginsShouldFindAndCacheArchivesForLoadDetectionWhenLoadingPlugins) { - Game game = Game(GetParam(), gamePath, localPath); - - EXPECT_NO_THROW(loadInstalledPlugins(game, false)); - - auto expected = std::set({dataPath / blankArchive}); - EXPECT_EQ(expected, game.GetCache().GetArchivePaths()); -} - -TEST_P(GameTest, loadPluginsShouldFindArchivesInAdditionalDataPaths) { - // Create a couple of external archive files. - const std::string archiveFileExtension = - GetParam() == GameType::fo4 || GetParam() == GameType::fo4vr || - GetParam() == GameType::starfield - ? ".ba2" - : ".bsa"; - - const auto ba2Path1 = - gamePath / - ("../../Fallout 4- Far Harbor (PC)/Content/Data/DLCCoast - Main" + - archiveFileExtension); - const auto ba2Path2 = - gamePath / ("../../Fallout 4- Nuka-World (PC)/Content/Data/DLCNukaWorld " - "- Voices_it" + - archiveFileExtension); - touch(ba2Path1); - touch(ba2Path2); - - Game game = Game(GetParam(), gamePath, localPath); - - game.SetAdditionalDataPaths({ba2Path1.parent_path(), ba2Path2.parent_path()}); - - EXPECT_NO_THROW(loadInstalledPlugins(game, true)); - - const auto archivePaths = game.GetCache().GetArchivePaths(); - - EXPECT_EQ(std::set( - {ba2Path1, ba2Path2, dataPath / blankArchive}), - archivePaths); -} - -TEST_P(GameTest, loadPluginsShouldClearTheArchivesCacheBeforeFindingArchives) { - Game game = Game(GetParam(), gamePath, localPath); - - EXPECT_NO_THROW(loadInstalledPlugins(game, false)); - EXPECT_NO_THROW(loadInstalledPlugins(game, false)); - EXPECT_EQ(1, game.GetCache().GetArchivePaths().size()); -} -} -} - -#endif diff --git a/src/tests/api/internals/game/load_order_handler_test.h b/src/tests/api/internals/game/load_order_handler_test.h deleted file mode 100644 index e7466b05..00000000 --- a/src/tests/api/internals/game/load_order_handler_test.h +++ /dev/null @@ -1,327 +0,0 @@ -/* LOOT - -A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and -Fallout: New Vegas. - -Copyright (C) 2014-2016 WrinklyNinja - -This file is part of LOOT. - -LOOT is free software: you can redistribute -it and/or modify it under the terms of the GNU General Public License -as published by the Free Software Foundation, either version 3 of -the License, or (at your option) any later version. - -LOOT is distributed in the hope that it will -be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with LOOT. If not, see -. -*/ - -#ifndef LOOT_TESTS_API_INTERNALS_LOAD_ORDER_HANDLER_TEST -#define LOOT_TESTS_API_INTERNALS_LOAD_ORDER_HANDLER_TEST - -#include "api/game/load_order_handler.h" -#include "tests/common_game_test_fixture.h" - -namespace loot { -namespace test { -class LoadOrderHandlerTest : public CommonGameTestFixture, - public testing::WithParamInterface { -protected: - LoadOrderHandlerTest() : - CommonGameTestFixture(GetParam()), - loadOrderToSet_({ - masterFile, - blankEsm, - blankMasterDependentEsm, - blankDifferentEsm, - blankDifferentMasterDependentEsm, - blankDifferentEsp, - blankDifferentPluginDependentEsp, - blankEsp, - blankMasterDependentEsp, - blankDifferentMasterDependentEsp, - blankPluginDependentEsp, - }) { - if (GetParam() == GameType::fo4 || GetParam() == GameType::tes5se) { - loadOrderToSet_.insert(loadOrderToSet_.begin() + 5, blankEsl); - } else if (GetParam() == GameType::starfield) { - loadOrderToSet_ = { - masterFile, - blankEsm, - blankMasterDependentEsm, - blankDifferentEsm, - blankDifferentEsp, - blankEsp, - blankMasterDependentEsp, - }; - } - } - - LoadOrderHandler createHandler() { - return LoadOrderHandler(GetParam(), gamePath, localPath); - } - - std::vector getEarlyLoadingPlugins() { - switch (GetParam()) { - case GameType::openmw: - return {"builtin.omwscripts"}; - case GameType::tes5: - return {"Skyrim.esm"}; - case GameType::tes5se: - return {"Skyrim.esm", - "Update.esm", - "Dawnguard.esm", - "HearthFires.esm", - "Dragonborn.esm"}; - case GameType::tes5vr: - return {"Skyrim.esm", - "Update.esm", - "Dawnguard.esm", - "HearthFires.esm", - "Dragonborn.esm", - "SkyrimVR.esm"}; - case GameType::fo4: - return {"Fallout4.esm", - "DLCRobot.esm", - "DLCworkshop01.esm", - "DLCCoast.esm", - "DLCworkshop02.esm", - "DLCworkshop03.esm", - "DLCNukaWorld.esm", - "DLCUltraHighResolution.esm"}; - case GameType::fo4vr: - return {"Fallout4.esm", "Fallout4_VR.esm"}; - case GameType::starfield: - return {"Starfield.esm", - "Constellation.esm", - "OldMars.esm", - "ShatteredSpace.esm", - "SFBGS003.esm", - "SFBGS004.esm", - "SFBGS006.esm", - "SFBGS007.esm", - "SFBGS008.esm"}; - default: - return {}; - } - } - - std::vector getActivePlugins() { - std::vector activePlugins; - for (auto& pair : getInitialLoadOrder()) { - if (pair.second) { - activePlugins.push_back(pair.first); - } - } - return activePlugins; - } - - std::vector loadOrderToSet_; -}; - -// Pass an empty first argument, as it's a prefix for the test instantation, -// but we only have the one so no prefix is necessary. -INSTANTIATE_TEST_SUITE_P(, - LoadOrderHandlerTest, - ::testing::ValuesIn(ALL_GAME_TYPES)); - -TEST_P(LoadOrderHandlerTest, constructorShouldThrowIfNoGamePathIsSet) { - EXPECT_THROW(LoadOrderHandler(GetParam(), ""), std::invalid_argument); - EXPECT_THROW(LoadOrderHandler(GetParam(), ""), std::invalid_argument); - EXPECT_THROW(LoadOrderHandler(GetParam(), "", localPath), - std::invalid_argument); - EXPECT_THROW(LoadOrderHandler(GetParam(), "", localPath), - std::invalid_argument); -} - -#ifdef _WIN32 -TEST_P(LoadOrderHandlerTest, constructorShouldNotThrowIfNoLocalPathIsSet) { - EXPECT_NO_THROW(LoadOrderHandler(GetParam(), gamePath)); -} -#else -TEST_P(LoadOrderHandlerTest, - constructorShouldNotThrowIfNoLocalPathIsSetAndGameTypeIsMorrowind) { - if (GetParam() == GameType::tes3 || GetParam() == GameType::openmw || - GetParam() == GameType::oblivionRemastered) { - EXPECT_NO_THROW(LoadOrderHandler(GetParam(), gamePath)); - } else { - EXPECT_THROW(LoadOrderHandler(GetParam(), gamePath), std::runtime_error); - } -} -#endif - -TEST_P(LoadOrderHandlerTest, - constructorShouldNotThrowIfAValidGameIdAndGamePathAndLocalPathAreSet) { - EXPECT_NO_THROW(LoadOrderHandler(GetParam(), gamePath, localPath)); -} - -TEST_P(LoadOrderHandlerTest, - isAmbiguousShouldReturnFalseForAnUnambiguousLoadOrder) { - auto loadOrderHandler = createHandler(); - - EXPECT_FALSE(loadOrderHandler.IsAmbiguous()); -} - -TEST_P(LoadOrderHandlerTest, - isPluginActiveShouldReturnFalseIfLoadOrderStateHasNotBeenLoaded) { - auto loadOrderHandler = createHandler(); - - EXPECT_FALSE(loadOrderHandler.IsPluginActive(masterFile)); - EXPECT_FALSE(loadOrderHandler.IsPluginActive(blankEsm)); - EXPECT_FALSE(loadOrderHandler.IsPluginActive(blankEsp)); -} - -TEST_P(LoadOrderHandlerTest, - isPluginActiveShouldReturnCorrectPluginStatesAfterInitialisation) { - auto loadOrderHandler = createHandler(); - loadOrderHandler.LoadCurrentState(); - - EXPECT_TRUE(loadOrderHandler.IsPluginActive(masterFile)); - EXPECT_TRUE(loadOrderHandler.IsPluginActive(blankEsm)); - EXPECT_FALSE(loadOrderHandler.IsPluginActive(blankEsp)); -} - -TEST_P(LoadOrderHandlerTest, - getLoadOrderShouldReturnAnEmptyVectorIfStateHasNotBeenLoaded) { - auto loadOrderHandler = createHandler(); - - EXPECT_TRUE(loadOrderHandler.GetLoadOrder().empty()); -} - -TEST_P(LoadOrderHandlerTest, getLoadOrderShouldReturnTheCurrentLoadOrder) { - auto loadOrderHandler = createHandler(); - loadOrderHandler.LoadCurrentState(); - - if (GetParam() == GameType::openmw) { - EXPECT_EQ(std::vector({ - blankDifferentEsm, - blankDifferentMasterDependentEsm, - blankDifferentEsp, - blankDifferentPluginDependentEsp, - blankMasterDependentEsm, - blankMasterDependentEsp, - blankEsp, - blankPluginDependentEsp, - masterFile, - blankEsm, - blankDifferentMasterDependentEsp, - }), - loadOrderHandler.GetLoadOrder()); - } else { - ASSERT_EQ(getLoadOrder(), loadOrderHandler.GetLoadOrder()); - } -} - -TEST_P(LoadOrderHandlerTest, - getActivePluginsShouldReturnAnEmptyVectorIfStateHasNotBeenLoaded) { - auto loadOrderHandler = createHandler(); - - EXPECT_TRUE(loadOrderHandler.GetActivePlugins().empty()); -} - -TEST_P(LoadOrderHandlerTest, getActivePluginsShouldReturnOnlyActivePlugins) { - auto loadOrderHandler = createHandler(); - loadOrderHandler.LoadCurrentState(); - - ASSERT_EQ(getActivePlugins(), loadOrderHandler.GetActivePlugins()); -} - -TEST_P(LoadOrderHandlerTest, - getEarlyLoadingPluginsShouldReturnValidDataEvenIfStateHasNotBeenLoaded) { - auto loadOrderHandler = createHandler(); - - ASSERT_EQ(getEarlyLoadingPlugins(), - loadOrderHandler.GetEarlyLoadingPlugins()); - - loadOrderHandler.LoadCurrentState(); - - ASSERT_EQ(getEarlyLoadingPlugins(), - loadOrderHandler.GetEarlyLoadingPlugins()); -} - -TEST_P(LoadOrderHandlerTest, getAdditionalDataPathsShouldReturnValidData) { - if (GetParam() == GameType::fo4) { - // Create the file that indicates it's a Microsoft Store install. - touch(gamePath / "appxmanifest.xml"); - } else if (GetParam() == GameType::openmw) { - std::ofstream out(gamePath / "openmw.cfg"); - out << "data-local=\"" << (localPath / "data").u8string() << "\"" - << std::endl - << "config=\"" << localPath.u8string() << "\""; - } - - auto loadOrderHandler = createHandler(); - - if (GetParam() == GameType::fo4) { - const auto basePath = gamePath / ".." / ".."; - EXPECT_EQ(std::vector( - {basePath / "Fallout 4- Automatron (PC)" / "Content" / "Data", - basePath / "Fallout 4- Nuka-World (PC)" / "Content" / "Data", - basePath / "Fallout 4- Wasteland Workshop (PC)" / "Content" / - "Data", - basePath / "Fallout 4- High Resolution Texture Pack" / - "Content" / "Data", - basePath / "Fallout 4- Vault-Tec Workshop (PC)" / "Content" / - "Data", - basePath / "Fallout 4- Far Harbor (PC)" / "Content" / "Data", - basePath / "Fallout 4- Contraptions Workshop (PC)" / - "Content" / "Data"}), - loadOrderHandler.GetAdditionalDataPaths()); - } else if (GetParam() == GameType::starfield) { - ASSERT_EQ(1, loadOrderHandler.GetAdditionalDataPaths().size()); - - const auto expectedSuffix = std::filesystem::u8path("Documents") / - "My Games" / "Starfield" / "Data"; - EXPECT_TRUE(boost::ends_with( - loadOrderHandler.GetAdditionalDataPaths()[0].u8string(), - expectedSuffix.u8string())); - } else if (GetParam() == GameType::openmw) { - EXPECT_EQ(std::vector{localPath / "data"}, - loadOrderHandler.GetAdditionalDataPaths()); - } else { - EXPECT_TRUE(loadOrderHandler.GetAdditionalDataPaths().empty()); - } -} - -TEST_P(LoadOrderHandlerTest, setLoadOrderShouldSetTheLoadOrder) { - auto loadOrderHandler = createHandler(); - loadOrderHandler.LoadCurrentState(); - - EXPECT_NO_THROW(loadOrderHandler.SetLoadOrder(loadOrderToSet_)); - - if (GetParam() == GameType::fo4 || GetParam() == GameType::fo4vr || - GetParam() == GameType::tes5se || GetParam() == GameType::tes5vr || - GetParam() == GameType::starfield) - loadOrderToSet_.erase(begin(loadOrderToSet_)); - - if (GetParam() == GameType::openmw) { - // Can't set the load order positions of inactive plugins, - // this reads what libloadorder has cached in memory instead of - // what was actually saved. - EXPECT_EQ(loadOrderToSet_, loadOrderHandler.GetLoadOrder()); - } else { - EXPECT_EQ(loadOrderToSet_, getLoadOrder()); - } -} - -TEST_P(LoadOrderHandlerTest, setExternalPluginPathsShouldAcceptAnEmptyVector) { - auto loadOrderHandler = createHandler(); - EXPECT_NO_THROW(loadOrderHandler.SetAdditionalDataPaths({})); -} - -TEST_P(LoadOrderHandlerTest, - setExternalPluginPathsShouldAcceptANonEmptyVector) { - auto loadOrderHandler = createHandler(); - EXPECT_NO_THROW(loadOrderHandler.SetAdditionalDataPaths( - {std::filesystem::u8path("a"), std::filesystem::u8path("b")})); -} -} -} - -#endif diff --git a/src/tests/api/internals/helpers/crc_test.h b/src/tests/api/internals/helpers/crc_test.h deleted file mode 100644 index e16c7434..00000000 --- a/src/tests/api/internals/helpers/crc_test.h +++ /dev/null @@ -1,50 +0,0 @@ -/* LOOT - -A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and -Fallout: New Vegas. - -Copyright (C) 2014-2016 WrinklyNinja - -This file is part of LOOT. - -LOOT is free software: you can redistribute -it and/or modify it under the terms of the GNU General Public License -as published by the Free Software Foundation, either version 3 of -the License, or (at your option) any later version. - -LOOT is distributed in the hope that it will -be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with LOOT. If not, see -. -*/ - -#ifndef LOOT_TESTS_API_INTERNALS_HELPERS_CRC_TEST -#define LOOT_TESTS_API_INTERNALS_HELPERS_CRC_TEST - -#include - -#include "api/helpers/crc.h" -#include "tests/common_game_test_fixture.h" - -namespace loot { -namespace test { -class GetCrc32Test : public CommonGameTestFixture { -protected: - GetCrc32Test() : CommonGameTestFixture(GameType::tes5) {} -}; - -TEST_F(GetCrc32Test, gettingTheCrcOfAMissingFileShouldThrow) { - EXPECT_THROW(GetCrc32(dataPath / missingEsp), std::runtime_error); -} - -TEST_F(GetCrc32Test, gettingTheCrcOfAFileShouldReturnTheCorrectValue) { - EXPECT_EQ(blankEsmCrc, GetCrc32(dataPath / blankEsm)); -} -} -} - -#endif diff --git a/src/tests/api/internals/helpers/text_test.h b/src/tests/api/internals/helpers/text_test.h deleted file mode 100644 index c1c9df5b..00000000 --- a/src/tests/api/internals/helpers/text_test.h +++ /dev/null @@ -1,340 +0,0 @@ -/* LOOT - -A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and -Fallout: New Vegas. - -Copyright (C) 2014-2016 WrinklyNinja - -This file is part of LOOT. - -LOOT is free software: you can redistribute -it and/or modify it under the terms of the GNU General Public License -as published by the Free Software Foundation, either version 3 of -the License, or (at your option) any later version. - -LOOT is distributed in the hope that it will -be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with LOOT. If not, see -. -*/ - -#ifndef LOOT_TESTS_API_INTERNALS_HELPERS_TEXT_TEST -#define LOOT_TESTS_API_INTERNALS_HELPERS_TEXT_TEST - -#include - -#include "api/helpers/text.h" -#include "loot/loot_version.h" - -namespace loot { -namespace test { - -TEST(ExtractBashTags, shouldExtractTagsFromPluginDescriptionText) { - auto description = R"raw(Unofficial Skyrim Special Edition Patch - -A comprehensive bugfixing mod for The Elder Scrolls V: Skyrim - Special Edition - -Version: 4.1.4 - -Requires Skyrim Special Edition 1.5.39 or greater. - -{{BASH:C.Climate,C.Encounter,C.ImageSpace,C.Light,C.Location,C.Music,C.Name,C.Owner,C.Water,Delev,Graphics,Invent,Names,Relev,Sound,Stats}})raw"; - - auto tags = ExtractBashTags(description); - - std::vector expectedTags({ - "C.Climate", - "C.Encounter", - "C.ImageSpace", - "C.Light", - "C.Location", - "C.Music", - "C.Name", - "C.Owner", - "C.Water", - "Delev", - "Graphics", - "Invent", - "Names", - "Relev", - "Sound", - "Stats", - }); - - EXPECT_EQ(expectedTags, tags); -} - -TEST(ExtractVersion, shouldExtractAVersionContainingASingleDigit) { - EXPECT_EQ("5", ExtractVersion("5").value()); -} - -TEST(ExtractVersion, shouldExtractAVersionContainingMultipleDigits) { - EXPECT_EQ("10", ExtractVersion("10").value()); -} - -TEST(ExtractVersion, shouldExtractAVersionContainingMultipleNumbers) { - EXPECT_EQ("10.11.12.13", ExtractVersion("10.11.12.13").value()); -} - -TEST(ExtractVersion, shouldExtractASemanticVersion) { - EXPECT_EQ("1.0.0-x.7.z.92", - ExtractVersion("1.0.0-x.7.z.92+exp.sha.5114f85").value()); -} - -TEST(ExtractVersion, - shouldExtractAPseudosemExtendedVersionStoppingAtTheFirstSpaceSeparator) { - EXPECT_EQ("01.0.0_alpha:1-2", ExtractVersion("01.0.0_alpha:1-2 3").value()); -} - -TEST(ExtractVersion, shouldExtractAVersionSubstring) { - EXPECT_EQ("5.0", ExtractVersion("v5.0").value()); -} - -TEST(ExtractVersion, shouldBeEmptyIfInputStringContainedNoVersion) { - EXPECT_FALSE(ExtractVersion("The quick brown fox jumped over the lazy dog.") - .has_value()); -} - -TEST(ExtractVersion, shouldExtractTimestampWithForwardslashDateSeparators) { - // Found in a Bashed Patch. Though the timestamp isn't useful to - // LOOT, it is semantically a ExtractVersion, and extracting it is far - // easier than trying to skip it and the number of records changed. - auto text = - ExtractVersion("Updated: 10/09/2016 13:15:18\r\n\r\nRecords Changed: 43"); - EXPECT_EQ("10/09/2016 13:15:18", text.value()); -} - -TEST(ExtractVersion, shouldNotExtractTrailingPeriods) { - // Found in . - EXPECT_EQ("0.2", ExtractVersion("Version 0.2.").value()); -} - -TEST(ExtractVersion, - shouldExtractVersionAfterTextWhenPrecededByVersionColonString) { - // Found in . - EXPECT_EQ("3.0.0", - ExtractVersion("Legendary Edition\r\n\r\nVersion: 3.0.0").value()); -} - -TEST(ExtractVersion, shouldIgnoreNumbersContainingCommas) { - // Found in . - EXPECT_EQ( - "3.5.3", - ExtractVersion("fixing over 2,300 bugs so far! Version: 3.5.3").value()); -} - -TEST(ExtractVersion, shouldExtractVersionBeforeText) { - // Found in . - EXPECT_EQ( - "2.1", - ExtractVersion("Version: 2.1 The Unofficial Fallout 3 Patch").value()); -} - -TEST(ExtractVersion, shouldExtractVersionWithPrecedingV) { - // Found in . - EXPECT_EQ("2.11", ExtractVersion("V2.11\r\n\r\n{{BASH:Invent}}").value()); -} - -TEST(ExtractVersion, shouldExtractVersionWithPrecedingColonPeriodWhitespace) { - // Found in . - EXPECT_EQ("1.09", ExtractVersion("Version:. 1.09").value()); -} - -TEST(ExtractVersion, shouldExtractVersionWithLettersImmediatelyAfterNumbers) { - // Found in . - auto text = ExtractVersion( - "comprehensive bugfixing mod for The Elder Scrolls V: " - "Skyrim\r\n\r\nVersion: 2.1.3b\r\n\r\n"); - EXPECT_EQ("2.1.3b", text.value()); -} - -TEST(ExtractVersion, shouldExtractVersionWithPeriodAndNoPrecedingIdentifier) { - // Found in . - EXPECT_EQ("5.1", ExtractVersion("SkyUI 5.1").value()); -} - -TEST(ExtractVersion, shouldNotExtractSingleDigitInSentence) { - // Found in . - auto text = ExtractVersion( - "Adds 8 variants of Triss Merigold's outfit from \"The Witcher 2\""); - EXPECT_FALSE(text.has_value()); -} - -TEST(ExtractVersion, shouldPreferVersionPrefixedNumbersOverVersionsInSentence) { - // Found in - auto text = ExtractVersion( - "Requires Skyrim patch 1.9.32.0.8 or greater.\n" - "Requires Unofficial Skyrim Legendary Edition Patch 3.0.0 or greater.\n" - "Version 2.0.0"); - EXPECT_EQ("2.0.0", text.value()); -} - -TEST(ExtractVersion, shouldExtractSingleDigitVersionPrecededByV) { - // Found in - EXPECT_EQ("8", ExtractVersion("Immersive Armors v8 Main Plugin").value()); -} - -TEST(ExtractVersion, shouldPreferVersionPrefixedNumbersOverVPrefixedNumber) { - // Found in - auto text = ExtractVersion( - "Compatibility patch for AOS v2.5 and True Storms v1.5 (or " - "later),\nPatch Version: 1.0"); - EXPECT_EQ("1.0", text.value()); -} - -TEST(ExtractVersion, shouldExtractSingleDigitAfterVersionColonSpace) { - // Found in - EXPECT_EQ("2", ExtractVersion("Version: 2 {{BASH:C.Water}}").value()); -} - -// MSVC interprets source files in the default code page, so -// for me u8"\xC3\x9C" != u8"\u00DC", which is a lot of fun. -// To avoid insanity, write non-ASCII characters as \uXXXX escapes. -// \u03a1 is greek rho uppercase 'Ρ' -// \u03c1 is greek rho lowercase 'ρ' -// \u03f1 is greek rho 'ϱ' -// \u0130 is turkish 'İ' -// \u0131 is turkish 'ı' - -TEST(CompareFilenames, shouldBeCaseInsensitiveAndLocaleInvariant) { - // ICU sees all three greek rhos as case-insensitively equal, unlike Windows. - // A small enough deviation that it should hopefully be insignificant. -#ifdef _WIN32 - const char* turkishLocale = "tr-TR"; - const char* greekLocale = "el-GR"; - const int expectedRhoSymbolOrder = 1; -#else - const char* turkishLocale = "tr_TR.UTF-8"; - const char* greekLocale = "el_GR.UTF-8"; - const int expectedRhoSymbolOrder = 0; -#endif - - EXPECT_EQ(0, CompareFilenames("i", "I")); - EXPECT_EQ(-1, CompareFilenames("i", u8"\u0130")); - EXPECT_EQ(-1, CompareFilenames("i", u8"\u0131")); - EXPECT_EQ(-1, CompareFilenames("I", u8"\u0130")); - EXPECT_EQ(-1, CompareFilenames("I", u8"\u0131")); - EXPECT_EQ(-1, CompareFilenames(u8"\u0130", u8"\u0131")); - EXPECT_EQ(expectedRhoSymbolOrder, CompareFilenames(u8"\u03f1", u8"\u03a1")); - EXPECT_EQ(expectedRhoSymbolOrder, CompareFilenames(u8"\u03f1", u8"\u03c1")); - EXPECT_EQ(0, CompareFilenames(u8"\u03a1", u8"\u03c1")); - - // Set locale to Turkish. - std::locale::global(std::locale(turkishLocale)); - - EXPECT_EQ(0, CompareFilenames("i", "I")); - EXPECT_EQ(-1, CompareFilenames("i", u8"\u0130")); - EXPECT_EQ(-1, CompareFilenames("i", u8"\u0131")); - EXPECT_EQ(-1, CompareFilenames("I", u8"\u0130")); - EXPECT_EQ(-1, CompareFilenames("I", u8"\u0131")); - EXPECT_EQ(-1, CompareFilenames(u8"\u0130", u8"\u0131")); - EXPECT_EQ(expectedRhoSymbolOrder, CompareFilenames(u8"\u03f1", u8"\u03a1")); - EXPECT_EQ(expectedRhoSymbolOrder, CompareFilenames(u8"\u03f1", u8"\u03c1")); - EXPECT_EQ(0, CompareFilenames(u8"\u03a1", u8"\u03c1")); - - // Set locale to Greek. - std::locale::global(std::locale(greekLocale)); - - EXPECT_EQ(0, CompareFilenames("i", "I")); - EXPECT_EQ(-1, CompareFilenames("i", u8"\u0130")); - EXPECT_EQ(-1, CompareFilenames("i", u8"\u0131")); - EXPECT_EQ(-1, CompareFilenames("I", u8"\u0130")); - EXPECT_EQ(-1, CompareFilenames("I", u8"\u0131")); - EXPECT_EQ(-1, CompareFilenames(u8"\u0130", u8"\u0131")); - EXPECT_EQ(expectedRhoSymbolOrder, CompareFilenames(u8"\u03f1", u8"\u03a1")); - EXPECT_EQ(expectedRhoSymbolOrder, CompareFilenames(u8"\u03f1", u8"\u03c1")); - EXPECT_EQ(0, CompareFilenames(u8"\u03a1", u8"\u03c1")); - - // Reset locale. - std::locale::global(std::locale::classic()); -} - -#ifdef _WIN32 -TEST(NormalizeFilename, shouldUppercaseStringsAndBeLocaleInvariant) { - EXPECT_EQ("I", NormalizeFilename("i")); - EXPECT_EQ("I", NormalizeFilename("I")); - EXPECT_EQ(u8"\u0130", NormalizeFilename(u8"\u0130")); - EXPECT_EQ(u8"\u0131", NormalizeFilename(u8"\u0131")); - EXPECT_EQ(u8"\u03f1", NormalizeFilename(u8"\u03f1")); - EXPECT_EQ(u8"\u03a1", NormalizeFilename(u8"\u03a1")); - EXPECT_EQ(u8"\u03a1", NormalizeFilename(u8"\u03c1")); - - // Set locale to Turkish. - std::locale::global(std::locale("tr-TR")); - - EXPECT_EQ("I", NormalizeFilename("i")); - EXPECT_EQ("I", NormalizeFilename("I")); - EXPECT_EQ(u8"\u0130", NormalizeFilename(u8"\u0130")); - EXPECT_EQ(u8"\u0131", NormalizeFilename(u8"\u0131")); - EXPECT_EQ(u8"\u03f1", NormalizeFilename(u8"\u03f1")); - EXPECT_EQ(u8"\u03a1", NormalizeFilename(u8"\u03a1")); - EXPECT_EQ(u8"\u03a1", NormalizeFilename(u8"\u03c1")); - - // Set locale to Greek. - std::locale::global(std::locale("el-GR")); - - EXPECT_EQ("I", NormalizeFilename("i")); - EXPECT_EQ("I", NormalizeFilename("I")); - EXPECT_EQ(u8"\u0130", NormalizeFilename(u8"\u0130")); - EXPECT_EQ(u8"\u0131", NormalizeFilename(u8"\u0131")); - EXPECT_EQ(u8"\u03f1", NormalizeFilename(u8"\u03f1")); - EXPECT_EQ(u8"\u03a1", NormalizeFilename(u8"\u03a1")); - EXPECT_EQ(u8"\u03a1", NormalizeFilename(u8"\u03c1")); - - // Reset locale. - std::locale::global(std::locale::classic()); -} - -TEST(NormalizeFilename, shouldReturnAnEmptyStringIfGivenAnEmptyString) { - EXPECT_EQ("", NormalizeFilename(std::string())); - EXPECT_EQ("", NormalizeFilename("")); -} -#else -TEST(NormalizeFilename, shouldCaseFoldStringsAndBeLocaleInvariant) { - // ICU folds all greek rhos to the lowercase rho, unlike Windows. The result - // for uppercase turkish i is different from Windows but functionally - // equivalent. - // A small enough deviation that it should hopefully be insignificant. - - EXPECT_EQ("i", NormalizeFilename("i")); - EXPECT_EQ("i", NormalizeFilename("I")); - EXPECT_EQ(u8"i\u0307", NormalizeFilename(u8"\u0130")); - EXPECT_EQ(u8"\u0131", NormalizeFilename(u8"\u0131")); - EXPECT_EQ(u8"\u03c1", NormalizeFilename(u8"\u03f1")); - EXPECT_EQ(u8"\u03c1", NormalizeFilename(u8"\u03a1")); - EXPECT_EQ(u8"\u03c1", NormalizeFilename(u8"\u03c1")); - - // Set locale to Turkish. - std::locale::global(std::locale("tr_TR.UTF-8")); - - EXPECT_EQ("i", NormalizeFilename("i")); - EXPECT_EQ("i", NormalizeFilename("I")); - EXPECT_EQ(u8"i\u0307", NormalizeFilename(u8"\u0130")); - EXPECT_EQ(u8"\u0131", NormalizeFilename(u8"\u0131")); - EXPECT_EQ(u8"\u03c1", NormalizeFilename(u8"\u03f1")); - EXPECT_EQ(u8"\u03c1", NormalizeFilename(u8"\u03a1")); - EXPECT_EQ(u8"\u03c1", NormalizeFilename(u8"\u03c1")); - - // Set locale to Greek. - std::locale::global(std::locale("el_GR.UTF-8")); - - EXPECT_EQ("i", NormalizeFilename("i")); - EXPECT_EQ("i", NormalizeFilename("I")); - EXPECT_EQ(u8"i\u0307", NormalizeFilename(u8"\u0130")); - EXPECT_EQ(u8"\u0131", NormalizeFilename(u8"\u0131")); - EXPECT_EQ(u8"\u03c1", NormalizeFilename(u8"\u03f1")); - EXPECT_EQ(u8"\u03c1", NormalizeFilename(u8"\u03a1")); - EXPECT_EQ(u8"\u03c1", NormalizeFilename(u8"\u03c1")); - - // Reset locale. - std::locale::global(std::locale::classic()); -} -#endif -} -} - -#endif diff --git a/src/tests/api/internals/main.cpp b/src/tests/api/internals/main.cpp deleted file mode 100644 index 0850b60e..00000000 --- a/src/tests/api/internals/main.cpp +++ /dev/null @@ -1,219 +0,0 @@ -/* LOOT - - A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and - Fallout: New Vegas. - - Copyright (C) 2014-2016 WrinklyNinja - - This file is part of LOOT. - - LOOT is free software: you can redistribute - it and/or modify it under the terms of the GNU General Public License - as published by the Free Software Foundation, either version 3 of - the License, or (at your option) any later version. - - LOOT is distributed in the hope that it will - be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with LOOT. If not, see - . - */ - -#include "tests/api/internals/bsa_test.h" -#include "tests/api/internals/game/game_cache_test.h" -#include "tests/api/internals/game/game_test.h" -#include "tests/api/internals/game/load_order_handler_test.h" -#include "tests/api/internals/helpers/crc_test.h" -#include "tests/api/internals/helpers/text_test.h" -#include "tests/api/internals/metadata/condition_evaluator_test.h" -#include "tests/api/internals/metadata/yaml/file_test.h" -#include "tests/api/internals/metadata/yaml/group_test.h" -#include "tests/api/internals/metadata/yaml/location_test.h" -#include "tests/api/internals/metadata/yaml/message_content_test.h" -#include "tests/api/internals/metadata/yaml/message_test.h" -#include "tests/api/internals/metadata/yaml/plugin_cleaning_data_test.h" -#include "tests/api/internals/metadata/yaml/plugin_metadata_test.h" -#include "tests/api/internals/metadata/yaml/tag_test.h" -#include "tests/api/internals/metadata_list_test.h" -#include "tests/api/internals/plugin_test.h" -#include "tests/api/internals/sorting/group_sort_test.h" -#include "tests/api/internals/sorting/plugin_graph_test.h" -#include "tests/api/internals/sorting/plugin_sort_test.h" -#include "tests/api/internals/sorting/plugin_sorting_data_test.h" - -TEST(ModuloOperator, shouldConformToTheCpp11Standard) { - // C++11 defines the modulo operator more strongly - // (only x % 0 is left undefined), whereas C++03 - // only defined the operator for positive first operand. - // Test that the modulo operator has been implemented - // according to C++11. - - EXPECT_EQ(0, 20 % 5); - EXPECT_EQ(0, 20 % -5); - EXPECT_EQ(0, -20 % 5); - EXPECT_EQ(0, -20 % -5); - - EXPECT_EQ(2, 9 % 7); - EXPECT_EQ(2, 9 % -7); - EXPECT_EQ(-2, -9 % 7); - EXPECT_EQ(-2, -9 % -7); -} - -TEST(YamlCpp, shouldSupportMergeKeys) { - YAML::Node node = YAML::Load("{<<: {a: 1}}"); - ASSERT_TRUE(node["a"]); - EXPECT_EQ(1, node["a"].as()); -} - -#ifdef _WIN32 -TEST(Filesystem, - pathStringConstructorDoesNotConvertCharacterEncodingFromUtf8ToNative) { - std::string utf8 = u8"Andr\u00E9_settings.toml"; - std::u16string utf16 = u"Andr\u00E9_settings.toml"; - - ASSERT_EQ('\xc3', utf8[4]); - ASSERT_EQ('\xa9', utf8[5]); - - std::filesystem::path path(utf8); - - EXPECT_EQ(utf8, path.string()); - EXPECT_NE(utf8, path.u8string()); - EXPECT_NE(utf16, path.u16string()); -} - -TEST( - Filesystem, - pathStringAndLocaleConstructorDoesNotConvertCharacterEncodingFromUtf8WithClassicLocale) { - std::string utf8 = u8"Andr\u00E9_settings.toml"; - std::u16string utf16 = u"Andr\u00E9_settings.toml"; - - ASSERT_EQ('\xc3', utf8[4]); - ASSERT_EQ('\xa9', utf8[5]); - - std::filesystem::path path(utf8, std::locale::classic()); - - EXPECT_EQ(utf8, path.string()); - - EXPECT_NE(utf8, path.u8string()); - EXPECT_NE(utf16, path.u16string()); -} -#else -TEST(Filesystem, pathStringConstructorUsesNativeEncodingOfUtf8) { - std::string utf8 = u8"Andr\u00E9_settings.toml"; - std::u16string utf16 = u"Andr\u00E9_settings.toml"; - - ASSERT_EQ('\xc3', utf8[4]); - ASSERT_EQ('\xa9', utf8[5]); - - std::filesystem::path path(utf8); - - EXPECT_EQ(utf8, path.string()); - EXPECT_EQ(utf8, path.u8string()); - EXPECT_EQ(utf16, path.u16string()); -} -#endif - -TEST(Filesystem, u8pathConvertsCharacterEncodingFromUtf8ToNative) { - std::string utf8 = u8"Andr\u00E9_settings.toml"; - std::u16string utf16 = u"Andr\u00E9_settings.toml"; - - ASSERT_EQ('\xc3', utf8[4]); - ASSERT_EQ('\xa9', utf8[5]); - - std::filesystem::path path = std::filesystem::u8path(utf8); - -#ifdef _WIN32 - EXPECT_NE(utf8, path.string()); -#else - EXPECT_EQ(utf8, path.string()); -#endif - - EXPECT_EQ(utf8, path.u8string()); - EXPECT_EQ(utf16, path.u16string()); -} - -TEST(Filesystem, shouldBeAbleToWriteToAndReadFromAUtf8Path) { - std::string utf8 = u8"Andr\u00E9_settings.toml"; - auto path = std::filesystem::u8path(utf8); - std::string output = u8"Test cont\u00E9nt"; - - std::ofstream out(path); - out << output; - out.close(); - - EXPECT_TRUE(std::filesystem::exists(path)); - - std::string input; - std::ifstream in(path); - std::getline(in, input); - in.close(); - - EXPECT_EQ(output, input); - - std::filesystem::remove(path); -} - -TEST(Filesystem, equalityShouldBeCaseSensitive) { - auto upper = std::filesystem::path("LICENSE"); - auto lower = std::filesystem::path("license"); - - ASSERT_NE(lower.u8string(), upper.u8string()); - - EXPECT_NE(lower, upper); -} - -TEST(Filesystem, equivalentShouldRequireThatBothPathsExist) { - auto upper = std::filesystem::path("LICENSE"); - auto lower = std::filesystem::path("license2"); - - ASSERT_FALSE(std::filesystem::exists(upper)); - ASSERT_FALSE(std::filesystem::exists(lower)); - - EXPECT_THROW(std::ignore = std::filesystem::equivalent(lower, upper), - std::filesystem::filesystem_error); -} - -#ifdef _WIN32 -TEST(Filesystem, equivalentShouldBeCaseInsensitive) { - auto upper = std::filesystem::path("./testing-plugins/LICENSE"); - auto lower = std::filesystem::path("./testing-plugins/license"); - - ASSERT_TRUE(std::filesystem::exists(upper)); - ASSERT_TRUE(std::filesystem::exists(lower)); - - EXPECT_TRUE(std::filesystem::equivalent(lower, upper)); -} - -TEST( - Filesystem, - equivalentCannotHandleCharactersThatAreUnrepresentableInTheSystemCodePage) { - auto path1 = std::filesystem::u8path( - u8"\u2551\u00BB\u00C1\u2510\u2557\u00FE\u00C3\u00CE.txt"); - auto path2 = std::filesystem::u8path( - u8"\u2551\u00BB\u00C1\u2510\u2557\u00FE\u00C3\u00CE.txt"); - - EXPECT_THROW(std::ignore = std::filesystem::equivalent(path1, path2), - std::system_error); -} -#else -TEST(Filesystem, equivalentShouldBeCaseSensitive) { - auto upper = std::filesystem::path("./testing-plugins/LICENSE"); - auto lower = std::filesystem::path("./testing-plugins/license"); - - std::ofstream out(lower); - out.close(); - - ASSERT_TRUE(std::filesystem::exists(upper)); - ASSERT_TRUE(std::filesystem::exists(lower)); - - EXPECT_FALSE(std::filesystem::equivalent(lower, upper)); -} -#endif - -int main(int argc, char **argv) { - ::testing::InitGoogleTest(&argc, argv); - return RUN_ALL_TESTS(); -} diff --git a/src/tests/api/internals/metadata/condition_evaluator_test.h b/src/tests/api/internals/metadata/condition_evaluator_test.h deleted file mode 100644 index 93bed1e7..00000000 --- a/src/tests/api/internals/metadata/condition_evaluator_test.h +++ /dev/null @@ -1,260 +0,0 @@ -/* LOOT - -A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and -Fallout: New Vegas. - -Copyright (C) 2014-2016 WrinklyNinja - -This file is part of LOOT. - -LOOT is free software: you can redistribute -it and/or modify it under the terms of the GNU General Public License -as published by the Free Software Foundation, either version 3 of -the License, or (at your option) any later version. - -LOOT is distributed in the hope that it will -be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with LOOT. If not, see -. -*/ - -#ifndef LOOT_TESTS_API_INTERNALS_METADATA_CONDITION_EVALUATOR_TEST -#define LOOT_TESTS_API_INTERNALS_METADATA_CONDITION_EVALUATOR_TEST - -#include - -#include "api/metadata/condition_evaluator.h" -#include "tests/common_game_test_fixture.h" - -namespace loot { -namespace test { -class ConditionEvaluatorTest : public CommonGameTestFixture, - public testing::WithParamInterface { -protected: - ConditionEvaluatorTest() : - CommonGameTestFixture(GetParam()), - info_(std::vector({ - MessageContent("info"), - })), - nonAsciiEsm(u8"non\u00C1scii.esm"), - nonAsciiNestedFile(u8"non\u00C1scii/test.txt"), - game_(GetParam(), gamePath, localPath), - evaluator_(game_.GetType(), game_.DataPath()) { - // Make sure the plugin with a non-ASCII filename exists. - std::filesystem::copy_file(dataPath / blankEsm, - dataPath / std::filesystem::u8path(nonAsciiEsm)); - - touch(dataPath / std::filesystem::u8path(nonAsciiNestedFile)); - - loadInstalledPlugins(); - evaluator_.RefreshLoadedPluginsState(game_.GetLoadedPlugins()); - evaluator_.RefreshActivePluginsState( - game_.GetLoadOrderHandler().GetActivePlugins()); - } - - std::string IntToHexString(const uint32_t value) { - std::stringstream stream; - stream << std::hex << value; - return stream.str(); - } - - void loadInstalledPlugins() { - auto plugins = GetInstalledPlugins(); - - plugins.push_back(std::filesystem::u8path(nonAsciiEsm)); - - game_.LoadCurrentLoadOrderState(); - game_.LoadPlugins(plugins, true); - } - - const std::vector info_; - const std::string nonAsciiEsm; - const std::string nonAsciiNestedFile; - - Game game_; - ConditionEvaluator evaluator_; -}; - -// Pass an empty first argument, as it's a prefix for the test instantation, -// but we only have the one so no prefix is necessary. -INSTANTIATE_TEST_SUITE_P(, - ConditionEvaluatorTest, - ::testing::ValuesIn(ALL_GAME_TYPES)); - -TEST_P(ConditionEvaluatorTest, - evaluateShouldReturnTrueForAnEmptyConditionString) { - EXPECT_TRUE(evaluator_.Evaluate("")); -} - -TEST_P(ConditionEvaluatorTest, evaluateShouldThrowForAnInvalidConditionString) { - EXPECT_THROW(evaluator_.Evaluate("condition"), std::runtime_error); -} - -TEST_P(ConditionEvaluatorTest, - evaluateShouldReturnTrueForAConditionThatIsTrue) { - EXPECT_TRUE(evaluator_.Evaluate("file(\"" + blankEsm + "\")")); -} - -TEST_P(ConditionEvaluatorTest, evaluateShouldUseAllGivenDataPaths) { - ASSERT_FALSE( - evaluator_.Evaluate("file(\"" + localPath.filename().u8string() + "\")")); - - evaluator_.ClearConditionCache(); - evaluator_.SetAdditionalDataPaths({localPath.parent_path()}); - - EXPECT_TRUE( - evaluator_.Evaluate("file(\"" + localPath.filename().u8string() + "\")")); -} - -TEST_P(ConditionEvaluatorTest, - evaluateFileConditionShouldReturnTrueForANonAsciiFileThatExists) { - EXPECT_TRUE(evaluator_.Evaluate("file(\"" + nonAsciiEsm + "\")")); -} - -TEST_P(ConditionEvaluatorTest, - evaluateChecksumConditionShouldBeAbleToGetTheCrcOfANonAsciiFile) { - std::string condition("checksum(\"" + nonAsciiEsm + "\", " + - IntToHexString(blankEsmCrc) + ")"); - EXPECT_TRUE(evaluator_.Evaluate(condition)); -} - -TEST_P(ConditionEvaluatorTest, - evaluateVersionConditionShouldBeAbleToGetTheVersionOfANonAsciiFile) { - std::string condition("version(\"" + nonAsciiEsm + "\", \"5.0\", ==)"); - EXPECT_TRUE(evaluator_.Evaluate(condition)); -} - -TEST_P(ConditionEvaluatorTest, - evaluateActiveConditionShouldReturnTrueForAnActivePlugin) { - std::string condition("active(\"" + blankEsm + "\")"); - EXPECT_TRUE(evaluator_.Evaluate(condition)); -} - -TEST_P(ConditionEvaluatorTest, - evaluateRegexFileConditionShouldReturnTrueForANonAsciiFileThatExists) { - std::string condition(u8"file(\"non\u00C1scii.*\\.esm\")"); - EXPECT_TRUE(evaluator_.Evaluate(condition)); -} - -TEST_P( - ConditionEvaluatorTest, - evaluateRegexFileConditionShouldReturnTrueForANonAsciiNestedFileThatExists) { - std::string condition(u8"file(\"non\u00C1scii/.+\\.txt\")"); - EXPECT_TRUE(evaluator_.Evaluate(condition)); -} - -TEST_P(ConditionEvaluatorTest, - evaluateShouldReturnFalseForAConditionThatIsFalse) { - EXPECT_FALSE(evaluator_.Evaluate("file(\"" + missingEsp + "\")")); -} - -TEST_P(ConditionEvaluatorTest, evaluateAllShouldEvaluateAllMetadataConditions) { - PluginMetadata plugin(nonAsciiEsm); - plugin.SetGroup("group1"); - - File file1(blankEsp); - File file2(blankDifferentEsm, "", "file(\"" + missingEsp + "\")"); - plugin.SetLoadAfterFiles({file1, file2}); - plugin.SetRequirements({file1, file2}); - plugin.SetIncompatibilities({file1, file2}); - - Message message1(MessageType::say, "content"); - Message message2(MessageType::say, "content", "file(\"" + missingEsp + "\")"); - plugin.SetMessages({message1, message2}); - - Tag tag1("Relev"); - Tag tag2("Relev", true, "file(\"" + missingEsp + "\")"); - plugin.SetTags({tag1, tag2}); - - PluginCleaningData info1(blankEsmCrc, "utility", info_, 1, 2, 3); - PluginCleaningData info2(0xDEADBEEF, "utility", info_, 1, 2, 3); - plugin.SetDirtyInfo({info1, info2}); - plugin.SetCleanInfo({info1, info2}); - - EXPECT_NO_THROW(plugin = evaluator_.EvaluateAll(plugin).value()); - - std::vector expectedFiles({file1}); - EXPECT_EQ("group1", plugin.GetGroup().value()); - EXPECT_EQ(expectedFiles, plugin.GetLoadAfterFiles()); - EXPECT_EQ(expectedFiles, plugin.GetRequirements()); - EXPECT_EQ(expectedFiles, plugin.GetIncompatibilities()); - EXPECT_EQ(std::vector({message1}), plugin.GetMessages()); - EXPECT_EQ(std::vector({tag1}), plugin.GetTags()); - EXPECT_EQ(std::vector({info1}), plugin.GetDirtyInfo()); - EXPECT_EQ(std::vector({info1}), plugin.GetCleanInfo()); -} - -TEST_P(ConditionEvaluatorTest, evaluateAllShouldPreserveGroupExplicitness) { - PluginMetadata plugin(blankEsm); - - EXPECT_FALSE(evaluator_.EvaluateAll(plugin).has_value()); -} - -TEST_P(ConditionEvaluatorTest, - refreshActivePluginsStateShouldClearTheConditionCache) { - std::string condition("active(\"" + blankEsm + "\")"); - ASSERT_TRUE(evaluator_.Evaluate(condition)); - - evaluator_.RefreshActivePluginsState({blankEsp}); - - EXPECT_FALSE(evaluator_.Evaluate(condition)); -} - -TEST_P( - ConditionEvaluatorTest, - refreshActivePluginsStateShouldClearTheActivePluginsCacheIfGivenAnEmptyVector) { - std::string condition("active(\"" + blankEsm + "\")"); - ASSERT_TRUE(evaluator_.Evaluate(condition)); - - evaluator_.RefreshActivePluginsState({}); - - EXPECT_FALSE(evaluator_.Evaluate(condition)); -} - -TEST_P(ConditionEvaluatorTest, - refreshLoadedPluginsStateShouldClearTheConditionCache) { - std::string condition("version(\"" + blankEsm + "\", \"5.0\", ==)"); - - ASSERT_TRUE(evaluator_.Evaluate(condition)); - - auto plugins = game_.GetLoadedPlugins(); - auto pluginsIt = - std::find_if(plugins.cbegin(), plugins.cend(), [&](auto plugin) { - return plugin->GetName() == blankEsm; - }); - plugins.erase(pluginsIt); - evaluator_.RefreshLoadedPluginsState(plugins); - - EXPECT_FALSE(evaluator_.Evaluate(condition)); -} - -TEST_P( - ConditionEvaluatorTest, - refreshLoadedPluginsStateShouldClearTheVersionsCacheIfGivenAnEmptyVector) { - std::string condition("version(\"" + blankEsm + "\", \"5.0\", ==)"); - - ASSERT_TRUE(evaluator_.Evaluate(condition)); - - evaluator_.RefreshLoadedPluginsState({}); - - EXPECT_FALSE(evaluator_.Evaluate(condition)); -} - -TEST_P(ConditionEvaluatorTest, - setAdditionalDataPathsShouldAcceptAnEmptyVector) { - EXPECT_NO_THROW(evaluator_.SetAdditionalDataPaths({})); -} - -TEST_P(ConditionEvaluatorTest, - setAdditionalDataPathsShouldAcceptANonEmptyVector) { - EXPECT_NO_THROW(evaluator_.SetAdditionalDataPaths( - {std::filesystem::u8path("a"), std::filesystem::u8path("b")})); -} -} -} - -#endif diff --git a/src/tests/api/internals/metadata/yaml/file_test.h b/src/tests/api/internals/metadata/yaml/file_test.h deleted file mode 100644 index d00986f6..00000000 --- a/src/tests/api/internals/metadata/yaml/file_test.h +++ /dev/null @@ -1,201 +0,0 @@ -/* LOOT - -A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and -Fallout: New Vegas. - -Copyright (C) 2014-2016 WrinklyNinja - -This file is part of LOOT. - -LOOT is free software: you can redistribute -it and/or modify it under the terms of the GNU General Public License -as published by the Free Software Foundation, either version 3 of -the License, or (at your option) any later version. - -LOOT is distributed in the hope that it will -be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with LOOT. If not, see -. -*/ - -#ifndef LOOT_TESTS_API_INTERNALS_METADATA_YAML_FILE_TEST -#define LOOT_TESTS_API_INTERNALS_METADATA_YAML_FILE_TEST - -#include - -#include "api/metadata/yaml/file.h" - -namespace loot::test { -TEST(File, emittingAsYamlShouldSingleQuoteValues) { - File file("name1", - "display1", - "condition1", - {MessageContent("english", "en")}, - "constraint1"); - YAML::Emitter emitter; - emitter << file; - std::string expected = "name: '" + std::string(file.GetName()) + - "'\ncondition: '" + file.GetCondition() + - "'\ndisplay: '" + file.GetDisplayName() + - "'\nconstraint: '" + file.GetConstraint() + - "'\ndetail: '" + file.GetDetail()[0].GetText() + "'"; - - EXPECT_EQ(expected, emitter.c_str()); -} - -TEST(File, emittingAsYamlShouldOutputAsAScalarIfOnlyTheNameStringIsNotEmpty) { - File file("file.esp"); - YAML::Emitter emitter; - emitter << file; - - EXPECT_EQ("'" + std::string(file.GetName()) + "'", emitter.c_str()); -} - -TEST(File, emittingAsYamlShouldOmitEmptyConditionAndConstraintStrings) { - File file("name1", "display1"); - YAML::Emitter emitter; - emitter << file; - std::string expected = "name: '" + std::string(file.GetName()) + - "'\ndisplay: '" + file.GetDisplayName() + "'"; - - EXPECT_EQ(expected, emitter.c_str()); -} - -TEST( - File, - emittingAsYamlShouldWriteDetailAsAListIfTheVectorContainsMoreThanOneElement) { - File file("", - "", - "", - {MessageContent("english", "en"), MessageContent("french", "fr")}); - YAML::Emitter emitter; - emitter << file; - std::string expected = - "name: ''\n" - "detail:\n" - " - lang: en\n" - " text: 'english'\n" - " - lang: fr\n" - " text: 'french'"; - - EXPECT_EQ(expected, emitter.c_str()); -} - -TEST(File, encodingAsYamlShouldStoreDataCorrectly) { - auto detail = {MessageContent("english", "en"), - MessageContent("french", "fr")}; - File file("name1", "display1", "condition1", detail, "constraint1"); - YAML::Node node; - node = file; - - EXPECT_EQ(std::string(file.GetName()), node["name"].as()); - EXPECT_EQ(file.GetDisplayName(), node["display"].as()); - EXPECT_EQ(file.GetCondition(), node["condition"].as()); - EXPECT_EQ(file.GetDetail(), node["detail"].as>()); - EXPECT_EQ(file.GetConstraint(), node["constraint"].as()); -} - -TEST(File, encodingAsYamlShouldOmitEmptyFields) { - File file("file.esp"); - YAML::Node node; - node = file; - - EXPECT_EQ(std::string(file.GetName()), node["name"].as()); - EXPECT_FALSE(node["display"]); - EXPECT_FALSE(node["condition"]); - EXPECT_FALSE(node["detail"]); -} - -TEST(File, decodingFromYamlShouldSetDataCorrectly) { - YAML::Node node = YAML::Load( - "{name: name1, display: display1, condition: 'file(\"Foo.esp\")', " - "detail: 'details', constraint: 'file(\"Bar.esp\")'}"); - File file = node.as(); - - std::vector expectedDetail = { - MessageContent("details", "en")}; - - EXPECT_EQ(node["name"].as(), std::string(file.GetName())); - EXPECT_EQ(node["display"].as(), file.GetDisplayName()); - EXPECT_EQ(node["condition"].as(), file.GetCondition()); - EXPECT_EQ(expectedDetail, file.GetDetail()); - EXPECT_EQ(node["constraint"].as(), file.GetConstraint()); -} - -TEST(File, - decodingFromYamlWithMissingConditionFieldShouldLeaveConditionStringEmpty) { - YAML::Node node = YAML::Load("{name: name1, display: display1}"); - File file = node.as(); - - EXPECT_EQ(node["name"].as(), std::string(file.GetName())); - EXPECT_EQ(node["display"].as(), file.GetDisplayName()); - EXPECT_TRUE(file.GetCondition().empty()); - EXPECT_TRUE(file.GetDetail().empty()); - EXPECT_TRUE(file.GetConstraint().empty()); -} - -TEST(File, decodingFromYamlWithAListOfMessageContentDetailsShouldReadThemAll) { - YAML::Node node = YAML::Load( - "{name: name1, display: display1, condition: 'file(\"Foo.esp\")', " - "detail: [{text: english, lang: en}, {text: french, lang: fr}]}"); - File file = node.as(); - - std::vector expectedDetail = {MessageContent("english", "en"), - MessageContent("french", "fr")}; - - EXPECT_EQ(expectedDetail, file.GetDetail()); -} - -TEST(File, decodingFromYamlShouldNotThrowIfTheOnlyDetailStringIsNotEnglish) { - YAML::Node node = YAML::Load( - "name: name1\n" - "detail:\n" - " - lang: fr\n" - " text: content1"); - - EXPECT_NO_THROW(node.as()); -} - -TEST( - File, - decodingFromYamlShouldThrowIfMultipleContentStringsAreGivenAndNoneAreEnglish) { - YAML::Node node = YAML::Load( - "name: name1\n" - "detail:\n" - " - lang: de\n" - " text: content1\n" - " - lang: fr\n" - " text: content2"); - - EXPECT_THROW(node.as(), YAML::RepresentationException); -} - -TEST(File, decodingFromYamlScalarShouldLeaveDisplayNameAndConditionEmpty) { - YAML::Node node = YAML::Load("name1"); - File file = node.as(); - - EXPECT_EQ(node.as(), std::string(file.GetName())); - EXPECT_TRUE(file.GetDisplayName().empty()); - EXPECT_TRUE(file.GetCondition().empty()); - EXPECT_TRUE(file.GetDetail().empty()); - EXPECT_TRUE(file.GetConstraint().empty()); -} - -TEST(File, decodingFromYamlShouldThrowIfAnInvalidMapIsGiven) { - YAML::Node node = YAML::Load("{name: name1, condition: invalid}"); - - EXPECT_THROW(node.as(), YAML::RepresentationException); -} - -TEST(File, decodingFromYamlShouldThrowIfAListIsGiven) { - YAML::Node node = YAML::Load("[0, 1, 2]"); - - EXPECT_THROW(node.as(), YAML::RepresentationException); -} -} - -#endif diff --git a/src/tests/api/internals/metadata/yaml/group_test.h b/src/tests/api/internals/metadata/yaml/group_test.h deleted file mode 100644 index 90e44358..00000000 --- a/src/tests/api/internals/metadata/yaml/group_test.h +++ /dev/null @@ -1,142 +0,0 @@ -/* LOOT - -A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and -Fallout: New Vegas. - -Copyright (C) 2014-2016 WrinklyNinja - -This file is part of LOOT. - -LOOT is free software: you can redistribute -it and/or modify it under the terms of the GNU General Public License -as published by the Free Software Foundation, either version 3 of -the License, or (at your option) any later version. - -LOOT is distributed in the hope that it will -be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with LOOT. If not, see -. -*/ - -#ifndef LOOT_TESTS_API_INTERNALS_METADATA_YAML_GROUP_TEST -#define LOOT_TESTS_API_INTERNALS_METADATA_YAML_GROUP_TEST - -#include - -#include "api/metadata/yaml/group.h" - -namespace loot::test { -TEST(Group, emittingAsYamlShouldOmitAfterKeyIfAfterGroupsIsEmpty) { - Group group; - - YAML::Emitter emitter; - emitter << group; - - EXPECT_STREQ("name: 'default'", emitter.c_str()); -} - -TEST(Group, emittingAsYamlShouldIncludeDescriptionKeyIfDescriptionIsNotEmpty) { - Group group("group1", {}, "test"); - - YAML::Emitter emitter; - emitter << group; - - EXPECT_STREQ( - "name: 'group1'\n" - "description: 'test'", - emitter.c_str()); -} - -TEST(Group, emittingAsYamlShouldIncludeAfterKeyIfAfterGroupsIsNotEmpty) { - Group group("group1", {"other_group"}); - - YAML::Emitter emitter; - emitter << group; - - EXPECT_STREQ( - "name: 'group1'\n" - "after:\n" - " - other_group", - emitter.c_str()); -} - -TEST(Group, encodingAsYamlShouldOmitDescriptionKeyIfDescriptionIsEmpty) { - Group group; - YAML::Node node; - node = group; - - EXPECT_EQ("default", node["name"].as()); - EXPECT_FALSE(node["description"]); -} - -TEST(Group, encodingAsYamlShouldIncludeDescriptionKeyIfDescriptionIsNotEmpty) { - Group group("group1", {}, "test"); - YAML::Node node; - node = group; - - EXPECT_EQ("group1", node["name"].as()); - EXPECT_EQ("test", node["description"].as()); -} - -TEST(Group, encodingAsYamlShouldOmitAfterKeyIfAfterGroupsIsEmpty) { - Group group; - YAML::Node node; - node = group; - - EXPECT_EQ("default", node["name"].as()); - EXPECT_FALSE(node["after"]); -} - -TEST(Group, encodingAsYamlShouldIncludeAfterKeyIfAfterGroupsIsNotEmpty) { - Group group("group1", {"other_group"}); - YAML::Node node; - node = group; - - std::vector expectedAfterGroups = {"other_group"}; - EXPECT_EQ("group1", node["name"].as()); - EXPECT_EQ(expectedAfterGroups, node["after"].as>()); -} - -TEST(Group, decodingFromYamlShouldSetGivenName) { - YAML::Node node = YAML::Load("{name: group1}"); - Group group = node.as(); - - EXPECT_EQ("group1", group.GetName()); - EXPECT_TRUE(group.GetAfterGroups().empty()); -} - -TEST(Group, decodingFromYamlShouldSetDescriptionIfOneIsGiven) { - YAML::Node node = YAML::Load("{name: group1, description: test}"); - Group group = node.as(); - - EXPECT_EQ("group1", group.GetName()); - EXPECT_EQ("test", group.GetDescription()); -} - -TEST(Group, decodingFromYamlShouldSetAfterGroupsIfAnyAreGiven) { - YAML::Node node = YAML::Load("{name: group1, after: [ other_group ]}"); - Group group = node.as(); - - std::vector expectedAfterGroups = {"other_group"}; - EXPECT_EQ("group1", group.GetName()); - EXPECT_EQ(expectedAfterGroups, group.GetAfterGroups()); -} - -TEST(Group, decodingFromYamlShouldThrowIfTheNameKeyIsMissing) { - YAML::Node node = YAML::Load("{after: []}"); - - EXPECT_THROW(node.as(), YAML::RepresentationException); -} - -TEST(Group, decodingFromYamlShouldThrowIfAListIsGiven) { - YAML::Node node = YAML::Load("[0, 1, 2]"); - - EXPECT_THROW(node.as(), YAML::RepresentationException); -} -} - -#endif diff --git a/src/tests/api/internals/metadata/yaml/location_test.h b/src/tests/api/internals/metadata/yaml/location_test.h deleted file mode 100644 index 5142e99f..00000000 --- a/src/tests/api/internals/metadata/yaml/location_test.h +++ /dev/null @@ -1,93 +0,0 @@ -/* LOOT - -A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and -Fallout: New Vegas. - -Copyright (C) 2014-2016 WrinklyNinja - -This file is part of LOOT. - -LOOT is free software: you can redistribute -it and/or modify it under the terms of the GNU General Public License -as published by the Free Software Foundation, either version 3 of -the License, or (at your option) any later version. - -LOOT is distributed in the hope that it will -be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with LOOT. If not, see -. -*/ - -#ifndef LOOT_TESTS_API_INTERNALS_METADATA_YAML_LOCATION_TEST -#define LOOT_TESTS_API_INTERNALS_METADATA_YAML_LOCATION_TEST - -#include - -#include "api/metadata/yaml/location.h" - -namespace loot::test { -TEST(Location, emittingAsYamlShouldOutputAScalarIfTheNameStringIsEmpty) { - Location location("http://www.example.com"); - YAML::Emitter emitter; - emitter << location; - - EXPECT_EQ("'" + location.GetURL() + "'", emitter.c_str()); -} - -TEST(Location, emittingAsYamlShouldOutputAMapIfTheNameStringIsNotEmpty) { - Location location("http://www.example.com", "example"); - YAML::Emitter emitter; - emitter << location; - - EXPECT_EQ( - "link: '" + location.GetURL() + "'\nname: '" + location.GetName() + "'", - emitter.c_str()); -} - -TEST(Location, encodingAsYamlShouldStoreDataCorrectly) { - Location location("http://www.example.com", "example"); - YAML::Node node; - node = location; - - EXPECT_EQ(location.GetURL(), node["link"].as()); - EXPECT_EQ(location.GetName(), node["name"].as()); -} - -TEST(Location, encodingAsYamlShouldOmitEmptyFields) { - Location location("http://www.example.com"); - YAML::Node node; - node = location; - - EXPECT_EQ(location.GetURL(), node["link"].as()); - EXPECT_FALSE(node["name"]); -} - -TEST(Location, decodingFromYamlShouldSetDataCorrectly) { - YAML::Node node = YAML::Load("{link: http://www.example.com, name: example}"); - Location location = node.as(); - - EXPECT_EQ(node["link"].as(), location.GetURL()); - EXPECT_EQ(node["name"].as(), location.GetName()); -} - -TEST(Location, - decodingFromYamlScalarShouldSetUrlToScalarValueAndLeaveNameEmpty) { - YAML::Node node = YAML::Load("http://www.example.com"); - Location location = node.as(); - - EXPECT_EQ(node.as(), location.GetURL()); - EXPECT_TRUE(location.GetName().empty()); -} - -TEST(Location, decodingFromYamlShouldThrowIfAListIsGiven) { - YAML::Node node = YAML::Load("[0, 1, 2]"); - - EXPECT_THROW(node.as(), YAML::RepresentationException); -} -} - -#endif diff --git a/src/tests/api/internals/metadata/yaml/message_content_test.h b/src/tests/api/internals/metadata/yaml/message_content_test.h deleted file mode 100644 index bf154267..00000000 --- a/src/tests/api/internals/metadata/yaml/message_content_test.h +++ /dev/null @@ -1,74 +0,0 @@ -/* LOOT - -A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and -Fallout: New Vegas. - -Copyright (C) 2014-2016 WrinklyNinja - -This file is part of LOOT. - -LOOT is free software: you can redistribute -it and/or modify it under the terms of the GNU General Public License -as published by the Free Software Foundation, either version 3 of -the License, or (at your option) any later version. - -LOOT is distributed in the hope that it will -be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with LOOT. If not, see -. -*/ - -#ifndef LOOT_TESTS_API_INTERNALS_METADATA_YAML_MESSAGE_CONTENT_TEST -#define LOOT_TESTS_API_INTERNALS_METADATA_YAML_MESSAGE_CONTENT_TEST - -#include - -#include "api/metadata/yaml/message_content.h" - -namespace loot::test { -const std::string french = "fr"; - -TEST(MessageContent, emittingAsYamlShouldOutputDataCorrectly) { - MessageContent content("content", french); - YAML::Emitter emitter; - emitter << content; - - EXPECT_EQ("lang: " + french + "\ntext: '" + content.GetText() + "'", - emitter.c_str()); -} - -TEST(MessageContent, encodingAsYamlShouldOutputDataCorrectly) { - MessageContent content("content", french); - YAML::Node node; - node = content; - - EXPECT_EQ(content.GetText(), node["text"].as()); - EXPECT_EQ(french, node["lang"].as()); -} - -TEST(MessageContent, decodingFromYamlShouldSetDataCorrectly) { - YAML::Node node = YAML::Load("{text: content, lang: fr}"); - MessageContent content = node.as(); - - EXPECT_EQ("content", content.GetText()); - EXPECT_EQ(french, content.GetLanguage()); -} - -TEST(MessageContent, decodingFromYamlScalarShouldThrow) { - YAML::Node node = YAML::Load("scalar"); - - EXPECT_THROW(node.as(), YAML::RepresentationException); -} - -TEST(MessageContent, decodingFromYamlListShouldThrow) { - YAML::Node node = YAML::Load("[0, 1, 2]"); - - EXPECT_THROW(node.as(), YAML::RepresentationException); -} -} - -#endif diff --git a/src/tests/api/internals/metadata/yaml/message_test.h b/src/tests/api/internals/metadata/yaml/message_test.h deleted file mode 100644 index 3afc4997..00000000 --- a/src/tests/api/internals/metadata/yaml/message_test.h +++ /dev/null @@ -1,349 +0,0 @@ -/* LOOT - -A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and -Fallout: New Vegas. - -Copyright (C) 2014-2016 WrinklyNinja - -This file is part of LOOT. - -LOOT is free software: you can redistribute -it and/or modify it under the terms of the GNU General Public License -as published by the Free Software Foundation, either version 3 of -the License, or (at your option) any later version. - -LOOT is distributed in the hope that it will -be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with LOOT. If not, see -. -*/ - -#ifndef LOOT_TESTS_API_INTERNALS_METADATA_YAML_MESSAGE_TEST -#define LOOT_TESTS_API_INTERNALS_METADATA_YAML_MESSAGE_TEST - -#include "api/metadata/yaml/message.h" -#include "tests/common_game_test_fixture.h" - -namespace loot::test { -class MessageTest : public CommonGameTestFixture { -protected: - MessageTest() : CommonGameTestFixture(GameType::tes4) {} - typedef std::vector MessageContents; -}; - -TEST_F(MessageTest, emittingAsYamlShouldOutputNoteMessageTypeCorrectly) { - Message message(MessageType::say, "content1"); - YAML::Emitter emitter; - emitter << message; - - EXPECT_STREQ( - "type: say\n" - "content: 'content1'", - emitter.c_str()); -} - -TEST_F(MessageTest, emittingAsYamlShouldOutputWarnMessageTypeCorrectly) { - Message message(MessageType::warn, "content1"); - YAML::Emitter emitter; - emitter << message; - - EXPECT_STREQ( - "type: warn\n" - "content: 'content1'", - emitter.c_str()); -} - -TEST_F(MessageTest, emittingAsYamlShouldOutputErrorMessageTypeCorrectly) { - Message message(MessageType::error, "content1"); - YAML::Emitter emitter; - emitter << message; - - EXPECT_STREQ( - "type: error\n" - "content: 'content1'", - emitter.c_str()); -} - -TEST_F(MessageTest, emittingAsYamlShouldOutputConditionIfItIsNotEmpty) { - Message message(MessageType::say, "content1", "condition1"); - YAML::Emitter emitter; - emitter << message; - - EXPECT_STREQ( - "type: say\n" - "content: 'content1'\n" - "condition: 'condition1'", - emitter.c_str()); -} - -TEST_F(MessageTest, emittingAsYamlShouldOutputMultipleContentStringsAsAList) { - Message message(MessageType::say, - MessageContents({MessageContent("content1"), - MessageContent("content2", french)})); - YAML::Emitter emitter; - emitter << message; - - EXPECT_STREQ( - "type: say\n" - "content:\n" - " - lang: en\n" - " text: 'content1'\n" - " - lang: fr\n" - " text: 'content2'", - emitter.c_str()); -} - -TEST_F(MessageTest, encodingAsYamlShouldStoreNoteMessageTypeCorrectly) { - Message message(MessageType::say, "content1"); - YAML::Node node; - node = message; - - EXPECT_EQ("say", node["type"].as()); -} - -TEST_F(MessageTest, encodingAsYamlShouldStoreWarningMessageTypeCorrectly) { - Message message(MessageType::warn, "content1"); - YAML::Node node; - node = message; - - EXPECT_EQ("warn", node["type"].as()); -} - -TEST_F(MessageTest, encodingAsYamlShouldStoreErrorMessageTypeCorrectly) { - Message message(MessageType::error, "content1"); - YAML::Node node; - node = message; - - EXPECT_EQ("error", node["type"].as()); -} - -TEST_F(MessageTest, encodingAsYamlShouldOmitConditionFieldIfItIsEmpty) { - Message message(MessageType::say, "content1"); - YAML::Node node; - node = message; - - EXPECT_FALSE(node["condition"]); -} - -TEST_F(MessageTest, encodingAsYamlShouldStoreConditionFieldIfItIsNotEmpty) { - Message message(MessageType::say, "content1", "condition1"); - YAML::Node node; - node = message; - - EXPECT_EQ("condition1", node["condition"].as()); -} - -TEST_F(MessageTest, encodingAsYamlShouldStoreASingleContentStringInAVector) { - Message message(MessageType::say, "content1"); - YAML::Node node; - node = message; - - EXPECT_EQ(message.GetContent(), node["content"].as()); -} - -TEST_F(MessageTest, encodingAsYamlShouldMultipleContentStringsInAVector) { - MessageContents contents({ - MessageContent("content1"), - MessageContent("content2", french), - }); - Message message(MessageType::say, contents); - YAML::Node node; - node = message; - - EXPECT_EQ(contents, node["content"].as()); -} - -TEST_F(MessageTest, decodingFromYamlShouldSetNoteTypeCorrectly) { - YAML::Node node = YAML::Load( - "type: say\n" - "content: content1"); - Message message = node.as(); - - EXPECT_EQ(MessageType::say, message.GetType()); -} - -TEST_F(MessageTest, decodingFromYamlShouldSetWarningTypeCorrectly) { - YAML::Node node = YAML::Load( - "type: warn\n" - "content: content1"); - Message message = node.as(); - - EXPECT_EQ(MessageType::warn, message.GetType()); -} - -TEST_F(MessageTest, decodingFromYamlShouldSetErrorTypeCorrectly) { - YAML::Node node = YAML::Load( - "type: error\n" - "content: content1"); - Message message = node.as(); - - EXPECT_EQ(MessageType::error, message.GetType()); -} - -TEST_F(MessageTest, decodingFromYamlShouldHandleAnUnrecognisedTypeAsANote) { - YAML::Node node = YAML::Load( - "type: invalid\n" - "content: content1"); - Message message = node.as(); - - EXPECT_EQ(MessageType::say, message.GetType()); -} - -TEST_F(MessageTest, - decodingFromYamlShouldLeaveTheConditionEmptyIfNoneIsPresent) { - YAML::Node node = YAML::Load( - "type: say\n" - "content: content1"); - Message message = node.as(); - - EXPECT_TRUE(message.GetCondition().empty()); -} - -TEST_F(MessageTest, decodingFromYamlShouldStoreANonEmptyConditionField) { - YAML::Node node = YAML::Load( - "type: say\n" - "content: content1\n" - "condition: 'file(\"Foo.esp\")'"); - Message message = node.as(); - - EXPECT_EQ("file(\"Foo.esp\")", message.GetCondition()); -} - -TEST_F(MessageTest, decodingFromYamlShouldStoreAScalarContentValueCorrectly) { - YAML::Node node = YAML::Load( - "type: say\n" - "content: content1\n"); - Message message = node.as(); - MessageContents expectedContent({MessageContent("content1")}); - - EXPECT_EQ(expectedContent, message.GetContent()); -} - -TEST_F(MessageTest, decodingFromYamlShouldStoreAListOfContentStringsCorrectly) { - YAML::Node node = YAML::Load( - "type: say\n" - "content:\n" - " - lang: en\n" - " text: content1\n" - " - lang: fr\n" - " text: content2"); - Message message = node.as(); - - EXPECT_EQ(MessageContents({ - MessageContent("content1"), - MessageContent("content2", french), - }), - message.GetContent()); -} - -TEST_F(MessageTest, - decodingFromYamlShouldNotThrowIfTheOnlyContentStringIsNotEnglish) { - YAML::Node node = YAML::Load( - "type: say\n" - "content:\n" - " - lang: fr\n" - " text: content1"); - - EXPECT_NO_THROW(Message message = node.as()); -} - -TEST_F( - MessageTest, - decodingFromYamlShouldThrowIfMultipleContentStringsAreGivenAndNoneAreEnglish) { - YAML::Node node = YAML::Load( - "type: say\n" - "content:\n" - " - lang: de\n" - " text: content1\n" - " - lang: fr\n" - " text: content2"); - - EXPECT_THROW(node.as(), YAML::RepresentationException); -} - -TEST_F( - MessageTest, - decodingFromYamlShouldApplySubstitutionsWhenThereIsOnlyOneContentString) { - YAML::Node node = YAML::Load( - "type: say\n" - "content: con{0}tent1\n" - "subs:\n" - " - sub1"); - Message message = node.as(); - - EXPECT_EQ(MessageContents({MessageContent("consub1tent1")}), - message.GetContent()); -} - -TEST_F(MessageTest, - decodingFromYamlShouldApplySubstitutionsToAllContentStrings) { - YAML::Node node = YAML::Load( - "type: say\n" - "content:\n" - " - lang: en\n" - " text: content1 {0}\n" - " - lang: fr\n" - " text: content2 {0}\n" - "subs:\n" - " - sub"); - Message message = node.as(); - - EXPECT_EQ(MessageContents({ - MessageContent("content1 sub"), - MessageContent("content2 sub", french), - }), - message.GetContent()); -} - -TEST_F( - MessageTest, - decodingFromYamlShouldThrowIfTheContentStringExpectsMoreSubstitutionsThanExist) { - YAML::Node node = YAML::Load( - "type: say\n" - "content: '{0} {1}'\n" - "subs:\n" - " - sub1"); - - EXPECT_THROW(node.as(), YAML::RepresentationException); -} - -// Don't throw because no subs are given, so none are expected in the content -// string. -TEST_F(MessageTest, - decodingFromYamlShouldIgnoreSubstitutionSyntaxIfNoSubstitutionsExist) { - YAML::Node node = YAML::Load( - "type: say\n" - "content: con{0}tent1\n"); - Message message = node.as(); - - EXPECT_EQ(MessageContents({MessageContent("con{0}tent1")}), - message.GetContent()); -} - -TEST_F(MessageTest, decodingFromYamlShouldThrowIfAnInvalidConditionIsGiven) { - YAML::Node node = YAML::Load( - "type: say\n" - "content: content1\n" - "condition: invalid"); - - EXPECT_THROW(node.as(), YAML::RepresentationException); -} - -TEST_F(MessageTest, decodingFromYamlShouldThrowIfAScalarIsGiven) { - YAML::Node node = YAML::Load("scalar"); - - EXPECT_THROW(node.as(), YAML::RepresentationException); -} - -TEST_F(MessageTest, decodingFromYamlShouldThrowIfAListIsGiven) { - YAML::Node node = YAML::Load("[0, 1, 2]"); - - EXPECT_THROW(node.as(), YAML::RepresentationException); -} -} - -#endif diff --git a/src/tests/api/internals/metadata/yaml/plugin_cleaning_data_test.h b/src/tests/api/internals/metadata/yaml/plugin_cleaning_data_test.h deleted file mode 100644 index e10d9749..00000000 --- a/src/tests/api/internals/metadata/yaml/plugin_cleaning_data_test.h +++ /dev/null @@ -1,157 +0,0 @@ -/* LOOT - -A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and -Fallout: New Vegas. - -Copyright (C) 2014-2016 WrinklyNinja - -This file is part of LOOT. - -LOOT is free software: you can redistribute -it and/or modify it under the terms of the GNU General Public License -as published by the Free Software Foundation, either version 3 of -the License, or (at your option) any later version. - -LOOT is distributed in the hope that it will -be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with LOOT. If not, see -. -*/ - -#ifndef LOOT_TESTS_API_INTERNALS_METADATA_YAML_PLUGIN_CLEANING_DATA_TEST -#define LOOT_TESTS_API_INTERNALS_METADATA_YAML_PLUGIN_CLEANING_DATA_TEST - -#include "api/metadata/yaml/plugin_cleaning_data.h" -#include "tests/common_game_test_fixture.h" - -namespace loot::test { -class PluginCleaningDataTest : public CommonGameTestFixture { -protected: - PluginCleaningDataTest() : - CommonGameTestFixture(GameType::tes4), - info_(std::vector({ - MessageContent("info"), - })) {} - - const std::vector info_; -}; - -TEST_F(PluginCleaningDataTest, emittingAsYamlShouldOutputAllNonZeroCounts) { - PluginCleaningData info(0x12345678, "cleaner", info_, 2, 10, 30); - YAML::Emitter emitter; - emitter << info; - - EXPECT_STREQ( - "crc: 0x12345678\nutil: 'cleaner'\ndetail: 'info'\nitm: 2\nudr: 10\nnav: " - "30", - emitter.c_str()); -} - -TEST_F(PluginCleaningDataTest, emittingAsYamlShouldOmitAllZeroCounts) { - PluginCleaningData info(0x12345678, "cleaner", info_, 0, 0, 0); - YAML::Emitter emitter; - emitter << info; - - EXPECT_STREQ("crc: 0x12345678\nutil: 'cleaner'\ndetail: 'info'", - emitter.c_str()); -} - -TEST_F(PluginCleaningDataTest, encodingAsYamlShouldOmitAllZeroCountFields) { - PluginCleaningData info(0x12345678, "cleaner", info_, 0, 0, 0); - YAML::Node node; - node = info; - - EXPECT_EQ(0x12345678u, node["crc"].as()); - EXPECT_EQ("cleaner", node["util"].as()); - EXPECT_EQ(info_, node["detail"].as>()); - EXPECT_FALSE(node["itm"]); - EXPECT_FALSE(node["udr"]); - EXPECT_FALSE(node["nav"]); -} - -TEST_F(PluginCleaningDataTest, - encodingAsYamlShouldOutputAllNonZeroCountFields) { - PluginCleaningData info(0x12345678, "cleaner", info_, 2, 10, 30); - YAML::Node node; - node = info; - - EXPECT_EQ(0x12345678u, node["crc"].as()); - EXPECT_EQ("cleaner", node["util"].as()); - EXPECT_EQ(info_, node["detail"].as>()); - EXPECT_EQ(2u, node["itm"].as()); - EXPECT_EQ(10u, node["udr"].as()); - EXPECT_EQ(30u, node["nav"].as()); -} - -TEST_F(PluginCleaningDataTest, - decodingFromYamlShouldLeaveMissingFieldsWithZeroValues) { - YAML::Node node = YAML::Load("{crc: 0x12345678, util: cleaner}"); - PluginCleaningData info = node.as(); - - EXPECT_EQ(0x12345678u, info.GetCRC()); - EXPECT_TRUE(info.GetDetail().empty()); - EXPECT_EQ(0u, info.GetITMCount()); - EXPECT_EQ(0u, info.GetDeletedReferenceCount()); - EXPECT_EQ(0u, info.GetDeletedNavmeshCount()); - EXPECT_EQ("cleaner", info.GetCleaningUtility()); -} - -TEST_F(PluginCleaningDataTest, decodingFromYamlShouldStoreAllNonZeroCounts) { - YAML::Node node = YAML::Load( - "{crc: 0x12345678, util: cleaner, detail: info, itm: 2, udr: 10, nav: " - "30}"); - PluginCleaningData info = node.as(); - - EXPECT_EQ(0x12345678u, info.GetCRC()); - EXPECT_EQ(info_, info.GetDetail()); - EXPECT_EQ(2u, info.GetITMCount()); - EXPECT_EQ(10u, info.GetDeletedReferenceCount()); - EXPECT_EQ(30u, info.GetDeletedNavmeshCount()); - EXPECT_EQ("cleaner", info.GetCleaningUtility()); -} - -TEST_F(PluginCleaningDataTest, - decodingFromYamlShouldNotThrowIfTheOnlyDetailStringIsNotEnglish) { - YAML::Node node = YAML::Load( - "crc: 0x12345678\n" - "util: cleaner\n" - "detail:\n" - " - lang: fr\n" - " text: content1"); - - EXPECT_NO_THROW(node.as()); -} - -TEST_F( - PluginCleaningDataTest, - decodingFromYamlShouldThrowIfMultipleDetailStringsAreGivenAndNoneAreEnglish) { - YAML::Node node = YAML::Load( - "crc: 0x12345678\n" - "util: cleaner\n" - "detail:\n" - " - lang: de\n" - " text: content1\n" - " - lang: fr\n" - " text: content2"); - - EXPECT_THROW(node.as(), YAML::RepresentationException); -} - -TEST_F(PluginCleaningDataTest, decodingFromYamlScalarShouldThrow) { - YAML::Node node = YAML::Load("scalar"); - - EXPECT_THROW(node.as(), YAML::RepresentationException); -} - -TEST_F(PluginCleaningDataTest, decodingFromYamlListShouldThrow) { - YAML::Node node = YAML::Load("[0, 1, 2]"); - - EXPECT_THROW(node.as(), YAML::RepresentationException); -} -} - -#endif diff --git a/src/tests/api/internals/metadata/yaml/plugin_metadata_test.h b/src/tests/api/internals/metadata/yaml/plugin_metadata_test.h deleted file mode 100644 index 37facffa..00000000 --- a/src/tests/api/internals/metadata/yaml/plugin_metadata_test.h +++ /dev/null @@ -1,391 +0,0 @@ -/* LOOT - -A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and -Fallout: New Vegas. - -Copyright (C) 2014-2016 WrinklyNinja - -This file is part of LOOT. - -LOOT is free software: you can redistribute -it and/or modify it under the terms of the GNU General Public License -as published by the Free Software Foundation, either version 3 of -the License, or (at your option) any later version. - -LOOT is distributed in the hope that it will -be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with LOOT. If not, see -. -*/ - -#ifndef LOOT_TESTS_API_INTERNALS_METADATA_YAML_PLUGIN_METADATA_TEST -#define LOOT_TESTS_API_INTERNALS_METADATA_YAML_PLUGIN_METADATA_TEST - -#include "api/metadata/yaml/plugin_metadata.h" -#include "tests/common_game_test_fixture.h" - -namespace loot::test { -class PluginMetadataTest : public CommonGameTestFixture { -protected: - PluginMetadataTest() : - CommonGameTestFixture(GameType::tes5), - info_(std::vector({ - MessageContent("info"), - })) {} - - const std::vector info_; -}; - -TEST_F(PluginMetadataTest, - emittingAsYamlShouldOutputAPluginWithNoMetadataAsABlankString) { - PluginMetadata plugin(blankEsm); - YAML::Emitter emitter; - emitter << plugin; - - EXPECT_STREQ("", emitter.c_str()); -} - -TEST_F(PluginMetadataTest, - emittingAsYamlShouldOutputAPluginOmittingAnImplicitGroup) { - PluginMetadata plugin(blankEsm); - plugin.SetLoadAfterFiles({File(blankEsm)}); - - YAML::Emitter emitter; - emitter << plugin; - - EXPECT_STREQ( - "name: 'Blank.esm'\n" - "after: ['Blank.esm']", - emitter.c_str()); -} - -TEST_F(PluginMetadataTest, - emittingAsYamlShouldOutputAPluginWithAnExplicitGroup) { - PluginMetadata plugin(blankEsm); - plugin.SetGroup("group1"); - - YAML::Emitter emitter; - emitter << plugin; - - EXPECT_STREQ( - "name: 'Blank.esm'\n" - "group: 'group1'", - emitter.c_str()); -} - -TEST_F(PluginMetadataTest, - emittingAsYamlShouldOutputAPluginWithLoadAfterMetadataCorrectly) { - PluginMetadata plugin(blankEsp); - plugin.SetLoadAfterFiles({File(blankEsm)}); - - YAML::Emitter emitter; - emitter << plugin; - - EXPECT_STREQ( - "name: 'Blank.esp'\n" - "after: ['Blank.esm']", - emitter.c_str()); -} - -TEST_F(PluginMetadataTest, - emittingAsYamlShouldOutputAPluginWithRequirementsCorrectly) { - PluginMetadata plugin(blankEsp); - plugin.SetRequirements({File(blankEsm)}); - - YAML::Emitter emitter; - emitter << plugin; - - EXPECT_STREQ( - "name: 'Blank.esp'\n" - "req: ['Blank.esm']", - emitter.c_str()); -} - -TEST_F(PluginMetadataTest, - emittingAsYamlShouldOutputAPluginWithIncompatibilitiesCorrectly) { - PluginMetadata plugin(blankEsp); - plugin.SetIncompatibilities({File(blankEsm)}); - - YAML::Emitter emitter; - emitter << plugin; - - EXPECT_STREQ( - "name: 'Blank.esp'\n" - "inc: ['Blank.esm']", - emitter.c_str()); -} - -TEST_F(PluginMetadataTest, - emittingAsYamlShouldOutputAPluginWithMessagesCorrectly) { - PluginMetadata plugin(blankEsp); - plugin.SetMessages({Message(MessageType::say, "content")}); - - YAML::Emitter emitter; - emitter << plugin; - - EXPECT_STREQ( - "name: 'Blank.esp'\n" - "msg:\n" - " - type: say\n" - " content: 'content'", - emitter.c_str()); -} - -TEST_F(PluginMetadataTest, emittingAsYamlShouldOutputAPluginWithTagsCorrectly) { - PluginMetadata plugin(blankEsp); - plugin.SetTags({Tag("Relev")}); - - YAML::Emitter emitter; - emitter << plugin; - - EXPECT_STREQ( - "name: 'Blank.esp'\n" - "tag: [Relev]", - emitter.c_str()); -} - -TEST_F(PluginMetadataTest, - emittingAsYamlShouldOutputAPluginWithDirtyInfoCorrectly) { - PluginMetadata plugin(blankEsp); - plugin.SetDirtyInfo({PluginCleaningData(5, "utility", info_, 0, 1, 2)}); - - YAML::Emitter emitter; - emitter << plugin; - - EXPECT_STREQ( - "name: 'Blank.esp'\n" - "dirty:\n" - " - crc: 0x00000005\n" - " util: 'utility'\n" - " detail: 'info'\n" - " udr: 1\n" - " nav: 2", - emitter.c_str()); -} - -TEST_F(PluginMetadataTest, - emittingAsYamlShouldOutputAPluginWithCleanInfoCorrectly) { - PluginMetadata plugin(blankEsp); - plugin.SetCleanInfo({PluginCleaningData(5, "utility")}); - - YAML::Emitter emitter; - emitter << plugin; - - EXPECT_STREQ( - "name: 'Blank.esp'\n" - "clean:\n" - " - crc: 0x00000005\n" - " util: 'utility'", - emitter.c_str()); -} - -TEST_F(PluginMetadataTest, - emittingAsYamlShouldOutputAPluginWithLocationsCorrectly) { - PluginMetadata plugin(blankEsp); - plugin.SetLocations({Location("http://www.example.com")}); - - YAML::Emitter emitter; - emitter << plugin; - - EXPECT_STREQ( - "name: 'Blank.esp'\n" - "url: ['http://www.example.com']", - emitter.c_str()); -} - -TEST_F(PluginMetadataTest, encodingAsYamlShouldOmitAllUnsetFields) { - PluginMetadata plugin(blankEsp); - YAML::Node node; - node = plugin; - - EXPECT_EQ(plugin.GetName(), node["name"].as()); - EXPECT_FALSE(node["after"]); - EXPECT_FALSE(node["req"]); - EXPECT_FALSE(node["inc"]); - EXPECT_FALSE(node["msg"]); - EXPECT_FALSE(node["tag"]); - EXPECT_FALSE(node["dirty"]); - EXPECT_FALSE(node["clean"]); - EXPECT_FALSE(node["url"]); -} - -TEST_F(PluginMetadataTest, - encodingAsYamlShouldSetAfterFieldIfLoadAfterMetadataExists) { - PluginMetadata plugin(blankEsp); - plugin.SetLoadAfterFiles({File(blankEsm)}); - YAML::Node node; - node = plugin; - - EXPECT_EQ(plugin.GetLoadAfterFiles(), node["after"].as>()); -} - -TEST_F(PluginMetadataTest, encodingAsYamlShouldSetReqFieldIfRequirementsExist) { - PluginMetadata plugin(blankEsp); - plugin.SetRequirements({File(blankEsm)}); - YAML::Node node; - node = plugin; - - EXPECT_EQ(plugin.GetRequirements(), node["req"].as>()); -} - -TEST_F(PluginMetadataTest, - encodingAsYamlShouldSetIncFieldIfIncompatibilitiesExist) { - PluginMetadata plugin(blankEsp); - plugin.SetIncompatibilities({File(blankEsm)}); - YAML::Node node; - node = plugin; - - EXPECT_EQ(plugin.GetIncompatibilities(), node["inc"].as>()); -} - -TEST_F(PluginMetadataTest, encodingAsYamlShouldSetMsgFieldIfMessagesExist) { - PluginMetadata plugin(blankEsp); - plugin.SetMessages({Message(MessageType::say, "content")}); - YAML::Node node; - node = plugin; - - EXPECT_EQ(plugin.GetMessages(), node["msg"].as>()); -} - -TEST_F(PluginMetadataTest, encodingAsYamlShouldSetTagFieldIfTagsExist) { - PluginMetadata plugin(blankEsp); - plugin.SetTags({Tag("Relev")}); - YAML::Node node; - node = plugin; - - EXPECT_EQ(plugin.GetTags(), node["tag"].as>()); -} - -TEST_F(PluginMetadataTest, encodingAsYamlShouldSetDirtyFieldIfDirtyInfoExists) { - PluginMetadata plugin(blankEsp); - plugin.SetDirtyInfo({PluginCleaningData(5, "utility", info_, 0, 1, 2)}); - YAML::Node node; - node = plugin; - - EXPECT_EQ(plugin.GetDirtyInfo(), - node["dirty"].as>()); -} - -TEST_F(PluginMetadataTest, encodingAsYamlShouldSetCleanFieldIfCleanInfoExists) { - PluginMetadata plugin(blankEsp); - plugin.SetCleanInfo({PluginCleaningData(5, "utility")}); - YAML::Node node; - node = plugin; - - EXPECT_EQ(plugin.GetCleanInfo(), - node["clean"].as>()); -} - -TEST_F(PluginMetadataTest, encodingAsYamlShouldSetUrlFieldIfLocationsExist) { - PluginMetadata plugin(blankEsp); - plugin.SetLocations({Location("http://www.example.com")}); - YAML::Node node; - node = plugin; - - EXPECT_EQ(plugin.GetLocations(), node["url"].as>()); -} - -TEST_F(PluginMetadataTest, decodingFromYamlShouldStoreAllGivenData) { - YAML::Node node = YAML::Load( - "name: 'Blank.esp'\n" - "after:\n" - " - 'Blank.esm'\n" - "req:\n" - " - 'Blank.esm'\n" - "inc:\n" - " - 'Blank.esm'\n" - "msg:\n" - " - type: say\n" - " content: 'content'\n" - "tag:\n" - " - Relev\n" - "dirty:\n" - " - crc: 0x5\n" - " util: 'utility'\n" - " udr: 1\n" - " nav: 2\n" - "clean:\n" - " - crc: 0x6\n" - " util: 'utility'\n" - "url:\n" - " - 'http://www.example.com'"); - PluginMetadata plugin = node.as(); - - EXPECT_EQ("Blank.esp", plugin.GetName()); - EXPECT_EQ(std::vector({File("Blank.esm")}), plugin.GetLoadAfterFiles()); - EXPECT_EQ(std::vector({File("Blank.esm")}), plugin.GetRequirements()); - EXPECT_EQ(std::vector({File("Blank.esm")}), - plugin.GetIncompatibilities()); - EXPECT_EQ(std::vector({Message(MessageType::say, "content")}), - plugin.GetMessages()); - EXPECT_EQ(std::vector({Tag("Relev")}), plugin.GetTags()); - EXPECT_EQ(std::vector( - {PluginCleaningData(5, "utility", {}, 0, 1, 2)}), - plugin.GetDirtyInfo()); - EXPECT_EQ(std::vector({PluginCleaningData(6, "utility")}), - plugin.GetCleanInfo()); - EXPECT_EQ(std::vector({Location("http://www.example.com")}), - plugin.GetLocations()); -} - -TEST_F(PluginMetadataTest, - decodingFromYamlWithDirtyInfoInARegexPluginMetadataObjectShouldThrow) { - YAML::Node node = YAML::Load( - "name: 'Blank\\.esp'\n" - "dirty:\n" - " - crc: 0x5\n" - " util: 'utility'\n" - " udr: 1\n" - " nav: 2"); - PluginMetadata plugin = node.as(); - - EXPECT_EQ("Blank\\.esp", plugin.GetName()); - EXPECT_EQ(std::vector( - {PluginCleaningData(5, "utility", {}, 0, 1, 2)}), - plugin.GetDirtyInfo()); -} - -TEST_F(PluginMetadataTest, - decodingFromYamlWithCleanInfoInARegexPluginMetadataObjectShouldThrow) { - YAML::Node node = YAML::Load( - "name: 'Blank\\.esp'\n" - "clean:\n" - " - crc: 0x5\n" - " util: 'utility'"); - PluginMetadata plugin = node.as(); - - EXPECT_EQ("Blank\\.esp", plugin.GetName()); - EXPECT_EQ(std::vector({PluginCleaningData(5, "utility")}), - plugin.GetCleanInfo()); -} - -TEST_F(PluginMetadataTest, decodingFromYamlWithAnInvalidRegexNameShouldThrow) { - YAML::Node node = YAML::Load( - "name: 'RagnvaldBook(Farengar(+Ragnvald)?)?\\.esp'\n" - "dirty:\n" - " - crc: 0x5\n" - " util: 'utility'\n" - " udr: 1\n" - " nav: 2"); - - EXPECT_THROW(node.as(), YAML::RepresentationException); -} - -TEST_F(PluginMetadataTest, decodingFromAYamlScalarShouldThrow) { - YAML::Node node = YAML::Load("scalar"); - - EXPECT_THROW(node.as(), YAML::RepresentationException); -} - -TEST_F(PluginMetadataTest, decodingFromAYamlListShouldThrow) { - YAML::Node node = YAML::Load("[0, 1, 2]"); - - EXPECT_THROW(node.as(), YAML::RepresentationException); -} -} - -#endif diff --git a/src/tests/api/internals/metadata/yaml/tag_test.h b/src/tests/api/internals/metadata/yaml/tag_test.h deleted file mode 100644 index 66508771..00000000 --- a/src/tests/api/internals/metadata/yaml/tag_test.h +++ /dev/null @@ -1,139 +0,0 @@ -/* LOOT - -A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and -Fallout: New Vegas. - -Copyright (C) 2014-2016 WrinklyNinja - -This file is part of LOOT. - -LOOT is free software: you can redistribute -it and/or modify it under the terms of the GNU General Public License -as published by the Free Software Foundation, either version 3 of -the License, or (at your option) any later version. - -LOOT is distributed in the hope that it will -be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with LOOT. If not, see -. -*/ - -#ifndef LOOT_TESTS_API_INTERNALS_METADATA_YAML_TAG_TEST -#define LOOT_TESTS_API_INTERNALS_METADATA_YAML_TAG_TEST - -#include - -#include "api/metadata/yaml/tag.h" - -namespace loot::test { -TEST( - Tag, - emittingAsYamlShouldOutputOnlyTheNameStringIfTheTagIsAnAdditionWithNoCondition) { - Tag tag("name1"); - YAML::Emitter emitter; - emitter << tag; - - EXPECT_EQ(tag.GetName(), emitter.c_str()); -} - -TEST( - Tag, - emittingAsYamlShouldOutputOnlyTheNameStringPrefixedWithAHyphenIfTheTagIsARemovalWithNoCondition) { - Tag tag("name1", false); - YAML::Emitter emitter; - emitter << tag; - - EXPECT_EQ("-" + tag.GetName(), emitter.c_str()); -} - -TEST(Tag, emittingAsYamlShouldOutputAMapIfTheTagHasACondition) { - Tag tag("name1", false, "condition1"); - YAML::Emitter emitter; - emitter << tag; - - EXPECT_STREQ("name: -name1\ncondition: 'condition1'", emitter.c_str()); -} - -TEST(Tag, - encodingAsYamlShouldOmitTheConditionFieldIfTheConditionStringIsEmpty) { - Tag tag; - YAML::Node node; - node = tag; - - EXPECT_FALSE(node["condition"]); -} - -TEST(Tag, encodingAsYamlShouldOutputTheNameFieldCorrectly) { - Tag tag("name1"); - YAML::Node node; - node = tag; - - EXPECT_EQ(tag.GetName(), node["name"].as()); -} - -TEST( - Tag, - encodingAsYamlShouldOutputTheNameFieldWithAHyphenPrefixIfTheTagIsARemoval) { - Tag tag("name1", false); - YAML::Node node; - node = tag; - - EXPECT_EQ("-" + tag.GetName(), node["name"].as()); -} - -TEST( - Tag, - encodingAsYamlShouldOutputTheConditionFieldIfTheConditionStringIsNotEmpty) { - Tag tag("name1", true, "condition1"); - YAML::Node node; - node = tag; - - EXPECT_EQ(tag.GetName(), node["name"].as()); - EXPECT_EQ(tag.GetCondition(), node["condition"].as()); -} - -TEST(Tag, decodingFromYamlScalarShouldSetNameCorrectly) { - YAML::Node node = YAML::Load("name1"); - Tag tag = node.as(); - - EXPECT_EQ("name1", tag.GetName()); - EXPECT_TRUE(tag.IsAddition()); - EXPECT_EQ("", tag.GetCondition()); -} - -TEST(Tag, decodingFromYamlScalarShouldSetAdditionStateCorrectly) { - YAML::Node node = YAML::Load("-name1"); - Tag tag = node.as(); - - EXPECT_EQ("name1", tag.GetName()); - EXPECT_FALSE(tag.IsAddition()); - EXPECT_EQ("", tag.GetCondition()); -} - -TEST(Tag, decodingFromYamlMapShouldSetDataCorrectly) { - YAML::Node node = YAML::Load("{name: name1, condition: 'file(\"Foo.esp\")'}"); - Tag tag = node.as(); - - EXPECT_EQ("name1", tag.GetName()); - EXPECT_TRUE(tag.IsAddition()); - EXPECT_EQ("file(\"Foo.esp\")", tag.GetCondition()); -} - -TEST(Tag, decodingFromYamlShouldThrowIfAnInvalidConditionIsGiven) { - YAML::Node node = YAML::Load("{name: name1, condition: invalid}"); - - EXPECT_THROW(node.as(), YAML::RepresentationException); -} - -TEST(Tag, decodingFromYamlListShouldThrow) { - YAML::Node node = YAML::Load("[0, 1, 2]"); - - EXPECT_THROW(node.as(), YAML::RepresentationException); -} -} - -#endif diff --git a/src/tests/api/internals/metadata_list_test.h b/src/tests/api/internals/metadata_list_test.h deleted file mode 100644 index 77d604d4..00000000 --- a/src/tests/api/internals/metadata_list_test.h +++ /dev/null @@ -1,677 +0,0 @@ -/* LOOT - -A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and -Fallout: New Vegas. - -Copyright (C) 2014-2016 WrinklyNinja - -This file is part of LOOT. - -LOOT is free software: you can redistribute -it and/or modify it under the terms of the GNU General Public License -as published by the Free Software Foundation, either version 3 of -the License, or (at your option) any later version. - -LOOT is distributed in the hope that it will -be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with LOOT. If not, see -. -*/ - -#ifndef LOOT_TESTS_API_INTERNALS_METADATA_LIST_TEST -#define LOOT_TESTS_API_INTERNALS_METADATA_LIST_TEST - -#include "api/metadata_list.h" -#include "tests/common_game_test_fixture.h" - -namespace loot { -namespace test { -class MetadataListTest : public CommonGameTestFixture { -protected: - MetadataListTest() : - CommonGameTestFixture(GameType::tes4), - metadataPath(metadataFilesPath / "masterlist.yaml"), - savedMetadataPath(metadataFilesPath / "saved.masterlist.yaml"), - missingMetadataPath(metadataFilesPath / "missing-metadata.yaml") {} - - void SetUp() override { - CommonGameTestFixture::SetUp(); - - using std::filesystem::copy; - using std::filesystem::exists; - - writeMasterlist(metadataPath); - ASSERT_TRUE(exists(metadataPath)); - - ASSERT_FALSE(exists(savedMetadataPath)); - ASSERT_FALSE(exists(missingMetadataPath)); - } - - static void writeMasterlist(const std::filesystem::path& path) { - std::ofstream out(path); - out << 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)"; - out.close(); - } - - static std::string PluginMetadataToString(const PluginMetadata& metadata) { - return metadata.GetName(); - } - - const std::filesystem::path metadataPath; - const std::filesystem::path savedMetadataPath; - const std::filesystem::path groupMetadataPath; - const std::filesystem::path missingMetadataPath; -}; - -TEST_F(MetadataListTest, loadShouldLoadGlobalMessages) { - MetadataList metadataList; - - EXPECT_NO_THROW(metadataList.Load(metadataPath)); - EXPECT_EQ(std::vector({ - Message(MessageType::say, "A global message."), - }), - metadataList.Messages()); -} - -TEST_F(MetadataListTest, loadShouldLoadPluginMetadata) { - MetadataList metadataList; - - EXPECT_NO_THROW(metadataList.Load(metadataPath)); - // Non-regex plugins can be outputted in any order, and regex entries can - // match each other, so convert the list to a set of strings for - // comparison. - std::vector result(metadataList.Plugins()); - std::set names; - std::transform( - begin(result), - end(result), - std::insert_iterator>(names, begin(names)), - &MetadataListTest::PluginMetadataToString); - - EXPECT_EQ(std::set({ - blankEsm, - blankEsp, - "Blank.+\\.esp", - "Blank.+(Different)?.*\\.esp", - }), - names); -} - -TEST_F(MetadataListTest, loadShouldLoadBashTags) { - MetadataList metadataList; - ASSERT_NO_THROW(metadataList.Load(metadataPath)); - - EXPECT_EQ(std::vector({"C.Climate", "Relev"}), - metadataList.BashTags()); -} - -TEST_F(MetadataListTest, loadShouldLoadGroups) { - MetadataList metadataList; - ASSERT_NO_THROW(metadataList.Load(metadataPath)); - - auto groups = metadataList.Groups(); - - ASSERT_EQ(3, groups.size()); - - EXPECT_EQ("default", groups[0].GetName()); - EXPECT_TRUE(groups[0].GetAfterGroups().empty()); - - EXPECT_EQ("group1", groups[1].GetName()); - EXPECT_EQ(std::vector({"group2"}), groups[1].GetAfterGroups()); - - EXPECT_EQ("group2", groups[2].GetName()); - EXPECT_EQ(std::vector({"default"}), groups[2].GetAfterGroups()); -} - -TEST_F(MetadataListTest, loadYamlParsingShouldSupportMergeKeys) { - using std::endl; - - std::ofstream out(metadataPath); - out << "common:" << endl - << " - &earlier" << endl - << " name: earlier" << endl - << " after:" << endl - << " - earliest" << endl - << "groups:" << endl - << " - name: default" << endl - << " <<: *earlier" << endl; - - out.close(); - - MetadataList metadataList; - ASSERT_NO_THROW(metadataList.Load(metadataPath)); - - auto groups = metadataList.Groups(); - - ASSERT_EQ(1, groups.size()); - - EXPECT_EQ("default", groups[0].GetName()); - EXPECT_EQ(std::vector({"earliest"}), groups[0].GetAfterGroups()); -} - -TEST_F(MetadataListTest, loadShouldThrowIfAnInvalidMetadataFileIsGiven) { - MetadataList metadataList; - - std::ofstream out(metadataPath); - out << R"( - 'C.Climate' - - 'Relev' - -globals: - - type: say - content: 'A global message.' - -plugins: - - name: 'Blank.+\.esp' - after: - - 'Blank.esm')"; - out.close(); - - EXPECT_THROW(metadataList.Load(metadataPath), std::runtime_error); - - out.open(metadataPath); - out << R"(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.esm' - msg: - - type: error - content: 'This plugin entry will cause a failure, as it is not the first exact entry.')"; - out.close(); - - EXPECT_THROW(metadataList.Load(metadataPath), std::runtime_error); -} - -TEST_F(MetadataListTest, - loadShouldClearExistingDataIfAnInvalidMetadataFileIsGiven) { - MetadataList metadataList; - - ASSERT_NO_THROW(metadataList.Load(metadataPath)); - ASSERT_FALSE(metadataList.Messages().empty()); - ASSERT_FALSE(metadataList.Plugins().empty()); - ASSERT_FALSE(metadataList.BashTags().empty()); - - EXPECT_THROW(metadataList.Load(blankEsm), std::runtime_error); - EXPECT_TRUE(metadataList.Messages().empty()); - EXPECT_TRUE(metadataList.Plugins().empty()); - EXPECT_TRUE(metadataList.BashTags().empty()); -} - -TEST_F(MetadataListTest, - loadShouldClearExistingDataIfAMissingMetadataFileIsGiven) { - MetadataList metadataList; - - ASSERT_NO_THROW(metadataList.Load(metadataPath)); - ASSERT_FALSE(metadataList.Messages().empty()); - ASSERT_FALSE(metadataList.Plugins().empty()); - ASSERT_FALSE(metadataList.BashTags().empty()); - - EXPECT_THROW(metadataList.Load(missingMetadataPath), std::runtime_error); - EXPECT_TRUE(metadataList.Messages().empty()); - EXPECT_TRUE(metadataList.Plugins().empty()); - EXPECT_TRUE(metadataList.BashTags().empty()); -} - -TEST_F( - MetadataListTest, - loadWithPreludeShouldReplaceThePreludeInTheFirstFileWithTheContentOfTheSecond) { - using std::endl; - - std::ofstream out(metadataPath); - out << "prelude:" << endl - << " - &ref" << endl - << " type: say" << endl - << " content: Loaded from same file" << endl - << "globals:" << endl - << " - *ref" << endl; - - out.close(); - - auto preludePath = metadataFilesPath / "prelude.yaml"; - out.open(preludePath); - out << "common:" << endl - << " - &ref" << endl - << " type: say" << endl - << " content: Loaded from prelude" << endl; - - MetadataList metadataList; - ASSERT_NO_THROW(metadataList.LoadWithPrelude(metadataPath, preludePath)); - - auto messages = metadataList.Messages(); - ASSERT_EQ(1, messages.size()); - EXPECT_EQ(MessageType::say, messages[0].GetType()); - ASSERT_EQ(1, messages[0].GetContent().size()); - EXPECT_EQ("Loaded from prelude", messages[0].GetContent()[0].GetText()); -} - -TEST_F(MetadataListTest, saveShouldWriteTheLoadedMetadataToTheGivenFilePath) { - MetadataList metadataList; - ASSERT_NO_THROW(metadataList.Load(metadataPath)); - - EXPECT_NO_THROW(metadataList.Save(savedMetadataPath)); - - EXPECT_TRUE(std::filesystem::exists(savedMetadataPath)); - - // Check the new file contains the same metadata. - EXPECT_NO_THROW(metadataList.Load(savedMetadataPath)); - - EXPECT_EQ(std::vector({"C.Climate", "Relev"}), - metadataList.BashTags()); - - auto expectedGroups = std::vector({Group("default"), - Group("group1", {"group2"}), - Group("group2", {"default"})}); - EXPECT_EQ(expectedGroups, metadataList.Groups()); - - EXPECT_EQ(std::vector({ - Message(MessageType::say, "A global message."), - }), - metadataList.Messages()); - - // Non-regex plugins can be outputted in any order, and regex entries can - // match each other, so convert the list to a set of strings for - // comparison. - std::vector result(metadataList.Plugins()); - std::set names; - std::transform( - begin(result), - end(result), - std::insert_iterator>(names, begin(names)), - &MetadataListTest::PluginMetadataToString); - EXPECT_EQ(std::set({ - blankEsm, - blankEsp, - "Blank.+\\.esp", - "Blank.+(Different)?.*\\.esp", - }), - names); -} - -TEST_F(MetadataListTest, clearShouldClearLoadedData) { - MetadataList metadataList; - ASSERT_NO_THROW(metadataList.Load(metadataPath)); - ASSERT_FALSE(metadataList.Messages().empty()); - ASSERT_FALSE(metadataList.Plugins().empty()); - ASSERT_FALSE(metadataList.BashTags().empty()); - - metadataList.Clear(); - EXPECT_TRUE(metadataList.Messages().empty()); - EXPECT_TRUE(metadataList.Plugins().empty()); - EXPECT_TRUE(metadataList.BashTags().empty()); -} - -TEST_F(MetadataListTest, setGroupsShouldReplaceExistingGroups) { - MetadataList metadataList; - ASSERT_NO_THROW(metadataList.Load(metadataPath)); - - metadataList.SetGroups({Group("group4")}); - - auto groups = metadataList.Groups(); - - ASSERT_EQ(2, groups.size()); - - EXPECT_EQ("default", groups[0].GetName()); - EXPECT_TRUE(groups[0].GetAfterGroups().empty()); - - EXPECT_EQ("group4", groups[1].GetName()); - EXPECT_TRUE(groups[1].GetAfterGroups().empty()); -} - -TEST_F( - MetadataListTest, - findPluginShouldReturnAnEmptyOptionalIfTheGivenPluginIsNotInTheMetadataList) { - MetadataList metadataList; - EXPECT_FALSE(metadataList.FindPlugin(blankDifferentEsm)); -} - -TEST_F( - MetadataListTest, - findPluginShouldReturnTheMetadataObjectInTheMetadataListIfOneExistsForTheGivenPlugin) { - MetadataList metadataList; - ASSERT_NO_THROW(metadataList.Load(metadataPath)); - - PluginMetadata plugin = metadataList.FindPlugin(blankDifferentEsp).value(); - - EXPECT_EQ(blankDifferentEsp, plugin.GetName()); - EXPECT_EQ(std::vector({ - File(blankEsm), - }), - plugin.GetLoadAfterFiles()); - EXPECT_EQ(std::vector({ - File(blankEsp), - }), - plugin.GetIncompatibilities()); -} - -TEST_F(MetadataListTest, addPluginShouldStoreGivenSpecificPluginMetadata) { - MetadataList metadataList; - ASSERT_NO_THROW(metadataList.Load(metadataPath)); - ASSERT_FALSE(metadataList.FindPlugin(blankDifferentEsm)); - - PluginMetadata plugin(blankDifferentEsm); - plugin.SetGroup("group1"); - metadataList.AddPlugin(plugin); - - plugin = metadataList.FindPlugin(plugin.GetName()).value(); - - EXPECT_EQ(blankDifferentEsm, plugin.GetName()); - EXPECT_EQ("group1", plugin.GetGroup()); -} - -TEST_F(MetadataListTest, addPluginShouldStoreGivenRegexPluginMetadata) { - MetadataList metadataList; - ASSERT_NO_THROW(metadataList.Load(metadataPath)); - - PluginMetadata plugin(".+Dependent\\.esp"); - plugin.SetGroup("group1"); - metadataList.AddPlugin(plugin); - - plugin = metadataList.FindPlugin(blankPluginDependentEsp).value(); - - EXPECT_EQ("group1", plugin.GetGroup()); -} - -TEST_F(MetadataListTest, addPluginShouldThrowIfAMatchingPluginAlreadyExists) { - MetadataList metadataList; - ASSERT_NO_THROW(metadataList.Load(metadataPath)); - - PluginMetadata plugin = metadataList.FindPlugin(blankEsm).value(); - ASSERT_EQ(blankEsm, plugin.GetName()); - - EXPECT_THROW(metadataList.AddPlugin(PluginMetadata(blankEsm)), - std::invalid_argument); -} - -TEST_F(MetadataListTest, - erasePluginShouldRemoveStoredMetadataForTheGivenPlugin) { - MetadataList metadataList; - ASSERT_NO_THROW(metadataList.Load(metadataPath)); - - PluginMetadata plugin = metadataList.FindPlugin(blankEsp).value(); - ASSERT_EQ(blankEsp, plugin.GetName()); - ASSERT_FALSE(plugin.HasNameOnly()); - - metadataList.ErasePlugin(plugin.GetName()); - - EXPECT_FALSE(metadataList.FindPlugin(plugin.GetName())); -} - -TEST(ReplaceMetadataListPrelude, shouldReturnAnEmptyStringIfGivenEmptyStrings) { - std::string prelude = ""; - std::string masterlist = ""; - - auto result = ReplaceMetadataListPrelude(prelude, std::string(masterlist)); - - EXPECT_EQ(masterlist, result); -} - -TEST(ReplaceMetadataListPrelude, shouldNotChangeAMasterlistWithNoPrelude) { - std::string prelude = R"(globals: - - type: note - content: A message. -)"; - std::string masterlist = R"(plugins: - - name: a.esp -)"; - - auto result = ReplaceMetadataListPrelude(prelude, std::string(masterlist)); - - EXPECT_EQ(masterlist, result); -} - -TEST(ReplaceMetadataListPrelude, - shouldReplaceAPreludeAtTheStartOfTheMasterlist) { - std::string prelude = R"(globals: - - type: note - content: A message. -)"; - std::string masterlist = R"(prelude: - a: b - -plugins: - - name: a.esp -)"; - - auto result = ReplaceMetadataListPrelude(prelude, std::move(masterlist)); - - auto expectedResult = R"(prelude: - globals: - - type: note - content: A message. - -plugins: - - name: a.esp -)"; - - EXPECT_EQ(expectedResult, result); -} - -TEST(ReplaceMetadataListPrelude, shouldChangeAMasterlistThatEndsWithAPrelude) { - std::string prelude = R"(globals: - - type: note - content: A message. -)"; - std::string masterlist = R"(plugins: - - name: a.esp -prelude: - a: b - -)"; - - auto result = ReplaceMetadataListPrelude(prelude, std::move(masterlist)); - - auto expectedResult = R"(plugins: - - name: a.esp -prelude: - globals: - - type: note - content: A message. -)"; - - EXPECT_EQ(expectedResult, result); -} - -TEST(ReplaceMetadataListPrelude, shouldReplaceOnlyThePreludeInTheMasterlist) { - std::string prelude = R"( - -globals: - - type: note - content: A message. - -)"; - std::string masterlist = R"( -common: - key: value -prelude: - a: b -plugins: - - name: a.esp -)"; - - auto result = ReplaceMetadataListPrelude(prelude, std::move(masterlist)); - - auto expectedResult = R"( -common: - key: value -prelude: - - - globals: - - type: note - content: A message. - - -plugins: - - name: a.esp -)"; - - EXPECT_EQ(expectedResult, result); -} - -TEST(ReplaceMetadataListPrelude, - shouldSucceedIfGivenABlockStylePreludeAndABlockStyleMasterlist) { - std::string prelude = R"(globals: - - type: note - content: A message. -)"; - std::string masterlist = R"(prelude: - a: b - -plugins: - - name: a.esp -)"; - - auto result = ReplaceMetadataListPrelude(prelude, std::move(masterlist)); - - auto expectedResult = R"(prelude: - globals: - - type: note - content: A message. - -plugins: - - name: a.esp -)"; - - EXPECT_EQ(expectedResult, result); -} - -TEST(ReplaceMetadataListPrelude, - shouldSucceedIfGivenAFlowStylePreludeAndABlockStyleMasterlist) { - std::string prelude = "globals: [{type: note, content: A message.}]"; - std::string masterlist = R"(prelude: - a: b - -plugins: - - name: a.esp -)"; - - auto result = ReplaceMetadataListPrelude(prelude, std::move(masterlist)); - - auto expectedResult = R"(prelude: - globals: [{type: note, content: A message.}] -plugins: - - name: a.esp -)"; - - EXPECT_EQ(expectedResult, result); -} - -TEST(ReplaceMetadataListPrelude, doesNotChangeAFlowStyleMasterlist) { - std::string prelude = "globals: [{type: note, content: A message.}]"; - std::string masterlist = "{prelude: {}, plugins: [{name: a.esp}]}"; - - auto result = ReplaceMetadataListPrelude(prelude, std::string(masterlist)); - - EXPECT_EQ(masterlist, result); -} - -TEST(ReplaceMetadataListPrelude, shouldNotStopAtComments) { - std::string prelude = R"(globals: - - type: note - content: A message. -)"; - std::string masterlist = R"(prelude: - a: b -# Comment line - c: d - -plugins: - - name: a.esp -)"; - - auto result = ReplaceMetadataListPrelude(prelude, std::move(masterlist)); - - auto expectedResult = R"(prelude: - globals: - - type: note - content: A message. - -plugins: - - name: a.esp -)"; - - EXPECT_EQ(expectedResult, result); -} - -TEST(ReplaceMetadataListPrelude, shouldNotStopAtABlankLine) { - std::string prelude = R"(globals: - - type: note - content: A message. -)"; - std::string masterlist = R"(prelude: - a: b - - -plugins: - - name: a.esp -)"; - - auto result = ReplaceMetadataListPrelude(prelude, std::move(masterlist)); - - auto expectedResult = R"(prelude: - globals: - - type: note - content: A message. - -plugins: - - name: a.esp -)"; - - EXPECT_EQ(expectedResult, result); -} -} -} - -#endif diff --git a/src/tests/api/internals/plugin_test.h b/src/tests/api/internals/plugin_test.h deleted file mode 100644 index a248d66f..00000000 --- a/src/tests/api/internals/plugin_test.h +++ /dev/null @@ -1,619 +0,0 @@ -/* LOOT - -A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and -Fallout: New Vegas. - -Copyright (C) 2014-2016 WrinklyNinja - -This file is part of LOOT. - -LOOT is free software: you can redistribute -it and/or modify it under the terms of the GNU General Public License -as published by the Free Software Foundation, either version 3 of -the License, or (at your option) any later version. - -LOOT is distributed in the hope that it will -be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with LOOT. If not, see -. -*/ - -#ifndef LOOT_TESTS_API_INTERNALS_PLUGIN_TEST -#define LOOT_TESTS_API_INTERNALS_PLUGIN_TEST - -#include "api/game/game.h" -#include "api/plugin.h" -#include "loot/exception/plugin_not_loaded_error.h" -#include "tests/common_game_test_fixture.h" - -namespace loot { -namespace test { -class PluginTest : public CommonGameTestFixture, - public testing::WithParamInterface { -protected: - PluginTest() : - CommonGameTestFixture(GetParam()), - emptyFile("EmptyFile.esm"), - lowercaseBlankEsp("blank.esp"), - nonAsciiEsp(u8"non\u00C1scii.esp"), - otherNonAsciiEsp(u8"other non\u00C1scii.esp"), - blankArchive("Blank" + GetArchiveFileExtension(GetParam())), - blankSuffixArchive("Blank - Different - suffix" + - GetArchiveFileExtension(GetParam())), - game_(GetParam(), gamePath, localPath) {} - - void SetUp() override { - CommonGameTestFixture::SetUp(); - - game_.LoadCurrentLoadOrderState(); - - // Write out an empty file. - touch(dataPath / emptyFile); - ASSERT_TRUE(std::filesystem::exists(dataPath / emptyFile)); - -#ifndef _WIN32 - ASSERT_NO_THROW(std::filesystem::copy(dataPath / blankEsp, - dataPath / lowercaseBlankEsp)); -#endif - - // Make sure the plugins with non-ASCII filenames exists. - ASSERT_NO_THROW(std::filesystem::copy_file( - dataPath / blankEsp, dataPath / std::filesystem::u8path(nonAsciiEsp))); - ASSERT_NO_THROW(std::filesystem::copy_file( - dataPath / blankEsp, - dataPath / std::filesystem::u8path(otherNonAsciiEsp))); - - if (GetParam() != GameType::fo4 && GetParam() != GameType::fo4vr && - GetParam() != GameType::tes5se && GetParam() != GameType::tes5vr && - GetParam() != GameType::starfield) { - ASSERT_NO_THROW( - std::filesystem::copy(dataPath / blankEsp, dataPath / blankEsl)); - } - - // Copy across archive files. - std::filesystem::path blankMasterDependentArchive; - if (GetParam() == GameType::fo4 || GetParam() == GameType::fo4vr || - GetParam() == GameType::starfield) { - copyPlugin(getSourceArchivesPath(GetParam()), "Blank - Main.ba2"); - copyPlugin(getSourceArchivesPath(GetParam()), "Blank - Textures.ba2"); - - blankMasterDependentArchive = "Blank - Master Dependent - Main.ba2"; - std::filesystem::copy_file( - getSourceArchivesPath(GetParam()) / "Blank - Main.ba2", - dataPath / blankMasterDependentArchive); - ASSERT_TRUE( - std::filesystem::exists(dataPath / blankMasterDependentArchive)); - } else if (GetParam() == GameType::tes3 || GetParam() == GameType::openmw) { - touch(dataPath / blankArchive); - - blankMasterDependentArchive = "Blank - Master Dependent.bsa"; - touch(dataPath / blankMasterDependentArchive); - } else { - copyPlugin(getSourcePluginsPath(), blankArchive); - - // Also create a copy for Blank - Master Dependent.esp to test overlap. - blankMasterDependentArchive = "Blank - Master Dependent.bsa"; - std::filesystem::copy_file(getSourcePluginsPath() / blankArchive, - dataPath / blankMasterDependentArchive); - ASSERT_TRUE( - std::filesystem::exists(dataPath / blankMasterDependentArchive)); - } - - // Create dummy archive files. - touch(dataPath / blankSuffixArchive); - - auto nonAsciiArchivePath = - dataPath / - std::filesystem::u8path(u8"non\u00E1scii" + - GetArchiveFileExtension(game_.GetType())); - touch(dataPath / nonAsciiArchivePath); - - auto nonAsciiPrefixArchivePath = - dataPath / - std::filesystem::u8path(u8"other non\u00E1scii2 - suffix" + - GetArchiveFileExtension(game_.GetType())); - touch(dataPath / nonAsciiPrefixArchivePath); - - game_.GetCache().CacheArchivePaths({dataPath / "Blank - Main.ba2", - dataPath / "Blank - Textures.ba2", - dataPath / blankArchive, - dataPath / blankMasterDependentArchive, - dataPath / blankSuffixArchive, - dataPath / nonAsciiArchivePath, - dataPath / nonAsciiPrefixArchivePath}); - } - - const std::string emptyFile; - const std::string lowercaseBlankEsp; - const std::string nonAsciiEsp; - const std::string otherNonAsciiEsp; - const std::string blankArchive; - const std::string blankSuffixArchive; - - Game game_; - -private: - static std::string GetArchiveFileExtension(const GameType gameType) { - if (gameType == GameType::fo4 || gameType == GameType::fo4vr || - gameType == GameType::starfield) - return ".ba2"; - else - return ".bsa"; - } -}; - -class TestPlugin : public PluginSortingInterface { -public: - TestPlugin() : name_("") {} - - TestPlugin(std::string_view name) : name_(name) {} - - std::string GetName() const override { return name_; } - - std::optional GetHeaderVersion() const override { - return std::optional(); - } - - std::optional GetVersion() const override { - return std::optional(); - } - - std::vector GetMasters() const override { return masters_; } - - std::vector GetBashTags() const override { return {}; } - - std::optional GetCRC() const override { - return std::optional(); - } - - bool IsMaster() const override { return isMaster_; } - - bool IsLightPlugin() const override { return isLightPlugin_; } - - bool IsMediumPlugin() const override { return false; } - - bool IsUpdatePlugin() const override { return false; } - - bool IsBlueprintPlugin() const override { return isBlueprintPlugin_; } - - bool IsValidAsLightPlugin() const override { return false; } - - bool IsValidAsMediumPlugin() const override { return false; } - - bool IsValidAsUpdatePlugin() const override { return false; } - - bool IsEmpty() const override { return false; } - - bool LoadsArchive() const override { return false; } - - bool DoRecordsOverlap(const PluginInterface& plugin) const override { - const auto otherPlugin = dynamic_cast(&plugin); - return recordsOverlapWith.count(&plugin) != 0 || - otherPlugin->recordsOverlapWith.count(this) != 0; - } - - size_t GetOverrideRecordCount() const override { - return overrideRecordCount_; - } - - size_t GetAssetCount() const override { return assetCount_; }; - - bool DoAssetsOverlap(const PluginSortingInterface& plugin) const override { - const auto otherPlugin = dynamic_cast(&plugin); - return assetsOverlapWith.count(&plugin) != 0 || - otherPlugin->assetsOverlapWith.count(this) != 0; - } - - void AddMaster(std::string_view master) { - masters_.push_back(std::string(master)); - } - - void SetIsMaster(bool isMaster) { isMaster_ = isMaster; } - - void SetIsLightPlugin(bool isLightPlugin) { isLightPlugin_ = isLightPlugin; } - - void SetIsBlueprintPlugin(bool isBlueprintPlugin) { - isBlueprintPlugin_ = isBlueprintPlugin; - } - - void AddOverlappingRecords(const PluginInterface& plugin) { - recordsOverlapWith.insert(&plugin); - } - - void SetOverrideRecordCount(size_t overrideRecordCount) { - overrideRecordCount_ = overrideRecordCount; - } - - void AddOverlappingAssets(const PluginSortingInterface& plugin) { - assetsOverlapWith.insert(&plugin); - } - - void SetAssetCount(size_t assetCount) { assetCount_ = assetCount; } - -private: - std::string name_; - std::vector masters_; - std::set recordsOverlapWith; - std::set assetsOverlapWith; - size_t overrideRecordCount_{0}; - size_t assetCount_{0}; - bool isMaster_{false}; - bool isLightPlugin_{false}; - bool isBlueprintPlugin_{false}; -}; - -// Pass an empty first argument, as it's a prefix for the test instantation, -// but we only have the one so no prefix is necessary. -INSTANTIATE_TEST_SUITE_P(, PluginTest, ::testing::ValuesIn(ALL_GAME_TYPES)); - -TEST_P(PluginTest, constructorShouldTrimGhostExtensionExceptForOpenMW) { - const auto pluginPath = - game_.DataPath() / (blankMasterDependentEsm + ".ghost"); - - if (GetParam() == GameType::openmw) { - // This wasn't done for OpenMW during common setup. - std::filesystem::rename(dataPath / blankMasterDependentEsm, pluginPath); - } - - Plugin plugin(game_.GetType(), game_.GetCache(), pluginPath, true); - - if (GetParam() == GameType::openmw) { - EXPECT_EQ(pluginPath.filename().u8string(), plugin.GetName()); - } else { - EXPECT_EQ(blankMasterDependentEsm, plugin.GetName()); - } -} - -TEST_P(PluginTest, loadingShouldHandleNonAsciiFilenamesCorrectly) { - Plugin plugin(game_.GetType(), - game_.GetCache(), - game_.DataPath() / std::filesystem::u8path(nonAsciiEsp), - true); - - EXPECT_EQ(nonAsciiEsp, plugin.GetName()); - EXPECT_EQ(nonAsciiEsp, plugin.GetName()); -} - -TEST_P(PluginTest, loadingWholePluginShouldReadFields) { - const auto pluginName = GetParam() == GameType::openmw - ? blankMasterDependentEsm - : blankMasterDependentEsm + ".ghost"; - Plugin plugin( - game_.GetType(), game_.GetCache(), game_.DataPath() / pluginName, false); - - if (GetParam() == GameType::tes3 || GetParam() == GameType::openmw) { - Plugin master( - game_.GetType(), game_.GetCache(), game_.DataPath() / blankEsm, false); - const auto pluginsMetadata = Plugin::GetPluginsMetadata({&master}); - - EXPECT_NO_THROW(plugin.ResolveRecordIds(pluginsMetadata.get())); - - EXPECT_EQ(4, plugin.GetOverrideRecordCount()); - } else if (GetParam() == GameType::starfield) { - Plugin master(game_.GetType(), - game_.GetCache(), - game_.DataPath() / blankFullEsm, - true); - const auto pluginsMetadata = Plugin::GetPluginsMetadata({&master}); - - EXPECT_NO_THROW(plugin.ResolveRecordIds(pluginsMetadata.get())); - - EXPECT_EQ(1, plugin.GetOverrideRecordCount()); - } else { - EXPECT_EQ(4, plugin.GetOverrideRecordCount()); - } -} - -TEST_P(PluginTest, loadingWholePluginShouldSucceedForOpenMWPlugins) { - const auto omwgame = "Blank.omwgame"; - const auto omwaddon = "Blank.omwaddon"; - const auto omwscripts = "Blank.omwscripts"; - - std::filesystem::rename(dataPath / blankEsm, dataPath / omwgame); - std::filesystem::rename(dataPath / blankEsp, dataPath / omwaddon); - std::ofstream out(dataPath / omwscripts); - out.close(); - - EXPECT_NO_THROW( - Plugin(game_.GetType(), game_.GetCache(), dataPath / omwgame, false)); - EXPECT_NO_THROW( - Plugin(game_.GetType(), game_.GetCache(), dataPath / omwaddon, false)); - if (GetParam() == GameType::openmw) { - EXPECT_NO_THROW(Plugin( - game_.GetType(), game_.GetCache(), dataPath / omwscripts, false)); - } else { - EXPECT_THROW( - Plugin(game_.GetType(), game_.GetCache(), dataPath / omwscripts, false), - std::runtime_error); - } -} - -TEST_P( - PluginTest, - isLightPluginShouldBeTrueForAPluginWithEslFileExtensionForFallout4AndSkyrimSeAndFalseOtherwise) { - Plugin plugin1( - game_.GetType(), game_.GetCache(), game_.DataPath() / blankEsm, true); - Plugin plugin2(game_.GetType(), - game_.GetCache(), - game_.DataPath() / blankMasterDependentEsp, - true); - Plugin plugin3( - game_.GetType(), game_.GetCache(), game_.DataPath() / blankEsl, true); - - EXPECT_FALSE(plugin1.IsLightPlugin()); - EXPECT_FALSE(plugin2.IsLightPlugin()); - EXPECT_EQ(GetParam() == GameType::fo4 || GetParam() == GameType::fo4vr || - GetParam() == GameType::tes5se || - GetParam() == GameType::tes5vr || - GetParam() == GameType::starfield, - plugin3.IsLightPlugin()); -} - -TEST_P(PluginTest, loadingAPluginThatDoesNotExistShouldThrow) { - EXPECT_THROW(Plugin(game_.GetType(), - game_.GetCache(), - game_.DataPath() / "Blank\\.esp", - true), - std::runtime_error); -} - -TEST_P(PluginTest, isValidShouldReturnTrueForAValidPlugin) { - EXPECT_TRUE(Plugin::IsValid(game_.GetType(), game_.DataPath() / blankEsm)); -} - -TEST_P(PluginTest, isValidShouldReturnTrueForAValidNonAsciiPlugin) { - EXPECT_TRUE( - Plugin::IsValid(game_.GetType(), - game_.DataPath() / std::filesystem::u8path(nonAsciiEsp))); -} - -TEST_P(PluginTest, isValidShouldReturnFalseForANonPluginFile) { - EXPECT_FALSE( - Plugin::IsValid(game_.GetType(), game_.DataPath() / nonPluginFile)); -} - -TEST_P(PluginTest, isValidShouldReturnFalseForAnEmptyFile) { - EXPECT_FALSE(Plugin::IsValid(game_.GetType(), game_.DataPath() / emptyFile)); -} - -TEST_P(PluginTest, isValidShouldReturnTrueForAnOpenMWOmwscriptsFile) { - const auto omwscripts = "Blank.omwscripts"; - std::ofstream out(dataPath / omwscripts); - out.close(); - - if (GetParam() == GameType::openmw) { - EXPECT_TRUE( - Plugin::IsValid(game_.GetType(), game_.DataPath() / omwscripts)); - } else { - EXPECT_FALSE( - Plugin::IsValid(game_.GetType(), game_.DataPath() / omwscripts)); - } -} - -TEST_P(PluginTest, - getAssetCountShouldReturnNumberOfFilesInArchivesLoadedByPlugin) { - const auto assetCount = - Plugin( - game_.GetType(), game_.GetCache(), game_.DataPath() / blankEsp, false) - .GetAssetCount(); - - if (GetParam() == GameType::tes3 || GetParam() == GameType::openmw) { - EXPECT_EQ(0, assetCount); - } else if (GetParam() == GameType::fo4 || GetParam() == GameType::fo4vr || - GetParam() == GameType::starfield) { - EXPECT_EQ(2, assetCount); - } else { - EXPECT_EQ(1, assetCount); - } -} - -TEST_P(PluginTest, getAssetCountShouldReturnZeroIfOnlyPluginHeaderWasLoaded) { - const auto assetCount = - Plugin( - game_.GetType(), game_.GetCache(), game_.DataPath() / blankEsp, true) - .GetAssetCount(); - - EXPECT_EQ(0, assetCount); -} - -TEST_P(PluginTest, - doAssetsOverlapShouldReturnFalseOrThrowIfTheArgumentIsNotAPluginObject) { - Plugin plugin1( - game_.GetType(), game_.GetCache(), game_.DataPath() / blankEsp, false); - TestPlugin plugin2; - - if (GetParam() == GameType::tes3 || GetParam() == GameType::openmw) { - EXPECT_FALSE(plugin1.DoAssetsOverlap(plugin2)); - } else { - EXPECT_THROW(plugin1.DoAssetsOverlap(plugin2), std::invalid_argument); - } -} - -TEST_P(PluginTest, - doAssetsOverlapShouldReturnFalseForTwoPluginsWithOnlyHeadersLoaded) { - Plugin plugin1( - game_.GetType(), game_.GetCache(), game_.DataPath() / blankEsp, true); - Plugin plugin2(game_.GetType(), - game_.GetCache(), - game_.DataPath() / blankMasterDependentEsp, - true); - - EXPECT_FALSE(plugin1.DoAssetsOverlap(plugin2)); - EXPECT_FALSE(plugin2.DoAssetsOverlap(plugin1)); -} - -TEST_P(PluginTest, - doAssetsOverlapShouldReturnFalseIfThePluginsDoNotLoadTheSameAssetPath) { - Plugin plugin1( - game_.GetType(), game_.GetCache(), game_.DataPath() / blankEsp, false); - // Blank - Different.esp does not load any assets. - Plugin plugin2(game_.GetType(), - game_.GetCache(), - game_.DataPath() / blankDifferentEsp, - false); - - EXPECT_FALSE(plugin1.DoAssetsOverlap(plugin2)); - EXPECT_FALSE(plugin2.DoAssetsOverlap(plugin1)); -} - -TEST_P(PluginTest, - doAssetsOverlapShouldReturnTrueIfThePluginsLoadTheSameAssetPath) { - Plugin plugin1( - game_.GetType(), game_.GetCache(), game_.DataPath() / blankEsp, false); - Plugin plugin2(game_.GetType(), - game_.GetCache(), - game_.DataPath() / blankMasterDependentEsp, - false); - - if (GetParam() == GameType::tes3 || GetParam() == GameType::openmw) { - // Morrowind plugins can't load assets. - EXPECT_FALSE(plugin1.DoAssetsOverlap(plugin2)); - EXPECT_FALSE(plugin2.DoAssetsOverlap(plugin1)); - } else { - EXPECT_TRUE(plugin1.DoAssetsOverlap(plugin2)); - EXPECT_TRUE(plugin2.DoAssetsOverlap(plugin1)); - } -} - -class HasPluginFileExtensionTest : public ::testing::TestWithParam {}; - -INSTANTIATE_TEST_SUITE_P(, - HasPluginFileExtensionTest, - ::testing::ValuesIn(ALL_GAME_TYPES)); - -TEST_P(HasPluginFileExtensionTest, shouldBeTrueIfFileEndsInDotEspOrDotEsm) { - EXPECT_TRUE(hasPluginFileExtension("file.esp", GetParam())); - EXPECT_TRUE(hasPluginFileExtension("file.esm", GetParam())); - EXPECT_FALSE(hasPluginFileExtension("file.bsa", GetParam())); -} - -TEST_P(HasPluginFileExtensionTest, - shouldBeTrueIfFileEndsInDotEslOnlyForFallout4AndLater) { - bool result = hasPluginFileExtension("file.esl", GetParam()); - - EXPECT_EQ(GetParam() == GameType::fo4 || GetParam() == GameType::fo4vr || - GetParam() == GameType::tes5se || - GetParam() == GameType::tes5vr || - GetParam() == GameType::starfield, - result); -} - -TEST_P(HasPluginFileExtensionTest, shouldTrimGhostExtensionExceptForOpenMW) { - if (GetParam() == GameType::openmw) { - EXPECT_FALSE(hasPluginFileExtension("file.esp.ghost", GetParam())); - EXPECT_FALSE(hasPluginFileExtension("file.esm.ghost", GetParam())); - } else { - EXPECT_TRUE(hasPluginFileExtension("file.esp.ghost", GetParam())); - EXPECT_TRUE(hasPluginFileExtension("file.esm.ghost", GetParam())); - } - EXPECT_FALSE(hasPluginFileExtension("file.bsa.ghost", GetParam())); -} - -TEST_P(HasPluginFileExtensionTest, shouldRecogniseOpenMWPluginExtensions) { - EXPECT_EQ(GetParam() == GameType::openmw, - hasPluginFileExtension("file.omwgame", GetParam())); - EXPECT_EQ(GetParam() == GameType::openmw, - hasPluginFileExtension("file.omwaddon", GetParam())); - EXPECT_EQ(GetParam() == GameType::openmw, - hasPluginFileExtension("file.omwscripts", GetParam())); -} - -TEST(equivalent, shouldReturnTrueIfGivenEqualPathsThatExist) { - auto path1 = std::filesystem::path("./testing-plugins/LICENSE"); - auto path2 = std::filesystem::path("./testing-plugins/LICENSE"); - - ASSERT_EQ(path1, path2); - ASSERT_TRUE(std::filesystem::exists(path1)); - - EXPECT_TRUE(loot::equivalent(path1, path2)); -} - -TEST(equivalent, shouldReturnTrueIfGivenEqualPathsThatDoNotExist) { - auto path1 = std::filesystem::path("LICENSE2"); - auto path2 = std::filesystem::path("LICENSE2"); - - ASSERT_EQ(path1, path2); - ASSERT_FALSE(std::filesystem::exists(path1)); - - EXPECT_TRUE(loot::equivalent(path1, path2)); -} - -TEST(equivalent, - shouldReturnFalseIfGivenCaseInsensitivelyEqualPathsThatDoNotExist) { - auto upper = std::filesystem::path("LICENSE2"); - auto lower = std::filesystem::path("license2"); - - ASSERT_TRUE(boost::iequals(upper.u8string(), lower.u8string())); - ASSERT_FALSE(std::filesystem::exists(upper)); - ASSERT_FALSE(std::filesystem::exists(lower)); - - EXPECT_FALSE(loot::equivalent(lower, upper)); -} - -TEST(equivalent, shouldReturnFalseIfGivenCaseInsensitivelyUnequalThatExist) { - auto path1 = std::filesystem::path("./testing-plugins/LICENSE"); - auto path2 = std::filesystem::path("./testing-plugins/README.md"); - - ASSERT_FALSE(boost::iequals(path1.u8string(), path2.u8string())); - ASSERT_TRUE(std::filesystem::exists(path1)); - ASSERT_TRUE(std::filesystem::exists(path2)); - - EXPECT_FALSE(loot::equivalent(path1, path2)); -} - -#ifdef _WIN32 -TEST(equivalent, shouldReturnTrueIfGivenCaseInsensitivelyEqualPathsThatExist) { - auto upper = std::filesystem::path("./testing-plugins/LICENSE"); - auto lower = std::filesystem::path("./testing-plugins/license"); - - ASSERT_TRUE(boost::iequals(upper.u8string(), lower.u8string())); - ASSERT_TRUE(std::filesystem::exists(upper)); - ASSERT_TRUE(std::filesystem::exists(lower)); - - EXPECT_TRUE(loot::equivalent(lower, upper)); -} - -TEST( - equivalent, - shouldReturnTrueIfEqualPathsHaveCharactersThatAreUnrepresentableInTheSystemMultiByteCodePage) { - auto path1 = std::filesystem::u8path( - u8"\u2551\u00BB\u00C1\u2510\u2557\u00FE\u00C3\u00CE.txt"); - auto path2 = std::filesystem::u8path( - u8"\u2551\u00BB\u00C1\u2510\u2557\u00FE\u00C3\u00CE.txt"); - - EXPECT_TRUE(loot::equivalent(path1, path2)); -} - -TEST( - equivalent, - shouldReturnFalseIfCaseInsensitivelyEqualPathsHaveCharactersThatAreUnrepresentableInTheSystemMultiByteCodePage) { - auto path1 = std::filesystem::u8path( - u8"\u2551\u00BB\u00C1\u2510\u2557\u00FE\u00E3\u00CE.txt"); - auto path2 = std::filesystem::u8path( - u8"\u2551\u00BB\u00C1\u2510\u2557\u00FE\u00C3\u00CE.txt"); - - EXPECT_FALSE(loot::equivalent(path1, path2)); -} -#else -TEST(equivalent, shouldReturnFalseIfGivenCaseInsensitivelyEqualPathsThatExist) { - auto upper = std::filesystem::path("./testing-plugins/LICENSE"); - auto lower = std::filesystem::path("./testing-plugins/license"); - - std::ofstream out(lower); - out.close(); - - ASSERT_TRUE(boost::iequals(upper.u8string(), lower.u8string())); - ASSERT_TRUE(std::filesystem::exists(upper)); - ASSERT_TRUE(std::filesystem::exists(lower)); - - EXPECT_FALSE(loot::equivalent(lower, upper)); -} -#endif -} -} - -#endif diff --git a/src/tests/api/internals/sorting/group_sort_test.h b/src/tests/api/internals/sorting/group_sort_test.h deleted file mode 100644 index 41181920..00000000 --- a/src/tests/api/internals/sorting/group_sort_test.h +++ /dev/null @@ -1,235 +0,0 @@ -/* LOOT - -A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and -Fallout: New Vegas. - -Copyright (C) 2018 WrinklyNinja - -This file is part of LOOT. - -LOOT is free software: you can redistribute -it and/or modify it under the terms of the GNU General Public License -as published by the Free Software Foundation, either version 3 of -the License, or (at your option) any later version. - -LOOT is distributed in the hope that it will -be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with LOOT. If not, see -. -*/ - -#ifndef LOOT_TESTS_API_INTERNALS_SORTING_GROUP_SORT_TEST -#define LOOT_TESTS_API_INTERNALS_SORTING_GROUP_SORT_TEST - -#include - -#include "api/sorting/group_sort.h" -#include "loot/exception/cyclic_interaction_error.h" -#include "loot/exception/undefined_group_error.h" - -namespace loot { -namespace test { -TEST(BuildGroupGraph, shouldThrowIfAnAfterGroupDoesNotExist) { - std::vector groups({Group("b", {"a"})}); - - EXPECT_THROW(BuildGroupGraph(groups, {}), UndefinedGroupError); -} - -TEST(BuildGroupGraph, shouldThrowIfMasterlistGroupLoadsAfterAUserlistGroup) { - std::vector groups({Group("a", {}), - Group("b", {"a"}), - Group("c", {"a"}), - Group("e", {"b", "d"})}); - std::vector userGroups({Group("d", {"c"})}); - - EXPECT_THROW(BuildGroupGraph(groups, userGroups), UndefinedGroupError); -} - -TEST(BuildGroupGraph, shouldThrowIfAfterGroupsAreCyclic) { - std::vector groups({Group("a"), Group("b", {"a"})}); - std::vector userGroups({Group("a", {"c"}), Group("c", {"b"})}); - - try { - const auto groupGraph = BuildGroupGraph(groups, userGroups); - FAIL(); - } catch (CyclicInteractionError& e) { - ASSERT_EQ(3, e.GetCycle().size()); - - EXPECT_EQ("a", e.GetCycle()[0].GetName()); - EXPECT_EQ(EdgeType::masterlistLoadAfter, - e.GetCycle()[0].GetTypeOfEdgeToNextVertex()); - - EXPECT_EQ("b", e.GetCycle()[1].GetName()); - EXPECT_EQ(EdgeType::userLoadAfter, - e.GetCycle()[1].GetTypeOfEdgeToNextVertex()); - - EXPECT_EQ("c", e.GetCycle()[2].GetName()); - EXPECT_EQ(EdgeType::userLoadAfter, - e.GetCycle()[2].GetTypeOfEdgeToNextVertex()); - } -} - -TEST(BuildGroupGraph, shouldNotThrowIfThereIsNoCycle) { - std::vector groups({Group("a"), Group("b", {"a"})}); - - EXPECT_NO_THROW(BuildGroupGraph(groups, {})); -} - -TEST(BuildGroupGraph, shouldThrowIfThereIsACycle) { - std::vector groups({Group("a", {"b"}), Group("b", {"a"})}); - - try { - BuildGroupGraph(groups, {}); - FAIL(); - } catch (const CyclicInteractionError& e) { - ASSERT_EQ(2, e.GetCycle().size()); - EXPECT_EQ("a", e.GetCycle()[0].GetName()); - EXPECT_EQ(EdgeType::masterlistLoadAfter, - e.GetCycle()[0].GetTypeOfEdgeToNextVertex()); - EXPECT_EQ("b", e.GetCycle()[1].GetName()); - EXPECT_EQ(EdgeType::masterlistLoadAfter, - e.GetCycle()[1].GetTypeOfEdgeToNextVertex()); - } -} - -TEST(BuildGroupGraph, - exceptionThrownShouldOnlyRecordGroupsThatArePartOfTheCycle) { - std::vector groups( - {Group("a", {"b"}), Group("b", {"a"}), Group("c", {"b"})}); - - try { - BuildGroupGraph(groups, {}); - FAIL(); - } catch (const CyclicInteractionError& e) { - ASSERT_EQ(2, e.GetCycle().size()); - EXPECT_EQ("a", e.GetCycle()[0].GetName()); - EXPECT_EQ(EdgeType::masterlistLoadAfter, - e.GetCycle()[0].GetTypeOfEdgeToNextVertex()); - EXPECT_EQ("b", e.GetCycle()[1].GetName()); - EXPECT_EQ(EdgeType::masterlistLoadAfter, - e.GetCycle()[1].GetTypeOfEdgeToNextVertex()); - } -} - -TEST(GetGroupsPath, shouldThrowIfTheFromGroupDoesNotExist) { - std::vector groups({Group("a"), Group("b", {"a"})}); - std::vector userGroups({Group("a", {"c"}), Group("c")}); - - const auto groupGraph = BuildGroupGraph(groups, userGroups); - - EXPECT_THROW(GetGroupsPath(groupGraph, "d", "a"), std::invalid_argument); -} - -TEST(GetGroupsPath, shouldThrowIfTheToGroupDoesNotExist) { - std::vector groups({Group("a"), Group("b", {"a"})}); - std::vector userGroups({Group("a", {"c"}), Group("c")}); - - const auto groupGraph = BuildGroupGraph(groups, userGroups); - - EXPECT_THROW(GetGroupsPath(groupGraph, "a", "d"), std::invalid_argument); -} - -TEST(GetGroupsPath, - shouldReturnAnEmptyVectorIfThereIsNoPathBetweenTheTwoGroups) { - std::vector groups({Group("a", {}), - Group("b", {"a"}), - Group("c", {"a"}), - Group("d", {"c"}), - Group("e", {"b", "d"})}); - - const auto groupGraph = BuildGroupGraph(groups, {}); - auto path = GetGroupsPath(groupGraph, "b", "d"); - - EXPECT_TRUE(path.empty()); -} - -TEST(GetGroupsPath, - shouldFindThePathWithTheLeastNumberOfEdgesInAMasterlistOnlyGraph) { - std::vector groups({Group("a", {}), - Group("b", {"a"}), - Group("c", {"a"}), - Group("d", {"c"}), - Group("e", {"b", "d"})}); - - const auto groupGraph = BuildGroupGraph(groups, {}); - auto path = GetGroupsPath(groupGraph, "a", "e"); - - ASSERT_EQ(3, path.size()); - EXPECT_EQ("a", path[0].GetName()); - EXPECT_EQ(EdgeType::masterlistLoadAfter, - path[0].GetTypeOfEdgeToNextVertex().value()); - EXPECT_EQ("b", path[1].GetName()); - EXPECT_EQ(EdgeType::masterlistLoadAfter, - path[1].GetTypeOfEdgeToNextVertex().value()); - EXPECT_EQ("e", path[2].GetName()); - EXPECT_FALSE(path[2].GetTypeOfEdgeToNextVertex().has_value()); -} - -TEST(GetGroupsPath, - shouldFindThePathWithTheLeastNumberOfEdgesThatContainsUserMetadata) { - std::vector groups({Group("a", {}), - Group("b", {"a"}), - Group("c", {"a"}), - Group("e", {"b"})}); - std::vector userGroups({Group("d", {"c"}), Group("e", {"d"})}); - - const auto groupGraph = BuildGroupGraph(groups, userGroups); - auto path = GetGroupsPath(groupGraph, "a", "e"); - - ASSERT_EQ(4, path.size()); - EXPECT_EQ("a", path[0].GetName()); - EXPECT_EQ(EdgeType::masterlistLoadAfter, - path[0].GetTypeOfEdgeToNextVertex().value()); - EXPECT_EQ("c", path[1].GetName()); - EXPECT_EQ(EdgeType::userLoadAfter, - path[1].GetTypeOfEdgeToNextVertex().value()); - EXPECT_EQ("d", path[2].GetName()); - EXPECT_EQ(EdgeType::userLoadAfter, - path[2].GetTypeOfEdgeToNextVertex().value()); - EXPECT_EQ("e", path[3].GetName()); - EXPECT_FALSE(path[3].GetTypeOfEdgeToNextVertex().has_value()); -} - -TEST(GetGroupsPath, shouldNotDependOnTheAfterGroupDefinitionOrder) { - std::vector> orders{ - // Create a graph with after groups in one order. - {Group("A"), - Group("B", {"A"}), - Group("C", {"A"}), - Group("D", {"B", "C"}), - Group("E", {"D"}), - Group()}, - // Now do the same again, but with a different after group order for D. - {Group("A"), - Group("B", {"A"}), - Group("C", {"A"}), - Group("D", {"C", "B"}), - Group("E", {"D"}), - Group()}}; - - for (const auto& masterlistGroups : orders) { - const auto groupGraph = BuildGroupGraph(masterlistGroups, {}); - auto path = GetGroupsPath(groupGraph, "A", "E"); - - ASSERT_EQ(4, path.size()); - EXPECT_EQ("A", path[0].GetName()); - EXPECT_EQ(EdgeType::masterlistLoadAfter, - path[0].GetTypeOfEdgeToNextVertex().value()); - EXPECT_EQ("B", path[1].GetName()); - EXPECT_EQ(EdgeType::masterlistLoadAfter, - path[1].GetTypeOfEdgeToNextVertex().value()); - EXPECT_EQ("D", path[2].GetName()); - EXPECT_EQ(EdgeType::masterlistLoadAfter, - path[2].GetTypeOfEdgeToNextVertex().value()); - EXPECT_EQ("E", path[3].GetName()); - EXPECT_FALSE(path[3].GetTypeOfEdgeToNextVertex().has_value()); - } -} -} -} - -#endif diff --git a/src/tests/api/internals/sorting/plugin_graph_test.h b/src/tests/api/internals/sorting/plugin_graph_test.h deleted file mode 100644 index 26852d56..00000000 --- a/src/tests/api/internals/sorting/plugin_graph_test.h +++ /dev/null @@ -1,1842 +0,0 @@ -/* LOOT - -A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and -Fallout: New Vegas. - -Copyright (C) 2014-2016 WrinklyNinja - -This file is part of LOOT. - -LOOT is free software: you can redistribute -it and/or modify it under the terms of the GNU General Public License -as published by the Free Software Foundation, either version 3 of -the License, or (at your option) any later version. - -LOOT is distributed in the hope that it will -be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with LOOT. If not, see -. -*/ - -#ifndef LOOT_TESTS_API_INTERNALS_SORTING_PLUGIN_GRAPH_TEST -#define LOOT_TESTS_API_INTERNALS_SORTING_PLUGIN_GRAPH_TEST - -#include - -#include "api/sorting/group_sort.h" -#include "api/sorting/plugin_graph.h" -#include "loot/exception/cyclic_interaction_error.h" -#include "tests/api/internals/plugin_test.h" - -namespace loot { -namespace test { - -class PluginGraphTest : public ::testing::Test { -protected: - PluginGraphTest() { - std::vector masterlistGroups{Group("A"), - Group("B", {"A"}), - Group("C"), - Group("default", {"C"}), - Group("E", {"default"}), - Group("F", {"E"})}; - std::vector userlistGroups{Group("C", {"B"})}; - - groupGraph = BuildGroupGraph(masterlistGroups, userlistGroups); - } - - PluginSortingData CreatePluginSortingData(const std::string& name) { - const auto plugin = GetPlugin(name); - - return PluginSortingData(plugin, PluginMetadata(), PluginMetadata(), {}); - } - - PluginSortingData CreatePluginSortingData(const std::string& name, - const std::string& group, - bool isGroupUserMetadata = false) { - const auto plugin = GetPlugin(name); - - PluginMetadata masterlistMetadata; - PluginMetadata userMetadata; - - if (isGroupUserMetadata) { - userMetadata.SetGroup(group); - } else { - masterlistMetadata.SetGroup(group); - } - - return PluginSortingData(plugin, masterlistMetadata, userMetadata, {}); - } - - TestPlugin* GetPlugin(const std::string& name) { - auto it = plugins.find(name); - - if (it != plugins.end()) { - return it->second.get(); - } - - const auto plugin = std::make_shared(name); - - return plugins.insert_or_assign(name, plugin).first->second.get(); - } - - GroupGraph groupGraph; - -private: - std::map> plugins; -}; - -TEST_F(PluginGraphTest, checkForCyclesShouldNotThrowIfThereIsNoCycle) { - PluginGraph graph; - - const auto a = graph.AddVertex(CreatePluginSortingData("A.esp")); - const auto b = graph.AddVertex(CreatePluginSortingData("B.esp")); - - graph.AddEdge(a, b, EdgeType::master); - - EXPECT_NO_THROW(graph.CheckForCycles()); -} - -TEST_F(PluginGraphTest, checkForCyclesShouldThrowIfThereIsACycle) { - PluginGraph graph; - - const auto a = graph.AddVertex(CreatePluginSortingData("A.esp")); - const auto b = graph.AddVertex(CreatePluginSortingData("B.esp")); - - graph.AddEdge(a, b, EdgeType::master); - graph.AddEdge(b, a, EdgeType::masterFlag); - - try { - graph.CheckForCycles(); - FAIL(); - } catch (const CyclicInteractionError& e) { - ASSERT_EQ(2, e.GetCycle().size()); - EXPECT_EQ("A.esp", e.GetCycle()[0].GetName()); - EXPECT_EQ(EdgeType::master, e.GetCycle()[0].GetTypeOfEdgeToNextVertex()); - EXPECT_EQ("B.esp", e.GetCycle()[1].GetName()); - EXPECT_EQ(EdgeType::masterFlag, - e.GetCycle()[1].GetTypeOfEdgeToNextVertex()); - } -} - -TEST_F(PluginGraphTest, - checkForCyclesShouldOnlyRecordPluginsThatArePartOfTheCycle) { - PluginGraph graph; - - const auto a = graph.AddVertex(CreatePluginSortingData("A.esp")); - const auto b = graph.AddVertex(CreatePluginSortingData("B.esp")); - const auto c = graph.AddVertex(CreatePluginSortingData("C.esp")); - - graph.AddEdge(a, b, EdgeType::master); - graph.AddEdge(b, c, EdgeType::master); - graph.AddEdge(b, a, EdgeType::masterFlag); - - try { - graph.CheckForCycles(); - FAIL(); - } catch (const CyclicInteractionError& e) { - ASSERT_EQ(2, e.GetCycle().size()); - EXPECT_EQ("A.esp", e.GetCycle()[0].GetName()); - EXPECT_EQ(EdgeType::master, e.GetCycle()[0].GetTypeOfEdgeToNextVertex()); - EXPECT_EQ("B.esp", e.GetCycle()[1].GetName()); - EXPECT_EQ(EdgeType::masterFlag, - e.GetCycle()[1].GetTypeOfEdgeToNextVertex()); - } -} - -TEST_F(PluginGraphTest, - topologicalSortWithNoLoadedPluginsShouldReturnAnEmptyList) { - PluginGraph graph; - std::vector sorted = graph.TopologicalSort(); - - EXPECT_TRUE(sorted.empty()); -} - -TEST_F( - PluginGraphTest, - addHardcodedPluginEdgesShouldNotThrowIfThereAreNoVerticesOrHardcodedPlugins) { - PluginGraph graph; - - EXPECT_NO_THROW(graph.AddHardcodedPluginEdges({})); -} - -TEST_F(PluginGraphTest, - addHardcodedPluginEdgesShouldNotThrowIfThereAreNoVertices) { - PluginGraph graph; - - const std::vector hardcodedPlugins{ - "1.esp", "2.esp", "3.esp", "4.esp"}; - - EXPECT_NO_THROW(graph.AddHardcodedPluginEdges(hardcodedPlugins)); -} - -TEST_F(PluginGraphTest, - addHardcodedPluginEdgesShouldAddNoEdgesIfThereAreNoHardcodedPlugins) { - PluginGraph graph; - - const auto v1 = graph.AddVertex(CreatePluginSortingData("1.esp")); - const auto v3 = graph.AddVertex(CreatePluginSortingData("3.esp")); - const auto v4 = graph.AddVertex(CreatePluginSortingData("4.esp")); - - graph.AddHardcodedPluginEdges({}); - - EXPECT_FALSE(graph.EdgeExists(v1, v3)); - EXPECT_FALSE(graph.EdgeExists(v1, v4)); - EXPECT_FALSE(graph.EdgeExists(v3, v1)); - EXPECT_FALSE(graph.EdgeExists(v3, v4)); - EXPECT_FALSE(graph.EdgeExists(v4, v1)); - EXPECT_FALSE(graph.EdgeExists(v4, v3)); - - EXPECT_NO_THROW(graph.CheckForCycles()); -} - -TEST_F(PluginGraphTest, - addHardcodedPluginsShouldNotThrowIfTheOnlyVertexIsAHardcodedPlugin) { - PluginGraph graph; - - const auto v1 = graph.AddVertex(CreatePluginSortingData("1.esp")); - - const std::vector hardcodedPlugins{ - graph.GetPlugin(v1).GetName()}; - - EXPECT_NO_THROW(graph.AddHardcodedPluginEdges(hardcodedPlugins)); -} - -TEST_F( - PluginGraphTest, - addHardcodedPluginEdgesShouldAddEdgesBetweenConsecutiveHardcodedPluginsSkippingMissingPlugins) { - PluginGraph graph; - - const auto v1 = graph.AddVertex(CreatePluginSortingData("1.esp")); - const auto v3 = graph.AddVertex(CreatePluginSortingData("3.esp")); - const auto v4 = graph.AddVertex(CreatePluginSortingData("4.esp")); - - const std::vector hardcodedPlugins{ - graph.GetPlugin(v1).GetName(), - "2.esp", - graph.GetPlugin(v3).GetName(), - graph.GetPlugin(v4).GetName()}; - - graph.AddHardcodedPluginEdges(hardcodedPlugins); - - EXPECT_TRUE(graph.EdgeExists(v1, v3)); - EXPECT_TRUE(graph.EdgeExists(v3, v4)); - EXPECT_FALSE(graph.EdgeExists(v1, v4)); - - EXPECT_NO_THROW(graph.CheckForCycles()); -} - -TEST_F( - PluginGraphTest, - addHardcodedPluginEdgesShouldAddEdgesFromOnlyTheLastInstalledHardcodedPluginToAllNonHardcodedPlugins) { - PluginGraph graph; - - const auto v1 = graph.AddVertex(CreatePluginSortingData("1.esp")); - const auto v2 = graph.AddVertex(CreatePluginSortingData("2.esp")); - const auto v4 = graph.AddVertex(CreatePluginSortingData("4.esp")); - const auto v5 = graph.AddVertex(CreatePluginSortingData("5.esp")); - - const std::vector hardcodedPlugins{ - graph.GetPlugin(v1).GetName(), graph.GetPlugin(v2).GetName(), "3.esp"}; - - graph.AddHardcodedPluginEdges(hardcodedPlugins); - - EXPECT_TRUE(graph.EdgeExists(v1, v2)); - EXPECT_TRUE(graph.EdgeExists(v2, v4)); - EXPECT_TRUE(graph.EdgeExists(v2, v5)); - EXPECT_FALSE(graph.EdgeExists(v1, v4)); - EXPECT_FALSE(graph.EdgeExists(v1, v5)); - - EXPECT_NO_THROW(graph.CheckForCycles()); -} - -TEST_F( - PluginGraphTest, - addGroupEdgesShouldAddUserGroupEdgeIfSourcePluginIsInGroupDueToUserMetadata) { - PluginGraph graph; - - const auto a = graph.AddVertex(CreatePluginSortingData("A.esp", "A", true)); - const auto b = graph.AddVertex(CreatePluginSortingData("B.esp", "B")); - - graph.AddGroupEdges(groupGraph); - - // Cause a cycle to see the edge types. - graph.AddEdge(b, a, EdgeType::master); - - try { - graph.CheckForCycles(); - FAIL(); - } catch (const CyclicInteractionError& e) { - ASSERT_EQ(2, e.GetCycle().size()); - EXPECT_EQ("A.esp", e.GetCycle()[0].GetName()); - EXPECT_EQ(EdgeType::userGroup, e.GetCycle()[0].GetTypeOfEdgeToNextVertex()); - EXPECT_EQ("B.esp", e.GetCycle()[1].GetName()); - EXPECT_EQ(EdgeType::master, e.GetCycle()[1].GetTypeOfEdgeToNextVertex()); - } -} - -TEST_F( - PluginGraphTest, - addGroupEdgesShouldAddUserGroupEdgeIfTargetPluginIsInGroupDueToUserMetadata) { - PluginGraph graph; - - const auto a = graph.AddVertex(CreatePluginSortingData("A.esp", "A")); - const auto b = graph.AddVertex(CreatePluginSortingData("B.esp", "B", true)); - - graph.AddGroupEdges(groupGraph); - - // Cause a cycle to see the edge types. - graph.AddEdge(b, a, EdgeType::master); - - try { - graph.CheckForCycles(); - FAIL(); - } catch (const CyclicInteractionError& e) { - ASSERT_EQ(2, e.GetCycle().size()); - EXPECT_EQ("A.esp", e.GetCycle()[0].GetName()); - EXPECT_EQ(EdgeType::userGroup, e.GetCycle()[0].GetTypeOfEdgeToNextVertex()); - EXPECT_EQ("B.esp", e.GetCycle()[1].GetName()); - EXPECT_EQ(EdgeType::master, e.GetCycle()[1].GetTypeOfEdgeToNextVertex()); - } -} - -TEST_F(PluginGraphTest, - addGroupEdgesShouldAddUserGroupEdgeIfGroupPathStartsWithUserMetadata) { - PluginGraph graph; - - const auto b = graph.AddVertex(CreatePluginSortingData("B.esp", "B")); - const auto d = graph.AddVertex(CreatePluginSortingData("D.esp")); - - graph.AddGroupEdges(groupGraph); - - // Cause a cycle to see the edge types. - graph.AddEdge(d, b, EdgeType::master); - - try { - graph.CheckForCycles(); - FAIL(); - } catch (const CyclicInteractionError& e) { - ASSERT_EQ(2, e.GetCycle().size()); - EXPECT_EQ("B.esp", e.GetCycle()[0].GetName()); - EXPECT_EQ(EdgeType::userGroup, e.GetCycle()[0].GetTypeOfEdgeToNextVertex()); - EXPECT_EQ("D.esp", e.GetCycle()[1].GetName()); - EXPECT_EQ(EdgeType::master, e.GetCycle()[1].GetTypeOfEdgeToNextVertex()); - } -} - -TEST_F(PluginGraphTest, - addGroupEdgesShouldAddUserGroupEdgeIfGroupPathEndsWithUserMetadata) { - PluginGraph graph; - - const auto a = graph.AddVertex(CreatePluginSortingData("A.esp", "A")); - const auto c = graph.AddVertex(CreatePluginSortingData("C.esp", "C")); - - graph.AddGroupEdges(groupGraph); - - // Cause a cycle to see the edge types. - graph.AddEdge(c, a, EdgeType::master); - - try { - graph.CheckForCycles(); - FAIL(); - } catch (const CyclicInteractionError& e) { - ASSERT_EQ(2, e.GetCycle().size()); - EXPECT_EQ("A.esp", e.GetCycle()[0].GetName()); - EXPECT_EQ(EdgeType::userGroup, e.GetCycle()[0].GetTypeOfEdgeToNextVertex()); - EXPECT_EQ("C.esp", e.GetCycle()[1].GetName()); - EXPECT_EQ(EdgeType::master, e.GetCycle()[1].GetTypeOfEdgeToNextVertex()); - } -} - -TEST_F(PluginGraphTest, - addGroupEdgesShouldAddUserGroupEdgeIfGroupPathInvolvesUserMetadata) { - PluginGraph graph; - - const auto a = graph.AddVertex(CreatePluginSortingData("A.esp", "A")); - const auto d = graph.AddVertex(CreatePluginSortingData("D.esp")); - - graph.AddGroupEdges(groupGraph); - - // Cause a cycle to see the edge types. - graph.AddEdge(d, a, EdgeType::master); - - try { - graph.CheckForCycles(); - FAIL(); - } catch (const CyclicInteractionError& e) { - ASSERT_EQ(2, e.GetCycle().size()); - EXPECT_EQ("A.esp", e.GetCycle()[0].GetName()); - EXPECT_EQ(EdgeType::userGroup, e.GetCycle()[0].GetTypeOfEdgeToNextVertex()); - EXPECT_EQ("D.esp", e.GetCycle()[1].GetName()); - EXPECT_EQ(EdgeType::master, e.GetCycle()[1].GetTypeOfEdgeToNextVertex()); - } -} - -TEST_F(PluginGraphTest, - addGroupEdgesShouldAddMasterlistGroupEdgeIfNoUserMetadataIsInvolved) { - PluginGraph graph; - - const auto a = graph.AddVertex(CreatePluginSortingData("A.esp", "A")); - const auto b = graph.AddVertex(CreatePluginSortingData("B.esp", "B")); - - graph.AddGroupEdges(groupGraph); - - // Cause a cycle to see the edge types. - graph.AddEdge(b, a, EdgeType::master); - - try { - graph.CheckForCycles(); - FAIL(); - } catch (const CyclicInteractionError& e) { - ASSERT_EQ(2, e.GetCycle().size()); - EXPECT_EQ("A.esp", e.GetCycle()[0].GetName()); - EXPECT_EQ(EdgeType::masterlistGroup, - e.GetCycle()[0].GetTypeOfEdgeToNextVertex()); - EXPECT_EQ("B.esp", e.GetCycle()[1].GetName()); - EXPECT_EQ(EdgeType::master, e.GetCycle()[1].GetTypeOfEdgeToNextVertex()); - } -} - -TEST_F( - PluginGraphTest, - addGroupEdgesShouldAddEdgesBetweenPluginsInIndirectlyConnectedGroups_whenAnIntermediatePluginEdgeIsSkipped) { - PluginGraph graph; - - const auto a1 = graph.AddVertex(CreatePluginSortingData("A1.esp", "A")); - const auto a2 = graph.AddVertex(CreatePluginSortingData("A2.esp", "A")); - const auto b1 = graph.AddVertex(CreatePluginSortingData("B1.esp", "B")); - const auto b2 = graph.AddVertex(CreatePluginSortingData("B2.esp", "B")); - const auto c1 = graph.AddVertex(CreatePluginSortingData("C1.esp", "C")); - const auto c2 = graph.AddVertex(CreatePluginSortingData("C2.esp", "C")); - - graph.AddEdge(b1, a1, EdgeType::master); - - graph.AddGroupEdges(groupGraph); - - // Should be A2.esp -> B1.esp -> A1.esp -> B2.esp -> C1.esp - // -> C2.esp - EXPECT_TRUE(graph.EdgeExists(b1, a1)); - EXPECT_TRUE(graph.EdgeExists(a1, b2)); - EXPECT_TRUE(graph.EdgeExists(a2, b1)); - EXPECT_TRUE(graph.EdgeExists(a2, b2)); - EXPECT_TRUE(graph.EdgeExists(b1, c1)); - EXPECT_TRUE(graph.EdgeExists(b1, c2)); - EXPECT_TRUE(graph.EdgeExists(b2, c1)); - EXPECT_TRUE(graph.EdgeExists(b2, c2)); - EXPECT_TRUE(graph.EdgeExists(a1, c1)); - EXPECT_TRUE(graph.EdgeExists(a1, c2)); - EXPECT_FALSE(graph.EdgeExists(c1, c2)); - EXPECT_FALSE(graph.EdgeExists(c2, c1)); - - EXPECT_NO_THROW(graph.CheckForCycles()); -} - -TEST_F(PluginGraphTest, addGroupEdgesShouldAddEdgesAcrossEmptyGroups) { - PluginGraph graph; - - const auto a = graph.AddVertex(CreatePluginSortingData("A.esp", "A")); - const auto c = graph.AddVertex(CreatePluginSortingData("C.esp", "C")); - - graph.AddGroupEdges(groupGraph); - - // Should be A.esp -> C.esp - EXPECT_TRUE(graph.EdgeExists(a, c)); - - EXPECT_NO_THROW(graph.CheckForCycles()); -} - -TEST_F(PluginGraphTest, - addGroupEdgesShouldAddEdgesAcrossTheNonEmptyDefaultGroup) { - PluginGraph graph; - - const auto a = graph.AddVertex(CreatePluginSortingData("A.esp", "A")); - const auto d = graph.AddVertex(CreatePluginSortingData("D.esp")); - const auto e = graph.AddVertex(CreatePluginSortingData("E.esp", "E")); - - graph.AddGroupEdges(groupGraph); - - // Should be A.esp -> D.esp -> E.esp - // ----------> - EXPECT_TRUE(graph.EdgeExists(a, d)); - EXPECT_TRUE(graph.EdgeExists(d, e)); - EXPECT_TRUE(graph.EdgeExists(a, e)); - - EXPECT_NO_THROW(graph.CheckForCycles()); -} - -TEST_F(PluginGraphTest, addGroupEdgesShouldSkipAnEdgeThatWouldCauseACycle) { - PluginGraph graph; - - const auto a = graph.AddVertex(CreatePluginSortingData("A.esp", "A")); - const auto c = graph.AddVertex(CreatePluginSortingData("C.esp", "C")); - - graph.AddEdge(c, a, EdgeType::master); - - graph.AddGroupEdges(groupGraph); - - // Should be C.esp -> A.esp - EXPECT_TRUE(graph.EdgeExists(c, a)); - - EXPECT_NO_THROW(graph.CheckForCycles()); -} - -TEST_F( - PluginGraphTest, - addGroupEdgesShouldSkipAnEdgeThatWouldCauseACycleInvolvingOtherNonDefaultGroups) { - PluginGraph graph; - - const auto a = graph.AddVertex(CreatePluginSortingData("A.esp", "A")); - const auto b = graph.AddVertex(CreatePluginSortingData("B.esp", "B")); - const auto c = graph.AddVertex(CreatePluginSortingData("C.esp", "C")); - - graph.AddEdge(c, a, EdgeType::master); - - graph.AddGroupEdges(groupGraph); - - // Should be C.esp -> A.esp -> B.esp - EXPECT_TRUE(graph.EdgeExists(c, a)); - EXPECT_TRUE(graph.EdgeExists(a, b)); - - EXPECT_NO_THROW(graph.CheckForCycles()); -} - -TEST_F( - PluginGraphTest, - addGroupEdgesShouldSkipOnlyEdgesToTheTargetGroupPluginsThatWouldCauseACycle) { - PluginGraph graph; - - const auto a = graph.AddVertex(CreatePluginSortingData("A.esp", "A")); - const auto c1 = graph.AddVertex(CreatePluginSortingData("C1.esp", "C")); - const auto c2 = graph.AddVertex(CreatePluginSortingData("C2.esp", "C")); - - graph.AddEdge(c1, a, EdgeType::master); - - graph.AddGroupEdges(groupGraph); - - // Should be C1.esp -> A.esp -> C2.esp - EXPECT_TRUE(graph.EdgeExists(c1, a)); - EXPECT_TRUE(graph.EdgeExists(a, c2)); - - EXPECT_NO_THROW(graph.CheckForCycles()); -} - -TEST_F( - PluginGraphTest, - addGroupEdgesShouldSkipOnlyEdgesFromAncestorsToTheTargetGroupPluginsThatWouldCauseACycle) { - PluginGraph graph; - - const auto b = graph.AddVertex(CreatePluginSortingData("B.esp", "B")); - const auto c = graph.AddVertex(CreatePluginSortingData("C.esp", "C")); - const auto d1 = graph.AddVertex(CreatePluginSortingData("D1.esp")); - const auto d2 = graph.AddVertex(CreatePluginSortingData("D2.esp")); - const auto d3 = graph.AddVertex(CreatePluginSortingData("D3.esp")); - - graph.AddEdge(d1, b, EdgeType::masterFlag); - graph.AddEdge(d2, b, EdgeType::masterFlag); - graph.AddEdge(c, b, EdgeType::masterFlag); - graph.AddEdge(c, d2, EdgeType::master); - graph.AddEdge(c, d3, EdgeType::masterFlag); - - graph.AddGroupEdges(groupGraph); - - // Should be: C.esp -> D2.esp -> B.esp -> D3.esp - // -> D1.esp -> - // --------------------> - // -----------> - EXPECT_TRUE(graph.EdgeExists(d1, b)); - EXPECT_TRUE(graph.EdgeExists(d2, b)); - EXPECT_TRUE(graph.EdgeExists(c, b)); - EXPECT_TRUE(graph.EdgeExists(c, d2)); - EXPECT_TRUE(graph.EdgeExists(c, d3)); - - EXPECT_TRUE(graph.EdgeExists(b, d3)); - EXPECT_TRUE(graph.EdgeExists(c, d1)); - - EXPECT_NO_THROW(graph.CheckForCycles()); -} - -TEST_F( - PluginGraphTest, - addGroupEdgesShouldAddAPluginsEdgesAcrossASuccessorIfAtLeastOneEdgeToTheSuccessorGroupWasSkipped_successiveDepths) { - PluginGraph graph; - - const auto a1 = graph.AddVertex(CreatePluginSortingData("A1.esp", "A")); - const auto a2 = graph.AddVertex(CreatePluginSortingData("A2.esp", "A")); - const auto b1 = graph.AddVertex(CreatePluginSortingData("B1.esp", "B")); - const auto b2 = graph.AddVertex(CreatePluginSortingData("B2.esp", "B")); - const auto c1 = graph.AddVertex(CreatePluginSortingData("C1.esp", "C")); - const auto c2 = graph.AddVertex(CreatePluginSortingData("C2.esp", "C")); - - graph.AddEdge(b1, a1, EdgeType::master); - graph.AddEdge(c1, b2, EdgeType::master); - - graph.AddGroupEdges(groupGraph); - - // Should be A2.esp -> B1.esp -> A1.esp -> C1.esp -> B2.esp -> C2.esp - EXPECT_TRUE(graph.EdgeExists(b1, a1)); - EXPECT_TRUE(graph.EdgeExists(c1, b2)); - EXPECT_TRUE(graph.EdgeExists(a1, b2)); - EXPECT_TRUE(graph.EdgeExists(a1, c1)); - EXPECT_TRUE(graph.EdgeExists(a1, c2)); - EXPECT_TRUE(graph.EdgeExists(a2, b1)); - EXPECT_TRUE(graph.EdgeExists(a2, b2)); - EXPECT_TRUE(graph.EdgeExists(b1, c1)); - EXPECT_TRUE(graph.EdgeExists(b1, c2)); - EXPECT_TRUE(graph.EdgeExists(b2, c2)); - - EXPECT_NO_THROW(graph.CheckForCycles()); -} - -TEST_F( - PluginGraphTest, - addGroupEdgesShouldAddAPluginsEdgesAcrossASuccessorIfAtLeastOneEdgeToTheSuccessorGroupWasSkipped_successiveDepthsDifferentOrder) { - PluginGraph graph; - - const auto a1 = graph.AddVertex(CreatePluginSortingData("A1.esp", "A")); - const auto a2 = graph.AddVertex(CreatePluginSortingData("A2.esp", "A")); - const auto b1 = graph.AddVertex(CreatePluginSortingData("B1.esp", "B")); - const auto b2 = graph.AddVertex(CreatePluginSortingData("B2.esp", "B")); - const auto c1 = graph.AddVertex(CreatePluginSortingData("C1.esp", "C")); - const auto c2 = graph.AddVertex(CreatePluginSortingData("C2.esp", "C")); - - graph.AddEdge(b1, a1, EdgeType::master); - graph.AddEdge(c1, b1, EdgeType::master); - - graph.AddGroupEdges(groupGraph); - - // Should be A2.esp -> C1.esp -> B1.esp -> A1.esp -> B2.esp -> C2.esp - EXPECT_TRUE(graph.EdgeExists(b1, a1)); - EXPECT_TRUE(graph.EdgeExists(c1, b1)); - EXPECT_TRUE(graph.EdgeExists(a1, b2)); - EXPECT_TRUE(graph.EdgeExists(a2, b1)); - EXPECT_TRUE(graph.EdgeExists(a2, b2)); - EXPECT_TRUE(graph.EdgeExists(a1, c2)); - EXPECT_TRUE(graph.EdgeExists(b1, c2)); - EXPECT_TRUE(graph.EdgeExists(b2, c2)); - EXPECT_TRUE(graph.EdgeExists(a2, c1)); - EXPECT_FALSE(graph.EdgeExists(b2, c1)); - - EXPECT_NO_THROW(graph.CheckForCycles()); -} - -TEST_F( - PluginGraphTest, - addGroupEdgesShouldAddEdgeFromAncestorToSuccessorIfNoneOfAGroupsPluginsCan_simple) { - PluginGraph graph; - - const auto a = graph.AddVertex(CreatePluginSortingData("A.esp", "A")); - const auto b1 = graph.AddVertex(CreatePluginSortingData("B1.esp", "B")); - const auto b2 = graph.AddVertex(CreatePluginSortingData("B2.esp", "B")); - const auto c = graph.AddVertex(CreatePluginSortingData("C.esp", "C")); - - graph.AddEdge(c, b1, EdgeType::master); - graph.AddEdge(c, b2, EdgeType::master); - - graph.AddGroupEdges(groupGraph); - - // Should be A.esp -> C1.esp -> B1.esp - // -> B2.esp - EXPECT_TRUE(graph.EdgeExists(a, b1)); - EXPECT_TRUE(graph.EdgeExists(a, b2)); - EXPECT_TRUE(graph.EdgeExists(c, b1)); - EXPECT_TRUE(graph.EdgeExists(c, b2)); - EXPECT_TRUE(graph.EdgeExists(a, c)); - EXPECT_FALSE(graph.EdgeExists(b1, b2)); - EXPECT_FALSE(graph.EdgeExists(b2, b1)); - - EXPECT_NO_THROW(graph.CheckForCycles()); -} - -TEST_F( - PluginGraphTest, - addGroupEdgesShouldAddEdgeFromAncestorToSuccessorIfNoneOfAGroupsPluginsCan_withEdgesAcrossedTheSkippedGroup) { - PluginGraph graph; - - const auto a1 = graph.AddVertex(CreatePluginSortingData("A1.esp", "A")); - const auto a2 = graph.AddVertex(CreatePluginSortingData("A2.esp", "A")); - const auto b1 = graph.AddVertex(CreatePluginSortingData("B1.esp", "B")); - const auto b2 = graph.AddVertex(CreatePluginSortingData("B2.esp", "B")); - const auto c1 = graph.AddVertex(CreatePluginSortingData("C1.esp", "C")); - const auto c2 = graph.AddVertex(CreatePluginSortingData("C2.esp", "C")); - const auto d1 = graph.AddVertex(CreatePluginSortingData("D1.esp")); - const auto d2 = graph.AddVertex(CreatePluginSortingData("D2.esp")); - - graph.AddEdge(b1, a1, EdgeType::master); - graph.AddEdge(c1, b1, EdgeType::master); - graph.AddEdge(d1, c1, EdgeType::master); - graph.AddEdge(d2, c1, EdgeType::master); - - graph.AddGroupEdges(groupGraph); - - // Should be: - // A2.esp -> D1.esp -> C1.esp -> B1.esp -> A1.esp -> B2.esp -> C2.esp - // -> D2.esp -> - EXPECT_TRUE(graph.EdgeExists(b1, a1)); - EXPECT_TRUE(graph.EdgeExists(c1, b1)); - EXPECT_TRUE(graph.EdgeExists(d1, c1)); - EXPECT_TRUE(graph.EdgeExists(d2, c1)); - EXPECT_TRUE(graph.EdgeExists(a1, b2)); - EXPECT_TRUE(graph.EdgeExists(a2, b1)); - EXPECT_TRUE(graph.EdgeExists(a2, b2)); - EXPECT_TRUE(graph.EdgeExists(a1, c2)); - EXPECT_TRUE(graph.EdgeExists(b1, c2)); - EXPECT_TRUE(graph.EdgeExists(a2, c1)); - EXPECT_TRUE(graph.EdgeExists(a2, d1)); - EXPECT_TRUE(graph.EdgeExists(a2, d2)); - EXPECT_FALSE(graph.EdgeExists(b2, c1)); - EXPECT_FALSE(graph.EdgeExists(d1, d2)); - EXPECT_FALSE(graph.EdgeExists(d2, d1)); - - EXPECT_NO_THROW(graph.CheckForCycles()); -} - -TEST_F( - PluginGraphTest, - addGroupEdgesShouldDeprioritiseEdgesFromDefaultGroupPlugins_defaultLast) { - PluginGraph graph; - - const auto b = graph.AddVertex(CreatePluginSortingData("B.esp", "B")); - const auto c = graph.AddVertex(CreatePluginSortingData("C.esp", "C")); - const auto d = graph.AddVertex(CreatePluginSortingData("D.esp")); - - graph.AddEdge(d, b, EdgeType::master); - - graph.AddGroupEdges(groupGraph); - - // Should be D.esp -> B.esp -> C.esp - EXPECT_TRUE(graph.EdgeExists(b, c)); - EXPECT_TRUE(graph.EdgeExists(d, b)); - EXPECT_FALSE(graph.EdgeExists(c, d)); - - EXPECT_NO_THROW(graph.CheckForCycles()); -} - -TEST_F( - PluginGraphTest, - addGroupEdgesShouldDeprioritiseEdgesFromDefaultGroupPlugins_defaultFirst) { - PluginGraph graph; - - const auto d = graph.AddVertex(CreatePluginSortingData("D.esp")); - const auto e = graph.AddVertex(CreatePluginSortingData("E.esp", "E")); - const auto f = graph.AddVertex(CreatePluginSortingData("F.esp", "F")); - - graph.AddEdge(f, d, EdgeType::master); - - graph.AddGroupEdges(groupGraph); - - // Should be E.esp -> F.esp -> D.esp - EXPECT_TRUE(graph.EdgeExists(e, f)); - EXPECT_TRUE(graph.EdgeExists(f, d)); - EXPECT_FALSE(graph.EdgeExists(d, e)); - - EXPECT_NO_THROW(graph.CheckForCycles()); -} - -TEST_F( - PluginGraphTest, - addGroupEdgesShouldDeprioritiseEdgesFromDefaultGroupPlugins_acrossSkippedIntermediateGroups) { - PluginGraph graph; - - const auto d = graph.AddVertex(CreatePluginSortingData("D.esp")); - const auto e = graph.AddVertex(CreatePluginSortingData("E.esp", "E")); - const auto f = graph.AddVertex(CreatePluginSortingData("F.esp", "F")); - - graph.AddEdge(e, d, EdgeType::master); - - graph.AddGroupEdges(groupGraph); - - // Should be E.esp -> D.esp -> F.esp - EXPECT_TRUE(graph.EdgeExists(e, d)); - EXPECT_TRUE(graph.EdgeExists(d, f)); - EXPECT_FALSE(graph.EdgeExists(f, e)); - - EXPECT_NO_THROW(graph.CheckForCycles()); -} - -TEST_F( - PluginGraphTest, - addGroupEdgesShouldDeprioritiseEdgesFromDefaultGroupPlugins_d1Firstd2Last) { - PluginGraph graph; - - const auto d1 = graph.AddVertex(CreatePluginSortingData("D1.esp")); - const auto d2 = graph.AddVertex(CreatePluginSortingData("D2.esp")); - const auto e = graph.AddVertex(CreatePluginSortingData("E.esp", "E")); - const auto f = graph.AddVertex(CreatePluginSortingData("F.esp", "F")); - - graph.AddEdge(f, d2, EdgeType::master); - - graph.AddGroupEdges(groupGraph); - - // Should be D1.esp -> E.esp -> F.esp -> D2.esp - EXPECT_TRUE(graph.EdgeExists(e, f)); - EXPECT_TRUE(graph.EdgeExists(f, d2)); - EXPECT_TRUE(graph.EdgeExists(d1, e)); - EXPECT_FALSE(graph.EdgeExists(d2, d1)); - - EXPECT_NO_THROW(graph.CheckForCycles()); -} - -TEST_F( - PluginGraphTest, - addGroupEdgesShouldDeprioritiseEdgesFromDefaultGroupPlugins_noIdealResult) { - PluginGraph graph; - - const auto b = graph.AddVertex(CreatePluginSortingData("B.esp", "B")); - const auto c = graph.AddVertex(CreatePluginSortingData("C.esp", "C")); - const auto d = graph.AddVertex(CreatePluginSortingData("D.esp")); - const auto e = graph.AddVertex(CreatePluginSortingData("E.esp", "E")); - const auto f = graph.AddVertex(CreatePluginSortingData("F.esp", "F")); - - graph.AddEdge(d, b, EdgeType::master); - graph.AddEdge(f, d, EdgeType::master); - - graph.AddGroupEdges(groupGraph); - - // No ideal result, expected is F.esp -> D.esp -> B.esp -> C.esp -> E.esp - EXPECT_TRUE(graph.EdgeExists(f, d)); - EXPECT_TRUE(graph.EdgeExists(d, b)); - EXPECT_TRUE(graph.EdgeExists(b, c)); - EXPECT_TRUE(graph.EdgeExists(c, e)); - EXPECT_FALSE(graph.EdgeExists(e, f)); - - EXPECT_NO_THROW(graph.CheckForCycles()); -} - -TEST_F( - PluginGraphTest, - addGroupEdgesShouldDeprioritiseEdgesFromDefaultGroupPlugins_defaultInMiddleDBookends) { - PluginGraph graph; - - const auto b = graph.AddVertex(CreatePluginSortingData("B.esp", "B")); - const auto c = graph.AddVertex(CreatePluginSortingData("C.esp", "C")); - const auto d1 = graph.AddVertex(CreatePluginSortingData("D1.esp")); - const auto d2 = graph.AddVertex(CreatePluginSortingData("D2.esp")); - const auto e = graph.AddVertex(CreatePluginSortingData("E.esp", "E")); - const auto f = graph.AddVertex(CreatePluginSortingData("F.esp", "F")); - - graph.AddEdge(d2, b, EdgeType::master); - graph.AddEdge(f, d1, EdgeType::master); - - graph.AddGroupEdges(groupGraph); - - // Should be D2.esp -> B.esp -> C.esp -> E.esp -> F.esp -> D1.esp - EXPECT_TRUE(graph.EdgeExists(d2, b)); - EXPECT_TRUE(graph.EdgeExists(b, c)); - EXPECT_TRUE(graph.EdgeExists(c, e)); - EXPECT_TRUE(graph.EdgeExists(e, f)); - EXPECT_TRUE(graph.EdgeExists(f, d1)); - EXPECT_FALSE(graph.EdgeExists(d1, d2)); - - EXPECT_NO_THROW(graph.CheckForCycles()); -} - -TEST_F( - PluginGraphTest, - addGroupEdgesShouldDeprioritiseEdgesFromDefaultGroupPlugins_defaultInMiddleDThroughout) { - PluginGraph graph; - - const auto b = graph.AddVertex(CreatePluginSortingData("B.esp", "B")); - const auto c = graph.AddVertex(CreatePluginSortingData("C.esp", "C")); - const auto d1 = graph.AddVertex(CreatePluginSortingData("D1.esp")); - const auto d2 = graph.AddVertex(CreatePluginSortingData("D2.esp")); - const auto d3 = graph.AddVertex(CreatePluginSortingData("D3.esp")); - const auto d4 = graph.AddVertex(CreatePluginSortingData("D4.esp")); - const auto e = graph.AddVertex(CreatePluginSortingData("E.esp", "E")); - const auto f = graph.AddVertex(CreatePluginSortingData("F.esp", "F")); - - graph.AddEdge(d2, b, EdgeType::master); - graph.AddEdge(d4, c, EdgeType::master); - graph.AddEdge(f, d1, EdgeType::master); - - graph.AddGroupEdges(groupGraph); - - // Should be: - // D2.esp -> B.esp -> D4.esp -> C.esp -> D3.esp -> E.esp -> F.esp -> D1.esp - EXPECT_TRUE(graph.EdgeExists(d2, b)); - EXPECT_TRUE(graph.EdgeExists(b, c)); - EXPECT_TRUE(graph.EdgeExists(c, d3)); - EXPECT_TRUE(graph.EdgeExists(c, e)); - EXPECT_TRUE(graph.EdgeExists(d3, e)); - EXPECT_TRUE(graph.EdgeExists(e, f)); - EXPECT_TRUE(graph.EdgeExists(f, d1)); - EXPECT_TRUE(graph.EdgeExists(d4, c)); - EXPECT_TRUE(graph.EdgeExists(b, d4)); - EXPECT_FALSE(graph.EdgeExists(d1, d2)); - EXPECT_FALSE(graph.EdgeExists(d1, d3)); - EXPECT_FALSE(graph.EdgeExists(d1, d4)); - - EXPECT_NO_THROW(graph.CheckForCycles()); -} - -TEST_F(PluginGraphTest, - addGroupEdgesShouldHandleAsymmetricBranchesInTheGroupsGraph) { - std::vector masterlistGroups{Group("A"), - Group("B", {"A"}), - Group("C", {"B"}), - Group("D", {"A"}), - Group()}; - - groupGraph = BuildGroupGraph(masterlistGroups, {}); - - PluginGraph graph; - - const auto a = graph.AddVertex(CreatePluginSortingData("A.esp", "A")); - const auto b = graph.AddVertex(CreatePluginSortingData("B.esp", "B")); - const auto c = graph.AddVertex(CreatePluginSortingData("C.esp", "C")); - const auto d = graph.AddVertex(CreatePluginSortingData("D.esp", "D")); - - graph.AddGroupEdges(groupGraph); - - // Should be A.esp -> B.esp -> C.esp - // -> D.esp - EXPECT_TRUE(graph.EdgeExists(a, b)); - EXPECT_TRUE(graph.EdgeExists(b, c)); - EXPECT_TRUE(graph.EdgeExists(a, d)); - EXPECT_FALSE(graph.EdgeExists(d, b)); - EXPECT_FALSE(graph.EdgeExists(d, c)); - EXPECT_FALSE(graph.EdgeExists(b, d)); - EXPECT_FALSE(graph.EdgeExists(c, d)); - - EXPECT_NO_THROW(graph.CheckForCycles()); -} - -TEST_F(PluginGraphTest, - addGroupEdgesShouldHandleAsymmetricBranchesInTheGroupsGraphThatMerge) { - std::vector masterlistGroups{Group("A"), - Group("B", {"A"}), - Group("C", {"B"}), - Group("D", {"A"}), - Group("E", {"C", "D"}), - Group()}; - - groupGraph = BuildGroupGraph(masterlistGroups, {}); - - PluginGraph graph; - - const auto a = graph.AddVertex(CreatePluginSortingData("A.esp", "A")); - const auto b = graph.AddVertex(CreatePluginSortingData("B.esp", "B")); - const auto c = graph.AddVertex(CreatePluginSortingData("C.esp", "C")); - const auto d = graph.AddVertex(CreatePluginSortingData("D.esp", "D")); - const auto e = graph.AddVertex(CreatePluginSortingData("E.esp", "E")); - - graph.AddGroupEdges(groupGraph); - - // Should be A.esp -> B.esp -> C.esp -> E.esp - // -> D.esp ----------> - EXPECT_TRUE(graph.EdgeExists(a, b)); - EXPECT_TRUE(graph.EdgeExists(b, c)); - EXPECT_TRUE(graph.EdgeExists(c, e)); - EXPECT_TRUE(graph.EdgeExists(a, d)); - EXPECT_TRUE(graph.EdgeExists(d, e)); - EXPECT_FALSE(graph.EdgeExists(d, b)); - EXPECT_FALSE(graph.EdgeExists(d, c)); - EXPECT_FALSE(graph.EdgeExists(b, d)); - EXPECT_FALSE(graph.EdgeExists(c, d)); - - EXPECT_NO_THROW(graph.CheckForCycles()); -} - -TEST_F( - PluginGraphTest, - addGroupEdgesShouldHandleBranchesInTheGroupsGraphThatFormADiamondPattern) { - std::vector masterlistGroups{Group("A"), - Group("B", {"A"}), - Group("C", {"A"}), - Group("D", {"B", "C"}), - Group()}; - - groupGraph = BuildGroupGraph(masterlistGroups, {}); - - PluginGraph graph; - - const auto a = graph.AddVertex(CreatePluginSortingData("A.esp", "A")); - const auto b = graph.AddVertex(CreatePluginSortingData("B.esp", "B")); - const auto c = graph.AddVertex(CreatePluginSortingData("C.esp", "C")); - const auto d = graph.AddVertex(CreatePluginSortingData("D.esp", "D")); - - graph.AddGroupEdges(groupGraph); - - // Should be A.esp -> B.esp -> D.esp - // -> C.esp -> - EXPECT_TRUE(graph.EdgeExists(a, b)); - EXPECT_TRUE(graph.EdgeExists(b, d)); - EXPECT_TRUE(graph.EdgeExists(a, c)); - EXPECT_TRUE(graph.EdgeExists(c, d)); - EXPECT_FALSE(graph.EdgeExists(b, c)); - EXPECT_FALSE(graph.EdgeExists(c, b)); - - EXPECT_NO_THROW(graph.CheckForCycles()); -} - -TEST_F( - PluginGraphTest, - addGroupEdgesShouldAddEdgesAcrossTheMergePointOfBranchesInTheGroupsGraph) { - std::vector masterlistGroups{Group("A"), - Group("B", {"A"}), - Group("C", {"A"}), - Group("D", {"B", "C"}), - Group("E", {"D"}), - Group()}; - - groupGraph = BuildGroupGraph(masterlistGroups, {}); - - PluginGraph graph; - - const auto a = graph.AddVertex(CreatePluginSortingData("A.esp", "A")); - const auto b = graph.AddVertex(CreatePluginSortingData("B.esp", "B")); - const auto c = graph.AddVertex(CreatePluginSortingData("C.esp", "C")); - const auto d = graph.AddVertex(CreatePluginSortingData("D.esp", "D")); - const auto e = graph.AddVertex(CreatePluginSortingData("E.esp", "E")); - - graph.AddEdge(d, c, EdgeType::master); - - graph.AddGroupEdges(groupGraph); - - // Should be A.esp -> B.esp -> D.esp -> C.esp -> E.esp - EXPECT_TRUE(graph.EdgeExists(d, c)); - EXPECT_TRUE(graph.EdgeExists(a, b)); - EXPECT_TRUE(graph.EdgeExists(b, d)); - EXPECT_TRUE(graph.EdgeExists(d, e)); - EXPECT_TRUE(graph.EdgeExists(a, c)); - EXPECT_TRUE(graph.EdgeExists(c, e)); - EXPECT_FALSE(graph.EdgeExists(b, c)); - EXPECT_FALSE(graph.EdgeExists(c, b)); - EXPECT_FALSE(graph.EdgeExists(c, d)); - - EXPECT_NO_THROW(graph.CheckForCycles()); -} - -TEST_F(PluginGraphTest, - addGroupEdgesShouldHandleAGroupGraphWithMultipleSuccessiveBranches) { - PluginGraph graph; - - std::vector masterlistGroups{Group("A"), - Group("B", {"A"}), - Group("C", {"A"}), - Group("D", {"B", "C"}), - Group("E", {"D"}), - Group("F", {"D"}), - Group("G", {"E", "F"}), - Group()}; - - groupGraph = BuildGroupGraph(masterlistGroups, {}); - - const auto a = graph.AddVertex(CreatePluginSortingData("A.esp", "A")); - const auto b = graph.AddVertex(CreatePluginSortingData("B.esp", "B")); - const auto c = graph.AddVertex(CreatePluginSortingData("C.esp", "C")); - const auto d = graph.AddVertex(CreatePluginSortingData("D.esp", "D")); - const auto e = graph.AddVertex(CreatePluginSortingData("E.esp", "E")); - const auto f = graph.AddVertex(CreatePluginSortingData("F.esp", "F")); - const auto g = graph.AddVertex(CreatePluginSortingData("G.esp", "G")); - - graph.AddGroupEdges(groupGraph); - - // Should be: - // A.esp -> B.esp -> D.esp -> E.esp -> G.esp - // -> C.esp -> -> F.esp -> - EXPECT_TRUE(graph.EdgeExists(a, b)); - EXPECT_TRUE(graph.EdgeExists(a, c)); - EXPECT_TRUE(graph.EdgeExists(b, d)); - EXPECT_TRUE(graph.EdgeExists(c, d)); - EXPECT_TRUE(graph.EdgeExists(d, e)); - EXPECT_TRUE(graph.EdgeExists(d, f)); - EXPECT_TRUE(graph.EdgeExists(e, g)); - EXPECT_TRUE(graph.EdgeExists(f, g)); - - EXPECT_FALSE(graph.EdgeExists(b, c)); - EXPECT_FALSE(graph.EdgeExists(c, b)); - EXPECT_FALSE(graph.EdgeExists(e, f)); - EXPECT_FALSE(graph.EdgeExists(f, e)); - - EXPECT_NO_THROW(graph.CheckForCycles()); -} - -TEST_F( - PluginGraphTest, - addGroupEdgesShouldFindAllGroupsInAllPathsBetweenTwoGroupsWhenIgnoringAPlugin) { - PluginGraph graph; - - std::vector masterlistGroups{Group("A"), - Group("B", {"A"}), - Group("C", {"B"}), - Group("D", {"C"}), - Group("default", {"B", "D"})}; - - groupGraph = BuildGroupGraph(masterlistGroups, {}); - - const auto a = graph.AddVertex(CreatePluginSortingData("A.esp", "A")); - const auto b = graph.AddVertex(CreatePluginSortingData("B.esp", "B")); - const auto c = graph.AddVertex(CreatePluginSortingData("C.esp", "C")); - const auto d = graph.AddVertex(CreatePluginSortingData("D.esp", "D")); - const auto e = graph.AddVertex(CreatePluginSortingData("E.esp")); - - graph.AddEdge(e, a, EdgeType::master); - - graph.AddGroupEdges(groupGraph); - - EXPECT_TRUE(graph.EdgeExists(a, b)); - EXPECT_TRUE(graph.EdgeExists(b, c)); - EXPECT_TRUE(graph.EdgeExists(c, d)); - - EXPECT_FALSE(graph.EdgeExists(a, e)); - EXPECT_FALSE(graph.EdgeExists(b, e)); - EXPECT_FALSE(graph.EdgeExists(c, e)); - EXPECT_FALSE(graph.EdgeExists(d, e)); - - EXPECT_NO_THROW(graph.CheckForCycles()); -} - -TEST_F(PluginGraphTest, addGroupEdgesShouldHandleIsolatedGroups) { - std::vector masterlistGroups{ - Group("A"), Group("B", {"A"}), Group("C"), Group()}; - - groupGraph = BuildGroupGraph(masterlistGroups, {}); - - PluginGraph graph; - - const auto a = graph.AddVertex(CreatePluginSortingData("A.esp", "A")); - const auto b = graph.AddVertex(CreatePluginSortingData("B.esp", "B")); - const auto c = graph.AddVertex(CreatePluginSortingData("C.esp", "C")); - - graph.AddGroupEdges(groupGraph); - - // Should be A.esp -> B.esp - // C.esp - EXPECT_TRUE(graph.EdgeExists(a, b)); - EXPECT_FALSE(graph.EdgeExists(a, c)); - EXPECT_FALSE(graph.EdgeExists(c, a)); - EXPECT_FALSE(graph.EdgeExists(b, c)); - EXPECT_FALSE(graph.EdgeExists(c, b)); - - EXPECT_NO_THROW(graph.CheckForCycles()); -} - -TEST_F(PluginGraphTest, addGroupEdgesShouldHandleDisconnectedGroupGraphs) { - std::vector masterlistGroups{ - Group("A"), Group("B", {"A"}), Group("C"), Group("D", {"C"}), Group()}; - - groupGraph = BuildGroupGraph(masterlistGroups, {}); - - PluginGraph graph; - - const auto a = graph.AddVertex(CreatePluginSortingData("A.esp", "A")); - const auto b = graph.AddVertex(CreatePluginSortingData("B.esp", "B")); - const auto c = graph.AddVertex(CreatePluginSortingData("C.esp", "C")); - const auto d = graph.AddVertex(CreatePluginSortingData("D.esp", "D")); - - graph.AddGroupEdges(groupGraph); - - // Should be A.esp -> B.esp - // C.esp -> D.esp - EXPECT_TRUE(graph.EdgeExists(a, b)); - EXPECT_TRUE(graph.EdgeExists(c, d)); - EXPECT_FALSE(graph.EdgeExists(a, c)); - EXPECT_FALSE(graph.EdgeExists(a, d)); - EXPECT_FALSE(graph.EdgeExists(b, c)); - EXPECT_FALSE(graph.EdgeExists(b, d)); - EXPECT_FALSE(graph.EdgeExists(c, a)); - EXPECT_FALSE(graph.EdgeExists(c, b)); - EXPECT_FALSE(graph.EdgeExists(d, a)); - EXPECT_FALSE(graph.EdgeExists(d, b)); - - EXPECT_NO_THROW(graph.CheckForCycles()); -} - -TEST_F(PluginGraphTest, - addGroupEdgesShouldAddEdgesAcrossTheMergePointOfTwoRootVertexPaths) { - std::vector masterlistGroups{Group("A"), - Group("B"), - Group("C", {"A", "B"}), - Group("D", {"C"}), - Group()}; - - groupGraph = BuildGroupGraph(masterlistGroups, {}); - - PluginGraph graph; - - const auto a = graph.AddVertex(CreatePluginSortingData("A.esp", "A")); - const auto b = graph.AddVertex(CreatePluginSortingData("B.esp", "B")); - const auto c = graph.AddVertex(CreatePluginSortingData("C.esp", "C")); - const auto d = graph.AddVertex(CreatePluginSortingData("D.esp", "D")); - - graph.AddEdge(c, b, EdgeType::master); - - graph.AddGroupEdges(groupGraph); - - // Should be A.esp -> C.esp -> D.esp - // B.esp ----------> - EXPECT_TRUE(graph.EdgeExists(c, b)); - EXPECT_TRUE(graph.EdgeExists(a, c)); - EXPECT_TRUE(graph.EdgeExists(c, d)); - EXPECT_TRUE(graph.EdgeExists(b, d)); - EXPECT_FALSE(graph.EdgeExists(a, b)); - EXPECT_FALSE(graph.EdgeExists(b, a)); - - EXPECT_NO_THROW(graph.CheckForCycles()); -} - -TEST_F( - PluginGraphTest, - addGroupEdgesShouldNotDependOnGroupDefinitionOrderIfThereIsASingleLinearPath) { - std::vector> masterlistsGroups{ - {Group("B"), Group("C", {"B"}), Group("default", {"C"})}, - {Group("C", {"B"}), Group("B"), Group("default", {"C"})}}; - - for (const auto& masterlistGroups : masterlistsGroups) { - groupGraph = BuildGroupGraph(masterlistGroups, {}); - - PluginGraph graph; - - const auto b = graph.AddVertex(CreatePluginSortingData("B.esp", "B")); - const auto c = graph.AddVertex(CreatePluginSortingData("C.esp", "C")); - const auto d = graph.AddVertex(CreatePluginSortingData("D.esp")); - - graph.AddEdge(d, b, EdgeType::master); - - graph.AddGroupEdges(groupGraph); - - // Should be D.esp -> B.esp -> C.esp - EXPECT_TRUE(graph.EdgeExists(b, c)); - EXPECT_TRUE(graph.EdgeExists(d, b)); - EXPECT_FALSE(graph.EdgeExists(c, d)); - - EXPECT_NO_THROW(graph.CheckForCycles()); - } -} - -TEST_F( - PluginGraphTest, - addGroupEdgesShouldNotDependOnGroupDefinitionOrderIfThereAreMultipleRoots) { - std::vector> orders{ - // Create a graph with groups in one order. - {Group("A"), - Group("B"), - Group("C", {"A", "B"}), - Group("D", {"C"}), - Group()}, - // Now do the same again, but with a different group order for A and B. - {Group("B"), - Group("A"), - Group("C", {"A", "B"}), - Group("D", {"C"}), - Group()}}; - for (const auto& masterlistGroups : orders) { - groupGraph = BuildGroupGraph(masterlistGroups, {}); - - PluginGraph graph; - - const auto a = graph.AddVertex(CreatePluginSortingData("A.esp", "A")); - const auto b = graph.AddVertex(CreatePluginSortingData("B.esp", "B")); - const auto c = graph.AddVertex(CreatePluginSortingData("C.esp", "C")); - const auto d = graph.AddVertex(CreatePluginSortingData("D.esp", "D")); - - graph.AddEdge(d, a, EdgeType::master); - - graph.AddGroupEdges(groupGraph); - - // Should be B.esp -> D.esp -> A.esp -> C.esp - // B.esp -------------------> - EXPECT_TRUE(graph.EdgeExists(d, a)); - EXPECT_TRUE(graph.EdgeExists(a, c)); - EXPECT_TRUE(graph.EdgeExists(b, c)); - EXPECT_TRUE(graph.EdgeExists(b, d)); - EXPECT_FALSE(graph.EdgeExists(a, b)); - EXPECT_FALSE(graph.EdgeExists(b, a)); - EXPECT_FALSE(graph.EdgeExists(c, d)); - - EXPECT_NO_THROW(graph.CheckForCycles()); - } -} - -TEST_F(PluginGraphTest, - addGroupEdgesShouldNotDependOnBranchingGroupDefinitionOrder) { - std::vector> orders{ - // Create a graph with groups in one order. - {Group("A"), - Group("B", {"A"}), - Group("C", {"A"}), - Group("D", {"B", "C"}), - Group("E", {"D"}), - Group()}, - // Now do the same again, but with a different group order for B and C. - {Group("A"), - Group("C", {"A"}), - Group("B", {"A"}), - Group("D", {"B", "C"}), - Group("E", {"D"}), - Group()}}; - for (const auto& masterlistGroups : orders) { - groupGraph = BuildGroupGraph(masterlistGroups, {}); - - PluginGraph graph; - - const auto a = graph.AddVertex(CreatePluginSortingData("A.esp", "A")); - const auto b = graph.AddVertex(CreatePluginSortingData("B.esp", "B")); - const auto c = graph.AddVertex(CreatePluginSortingData("C.esp", "C")); - const auto d = graph.AddVertex(CreatePluginSortingData("D.esp", "D")); - const auto e = graph.AddVertex(CreatePluginSortingData("E.esp", "E")); - - graph.AddEdge(e, c, EdgeType::master); - - graph.AddGroupEdges(groupGraph); - - // Should be A.esp -> B.esp -> D.esp -> E.esp -> C.esp - EXPECT_TRUE(graph.EdgeExists(a, b)); - EXPECT_TRUE(graph.EdgeExists(a, c)); - EXPECT_TRUE(graph.EdgeExists(a, d)); - EXPECT_TRUE(graph.EdgeExists(a, e)); - EXPECT_TRUE(graph.EdgeExists(b, d)); - EXPECT_TRUE(graph.EdgeExists(b, e)); - EXPECT_TRUE(graph.EdgeExists(d, e)); - EXPECT_TRUE(graph.EdgeExists(e, c)); - - EXPECT_FALSE(graph.EdgeExists(b, c)); - EXPECT_FALSE(graph.EdgeExists(c, b)); - EXPECT_FALSE(graph.EdgeExists(c, d)); - EXPECT_FALSE(graph.EdgeExists(c, e)); - EXPECT_FALSE(graph.EdgeExists(d, c)); - - EXPECT_NO_THROW(graph.CheckForCycles()); - } -} - -TEST_F(PluginGraphTest, addGroupEdgesShouldNotDependOnPluginGraphVertexOrder) { - std::vector masterlistGroups{ - Group("A"), Group("B", {"A"}), Group("C", {"B"}), Group()}; - - groupGraph = BuildGroupGraph(masterlistGroups, {}); - - const auto a1Data = CreatePluginSortingData("A1.esp", "A"); - const auto a2Data = CreatePluginSortingData("A2.esp", "A"); - const auto bData = CreatePluginSortingData("B.esp", "B"); - const auto cData = CreatePluginSortingData("C.esp", "C"); - - const std::vector> variations{ - {a1Data, a2Data, bData, cData}, {a1Data, a2Data, cData, bData}, - {a1Data, bData, cData, a2Data}, {a1Data, bData, a2Data, cData}, - {a1Data, cData, a2Data, bData}, {a1Data, cData, bData, a2Data}, - - {a2Data, a1Data, bData, cData}, {a2Data, a1Data, cData, bData}, - {a2Data, bData, cData, a1Data}, {a2Data, bData, a1Data, cData}, - {a2Data, cData, a1Data, bData}, {a2Data, cData, bData, a1Data}, - - {bData, a2Data, a1Data, cData}, {bData, a2Data, cData, a1Data}, - {bData, a1Data, cData, a2Data}, {bData, a1Data, a2Data, cData}, - {bData, cData, a2Data, a1Data}, {bData, cData, a1Data, a2Data}, - - {cData, a2Data, bData, a1Data}, {cData, a2Data, a1Data, bData}, - {cData, bData, a1Data, a2Data}, {cData, bData, a2Data, a1Data}, - {cData, a1Data, a2Data, bData}, {cData, a1Data, bData, a2Data}, - }; - - for (const auto& pluginsSortingData : variations) { - PluginGraph graph; - - for (const auto& plugin : pluginsSortingData) { - graph.AddVertex(plugin); - } - - const auto a1 = graph.GetVertexByName("A1.esp").value(); - const auto a2 = graph.GetVertexByName("A2.esp").value(); - const auto b = graph.GetVertexByName("B.esp").value(); - const auto c = graph.GetVertexByName("C.esp").value(); - - graph.AddEdge(c, a1, EdgeType::master); - - graph.AddGroupEdges(groupGraph); - - // Should be A2.esp -> C.esp -> A1.esp -> B.esp - // A2.esp --------------------> - ASSERT_TRUE(graph.EdgeExists(c, a1)); - ASSERT_TRUE(graph.EdgeExists(a1, b)); - ASSERT_TRUE(graph.EdgeExists(a2, b)); - ASSERT_TRUE(graph.EdgeExists(a2, c)); - ASSERT_FALSE(graph.EdgeExists(a1, a2)); - ASSERT_FALSE(graph.EdgeExists(a2, a1)); - ASSERT_FALSE(graph.EdgeExists(b, c)); - - ASSERT_NO_THROW(graph.CheckForCycles()); - } -} - -TEST_F( - PluginGraphTest, - addGroupEdgesShouldStartSearchingFromRootGroupsBeforeGoingInLexicographicalOrder) { - std::vector masterlistGroups{Group("D"), - Group("A", {"D"}), - Group("B", {"A"}), - Group("C", {"B"}), - Group()}; - groupGraph = BuildGroupGraph(masterlistGroups, {}); - - PluginGraph graph; - - const auto a = graph.AddVertex(CreatePluginSortingData("A.esp", "A")); - const auto b = graph.AddVertex(CreatePluginSortingData("B.esp", "B")); - const auto c = graph.AddVertex(CreatePluginSortingData("C.esp", "C")); - const auto d = graph.AddVertex(CreatePluginSortingData("D.esp", "D")); - - graph.AddEdge(c, d, EdgeType::master); - - graph.AddGroupEdges(groupGraph); - - // Should be C.esp -> D.esp -> A.esp -> B.esp - // Processing groups lexicographically would give: - // A.esp -> B.esp -> C.esp -> D.esp - EXPECT_TRUE(graph.EdgeExists(c, d)); - EXPECT_TRUE(graph.EdgeExists(d, a)); - EXPECT_TRUE(graph.EdgeExists(d, b)); - EXPECT_TRUE(graph.EdgeExists(a, b)); - - EXPECT_FALSE(graph.EdgeExists(a, c)); - EXPECT_FALSE(graph.EdgeExists(a, d)); - EXPECT_FALSE(graph.EdgeExists(b, a)); - EXPECT_FALSE(graph.EdgeExists(b, c)); - EXPECT_FALSE(graph.EdgeExists(b, d)); - EXPECT_FALSE(graph.EdgeExists(d, c)); - - EXPECT_NO_THROW(graph.CheckForCycles()); -} - -TEST_F(PluginGraphTest, - addGroupEdgesShouldStartSearchingFromTheRootGroupWithTheLongestPath) { - std::vector masterlistGroups{Group("D"), - Group("B", {"D"}), - Group("C", {"B"}), - Group("A"), - Group("E", {"C", "A"}), - Group("F", {"E"}), - Group()}; - groupGraph = BuildGroupGraph(masterlistGroups, {}); - - PluginGraph graph; - - const auto a = graph.AddVertex(CreatePluginSortingData("A.esp", "A")); - const auto b = graph.AddVertex(CreatePluginSortingData("B.esp", "B")); - const auto c = graph.AddVertex(CreatePluginSortingData("C.esp", "C")); - const auto d = graph.AddVertex(CreatePluginSortingData("D.esp", "D")); - const auto e = graph.AddVertex(CreatePluginSortingData("E.esp", "E")); - const auto f = graph.AddVertex(CreatePluginSortingData("F.esp", "F")); - - graph.AddEdge(f, b, EdgeType::master); - - graph.AddGroupEdges(groupGraph); - - // Should be D.esp -> B.esp -> C.esp -> E.esp - // A.esp -> F.esp ----------> B.esp - // A.esp -------------------------------------> E.esp - EXPECT_TRUE(graph.EdgeExists(d, b)); - EXPECT_TRUE(graph.EdgeExists(b, c)); - EXPECT_TRUE(graph.EdgeExists(c, e)); - EXPECT_TRUE(graph.EdgeExists(a, f)); - EXPECT_TRUE(graph.EdgeExists(a, e)); - EXPECT_TRUE(graph.EdgeExists(f, b)); - - EXPECT_NO_THROW(graph.CheckForCycles()); -} - -TEST_F(PluginGraphTest, addGroupEdgesDoesNotStartSearchingWithTheLongestPath) { - std::vector masterlistGroups{Group("A"), - Group("B", {"A"}), - Group("C", {"A"}), - Group("D", {"C"}), - Group("E", {"B", "D"}), - Group()}; - groupGraph = BuildGroupGraph(masterlistGroups, {}); - - PluginGraph graph; - - const auto a = graph.AddVertex(CreatePluginSortingData("A.esp", "A")); - const auto b = graph.AddVertex(CreatePluginSortingData("B.esp", "B")); - const auto c = graph.AddVertex(CreatePluginSortingData("C.esp", "C")); - const auto d = graph.AddVertex(CreatePluginSortingData("D.esp", "D")); - const auto e = graph.AddVertex(CreatePluginSortingData("E.esp", "E")); - - graph.AddEdge(e, c, EdgeType::master); - - graph.AddGroupEdges(groupGraph); - - // Should be A.esp -> B.esp -> E.esp -> C.esp -> D.esp - EXPECT_TRUE(graph.EdgeExists(a, b)); - EXPECT_TRUE(graph.EdgeExists(b, e)); - EXPECT_TRUE(graph.EdgeExists(e, c)); - EXPECT_TRUE(graph.EdgeExists(c, d)); - - EXPECT_NO_THROW(graph.CheckForCycles()); -} - -TEST_F( - PluginGraphTest, - addGroupEdgesShouldMarkVerticesAsUnfinishableIfAVertexInTheirSubtreeIsUnfinishable) { - std::vector masterlistGroups{Group("A"), - Group("B", {"A"}), - Group("C", {"B"}), - Group("D", {"C"}), - Group()}; - std::vector userGroups{ - Group("BU1", {"B"}), Group("BU2", {"BU1"}), Group("C", {"BU2"})}; - groupGraph = BuildGroupGraph(masterlistGroups, userGroups); - - PluginGraph graph; - - const auto a = graph.AddVertex(CreatePluginSortingData("A.esp", "A")); - const auto b = graph.AddVertex(CreatePluginSortingData("B.esp", "B")); - const auto bu1 = graph.AddVertex(CreatePluginSortingData("BU1.esp", "BU1")); - const auto bu2 = graph.AddVertex(CreatePluginSortingData("BU2.esp", "BU2")); - const auto c = graph.AddVertex(CreatePluginSortingData("C.esp", "C")); - const auto d = graph.AddVertex(CreatePluginSortingData("D.esp", "D")); - - graph.AddGroupEdges(groupGraph); - - // Should be A.esp -> B.esp -----------------------> C.esp -> D.esp - // -> BU1.esp -> BU2.esp -> - EXPECT_TRUE(graph.EdgeExists(a, b)); - EXPECT_TRUE(graph.EdgeExists(a, c)); - EXPECT_TRUE(graph.EdgeExists(a, d)); - EXPECT_TRUE(graph.EdgeExists(b, c)); - EXPECT_TRUE(graph.EdgeExists(b, d)); - EXPECT_TRUE(graph.EdgeExists(b, bu1)); - EXPECT_TRUE(graph.EdgeExists(b, bu2)); - EXPECT_TRUE(graph.EdgeExists(bu1, bu2)); - EXPECT_TRUE(graph.EdgeExists(bu1, c)); - EXPECT_TRUE(graph.EdgeExists(bu1, d)); - EXPECT_TRUE(graph.EdgeExists(bu2, c)); - EXPECT_TRUE(graph.EdgeExists(bu2, d)); - EXPECT_TRUE(graph.EdgeExists(c, d)); -} - -TEST_F(PluginGraphTest, - addOverlapEdgesShouldNotAddEdgesBetweenNonOverlappingPlugins) { - PluginGraph graph; - - const auto v1 = graph.AddVertex(CreatePluginSortingData("1.esp")); - const auto v2 = graph.AddVertex(CreatePluginSortingData("2.esp")); - - graph.AddOverlapEdges(); - - EXPECT_FALSE(graph.EdgeExists(v1, v2)); - EXPECT_FALSE(graph.EdgeExists(v2, v1)); -} - -TEST_F( - PluginGraphTest, - addOverlapEdgesShouldNotAddEdgeBetweenPluginsWithOverlappingRecordsAndEqualOverrideCounts) { - const auto p1 = GetPlugin("1.esp"); - const auto p2 = GetPlugin("2.esp"); - - p1->AddOverlappingRecords(*p2); - p1->SetOverrideRecordCount(1); - p2->SetOverrideRecordCount(1); - - PluginGraph graph; - - const auto v1 = graph.AddVertex(CreatePluginSortingData("1.esp")); - const auto v2 = graph.AddVertex(CreatePluginSortingData("2.esp")); - - graph.AddOverlapEdges(); - - EXPECT_FALSE(graph.EdgeExists(v1, v2)); - EXPECT_FALSE(graph.EdgeExists(v2, v1)); -} - -TEST_F( - PluginGraphTest, - addOverlapEdgesShouldAddEdgeBetweenPluginsWithOverlappingRecordsAndInequalOverrideCounts) { - const auto p1 = GetPlugin("1.esp"); - const auto p2 = GetPlugin("2.esp"); - - p1->AddOverlappingRecords(*p2); - p1->SetOverrideRecordCount(2); - p2->SetOverrideRecordCount(1); - - PluginGraph graph; - - const auto v1 = graph.AddVertex(CreatePluginSortingData("1.esp")); - const auto v2 = graph.AddVertex(CreatePluginSortingData("2.esp")); - - graph.AddOverlapEdges(); - - EXPECT_EQ(EdgeType::recordOverlap, graph.GetEdgeType(v1, v2).value()); - EXPECT_FALSE(graph.EdgeExists(v2, v1)); -} - -TEST_F( - PluginGraphTest, - addOverlapEdgesShouldNotAddEdgeBetweenPluginsWithNonOverlappingRecordsAndInequalOverrideCounts) { - const auto p1 = GetPlugin("1.esp"); - const auto p2 = GetPlugin("2.esp"); - - p1->SetOverrideRecordCount(2); - p2->SetOverrideRecordCount(1); - - PluginGraph graph; - - const auto v1 = graph.AddVertex(CreatePluginSortingData("1.esp")); - const auto v2 = graph.AddVertex(CreatePluginSortingData("2.esp")); - - graph.AddOverlapEdges(); - - EXPECT_FALSE(graph.EdgeExists(v1, v2)); - EXPECT_FALSE(graph.EdgeExists(v2, v1)); -} - -TEST_F( - PluginGraphTest, - addOverlapEdgesShouldNotAddEdgeBetweenPluginsWithAssetOverlapAndEqualAssetCounts) { - const auto p1 = GetPlugin("1.esp"); - const auto p2 = GetPlugin("2.esp"); - - p1->AddOverlappingAssets(*p2); - p1->SetAssetCount(1); - p2->SetAssetCount(1); - - PluginGraph graph; - - const auto v1 = graph.AddVertex(CreatePluginSortingData("1.esp")); - const auto v2 = graph.AddVertex(CreatePluginSortingData("2.esp")); - - graph.AddOverlapEdges(); - - EXPECT_FALSE(graph.EdgeExists(v1, v2)); - EXPECT_FALSE(graph.EdgeExists(v2, v1)); -} - -TEST_F( - PluginGraphTest, - addOverlapEdgesShouldNotAddEdgeBetweenPluginsWithNoAssetOverlapAndInequalAssetCounts) { - const auto p1 = GetPlugin("1.esp"); - const auto p2 = GetPlugin("2.esp"); - - p1->SetAssetCount(2); - p2->SetAssetCount(1); - - PluginGraph graph; - - const auto v1 = graph.AddVertex(CreatePluginSortingData("1.esp")); - const auto v2 = graph.AddVertex(CreatePluginSortingData("2.esp")); - - graph.AddOverlapEdges(); - - EXPECT_FALSE(graph.EdgeExists(v1, v2)); - EXPECT_FALSE(graph.EdgeExists(v2, v1)); -} - -TEST_F( - PluginGraphTest, - addOverlapEdgesShouldAddEdgeBetweenPluginsWithAssetOverlapAndInequalAssetCounts) { - const auto p1 = GetPlugin("1.esp"); - const auto p2 = GetPlugin("2.esp"); - - p1->AddOverlappingAssets(*p2); - p1->SetAssetCount(2); - p2->SetAssetCount(1); - - PluginGraph graph; - - const auto v1 = graph.AddVertex(CreatePluginSortingData("1.esp")); - const auto v2 = graph.AddVertex(CreatePluginSortingData("2.esp")); - - graph.AddOverlapEdges(); - - EXPECT_EQ(EdgeType::assetOverlap, graph.GetEdgeType(v1, v2).value()); - EXPECT_FALSE(graph.EdgeExists(v2, v1)); -} - -TEST_F( - PluginGraphTest, - addOverlapEdgesShouldCheckAssetsIfRecordsOverlapWithEqualOverrideCounts) { - const auto p1 = GetPlugin("1.esp"); - const auto p2 = GetPlugin("2.esp"); - - p1->AddOverlappingRecords(*p2); - p1->AddOverlappingAssets(*p2); - p1->SetAssetCount(2); - p2->SetAssetCount(1); - - PluginGraph graph; - - const auto v1 = graph.AddVertex(CreatePluginSortingData("1.esp")); - const auto v2 = graph.AddVertex(CreatePluginSortingData("2.esp")); - - graph.AddOverlapEdges(); - - EXPECT_EQ(EdgeType::assetOverlap, graph.GetEdgeType(v1, v2).value()); - EXPECT_FALSE(graph.EdgeExists(v2, v1)); -} - -TEST_F( - PluginGraphTest, - addOverlapEdgesShouldCheckAssetsIfRecordsDoNotOverlapWithInequalOverrideCounts) { - const auto p1 = GetPlugin("1.esp"); - const auto p2 = GetPlugin("2.esp"); - - p1->AddOverlappingAssets(*p2); - p1->SetAssetCount(2); - p2->SetAssetCount(1); - p1->SetOverrideRecordCount(1); - p2->SetOverrideRecordCount(2); - - PluginGraph graph; - - const auto v1 = graph.AddVertex(CreatePluginSortingData("1.esp")); - const auto v2 = graph.AddVertex(CreatePluginSortingData("2.esp")); - - graph.AddOverlapEdges(); - - EXPECT_EQ(EdgeType::assetOverlap, graph.GetEdgeType(v1, v2).value()); - EXPECT_FALSE(graph.EdgeExists(v2, v1)); -} - -TEST_F(PluginGraphTest, - addOverlapEdgesShouldChooseRecordOverlapOverAssetOverlap) { - const auto p1 = GetPlugin("1.esp"); - const auto p2 = GetPlugin("2.esp"); - - p1->AddOverlappingRecords(*p2); - p1->SetOverrideRecordCount(2); - p2->SetOverrideRecordCount(1); - p1->AddOverlappingAssets(*p2); - p1->SetAssetCount(1); - p2->SetAssetCount(2); - - PluginGraph graph; - - const auto v1 = graph.AddVertex(CreatePluginSortingData("1.esp")); - const auto v2 = graph.AddVertex(CreatePluginSortingData("2.esp")); - - graph.AddOverlapEdges(); - - EXPECT_EQ(EdgeType::recordOverlap, graph.GetEdgeType(v1, v2).value()); - EXPECT_FALSE(graph.EdgeExists(v2, v1)); -} - -TEST_F(PluginGraphTest, addTieBreakEdgesShouldNotErrorOnAGraphWithOneVertex) { - const auto plugin = CreatePluginSortingData("A.esp"); - - PluginGraph graph; - graph.AddVertex(plugin); - - graph.AddTieBreakEdges(); -} - -TEST_F( - PluginGraphTest, - addTieBreakEdgesShouldResultInASortOrderEqualToVertexCreationOrderIfThereAreNoOtherEdges) { - PluginGraph graph; - - for (size_t i = 0; i < 10; ++i) { - const auto plugin = CreatePluginSortingData(std::to_string(i) + ".esp"); - graph.AddVertex(plugin); - } - - graph.AddTieBreakEdges(); - const auto sorted = graph.TopologicalSort(); - const auto names = graph.ToPluginNames(sorted); - - std::vector expected({"0.esp", - "1.esp", - "2.esp", - "3.esp", - "4.esp", - "5.esp", - "6.esp", - "7.esp", - "8.esp", - "9.esp"}); - - EXPECT_FALSE(graph.IsHamiltonianPath(sorted).has_value()); - EXPECT_EQ(expected, names); -} - -TEST_F( - PluginGraphTest, - addTieBreakEdgesShouldPinPathsThatPreventTheVertexCreationOrderBeingUsed) { - PluginGraph graph; - - for (size_t i = 0; i < 10; ++i) { - const auto plugin = CreatePluginSortingData(std::to_string(i) + ".esp"); - graph.AddVertex(plugin); - } - - // Add a path 6 -> 7 -> 8 -> 5. - vertex_t five = graph.GetVertexByName("5.esp").value(); - vertex_t six = graph.GetVertexByName("6.esp").value(); - vertex_t seven = graph.GetVertexByName("7.esp").value(); - vertex_t eight = graph.GetVertexByName("8.esp").value(); - - graph.AddEdge(six, seven, EdgeType::recordOverlap); - graph.AddEdge(seven, eight, EdgeType::recordOverlap); - graph.AddEdge(eight, five, EdgeType::recordOverlap); - - // Also add a path going from 6 to 3 and another from 8 to 4. - vertex_t three = graph.GetVertexByName("3.esp").value(); - vertex_t four = graph.GetVertexByName("4.esp").value(); - - graph.AddEdge(six, three, EdgeType::recordOverlap); - graph.AddEdge(eight, four, EdgeType::recordOverlap); - - graph.AddTieBreakEdges(); - const auto sorted = graph.TopologicalSort(); - const auto names = graph.ToPluginNames(sorted); - - std::vector expected({"0.esp", - "1.esp", - "2.esp", - "6.esp", - "3.esp", - "7.esp", - "8.esp", - "4.esp", - "5.esp", - "9.esp"}); - - EXPECT_FALSE(graph.IsHamiltonianPath(sorted).has_value()); - EXPECT_EQ(expected, names); -} - -TEST_F( - PluginGraphTest, - addTieBreakEdgesShouldPrefixPathToNewLoadOrderIfTheFirstPairOfVerticesCannotBeUsedInCreationOrder) { - PluginGraph graph; - - for (size_t i = 0; i < 10; ++i) { - const auto plugin = CreatePluginSortingData(std::to_string(i) + ".esp"); - graph.AddVertex(plugin); - } - - // Add a path 1 -> 2 -> 3 -> 0. - vertex_t zero = graph.GetVertexByName("0.esp").value(); - vertex_t one = graph.GetVertexByName("1.esp").value(); - vertex_t two = graph.GetVertexByName("2.esp").value(); - vertex_t three = graph.GetVertexByName("3.esp").value(); - - graph.AddEdge(one, two, EdgeType::recordOverlap); - graph.AddEdge(two, three, EdgeType::recordOverlap); - graph.AddEdge(three, zero, EdgeType::recordOverlap); - - graph.AddTieBreakEdges(); - const auto sorted = graph.TopologicalSort(); - const auto names = graph.ToPluginNames(sorted); - - std::vector expected({"1.esp", - "2.esp", - "3.esp", - "0.esp", - "4.esp", - "5.esp", - "6.esp", - "7.esp", - "8.esp", - "9.esp"}); - - EXPECT_FALSE(graph.IsHamiltonianPath(sorted).has_value()); - EXPECT_EQ(expected, names); -} -} -} - -#endif diff --git a/src/tests/api/internals/sorting/plugin_sort_test.h b/src/tests/api/internals/sorting/plugin_sort_test.h deleted file mode 100644 index f9b13bc7..00000000 --- a/src/tests/api/internals/sorting/plugin_sort_test.h +++ /dev/null @@ -1,772 +0,0 @@ -/* LOOT - -A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and -Fallout: New Vegas. - -Copyright (C) 2014-2016 WrinklyNinja - -This file is part of LOOT. - -LOOT is free software: you can redistribute -it and/or modify it under the terms of the GNU General Public License -as published by the Free Software Foundation, either version 3 of -the License, or (at your option) any later version. - -LOOT is distributed in the hope that it will -be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with LOOT. If not, see -. -*/ - -#ifndef LOOT_TESTS_API_INTERNALS_SORTING_PLUGIN_SORT_TEST -#define LOOT_TESTS_API_INTERNALS_SORTING_PLUGIN_SORT_TEST - -#include "api/sorting/plugin_sort.h" -#include "loot/exception/cyclic_interaction_error.h" -#include "loot/exception/undefined_group_error.h" -#include "tests/api/internals/plugin_test.h" -#include "tests/common_game_test_fixture.h" - -namespace loot { -namespace test { -class GetPluginsSortingDataTest : public CommonGameTestFixture { -protected: - GetPluginsSortingDataTest() : CommonGameTestFixture(GameType::tes4) {} -}; - -TEST_F(GetPluginsSortingDataTest, shouldFilterOutFilesWithFalseConstraints) { - Game game(GameType::tes4, gamePath, localPath); - - game.LoadPlugins({blankEsp}, true); - - const auto plugin = game.GetPlugin(blankEsp); - - const auto trueConstraint = "file(\"Blank.esm\")"; - const auto falseConstraint = "file(\"missing.esp\")"; - - std::filesystem::path masterlistPath = localPath / "masterlist.yaml"; - std::ofstream out(masterlistPath); - out << "{plugins: [{name: Blank.esp, after: [{name: A.esp, constraint: '" - << trueConstraint << "'}, {name: B.esp, constraint: '" << falseConstraint - << "'}], req: [{name: C.esp, constraint: '" << trueConstraint - << "'}, {name: D.esp, constraint: '" << falseConstraint << "'}]}]}"; - out.close(); - - game.GetDatabase().LoadMasterlist(masterlistPath); - - PluginMetadata userMetadata(blankEsp); - userMetadata.SetLoadAfterFiles( - {File(blankEsm, "", "", {}, trueConstraint), - File(blankDifferentEsm, "", "", {}, falseConstraint)}); - userMetadata.SetRequirements( - {File(blankDifferentEsp, "", "", {}, trueConstraint), - File(blankMasterDependentEsm, "", "", {}, falseConstraint)}); - - game.GetDatabase().SetPluginUserMetadata(userMetadata); - - const auto pluginsSortingData = GetPluginsSortingData( - game.GetDatabase(), {reinterpret_cast(plugin.get())}); - - ASSERT_EQ(1, pluginsSortingData.size()); - EXPECT_EQ(std::vector{File("A.esp", "", "", {}, trueConstraint)}, - pluginsSortingData[0].GetMasterlistLoadAfterFiles()); - EXPECT_EQ(std::vector{File("C.esp", "", "", {}, trueConstraint)}, - pluginsSortingData[0].GetMasterlistRequirements()); - EXPECT_EQ(std::vector{userMetadata.GetLoadAfterFiles()[0]}, - pluginsSortingData[0].GetUserLoadAfterFiles()); - EXPECT_EQ(std::vector{userMetadata.GetRequirements()[0]}, - pluginsSortingData[0].GetUserRequirements()); -} - -class SortPluginsTest : public ::testing::Test { -protected: - PluginSortingData CreatePluginSortingData(const std::string& name, - const size_t loadOrderIndex) { - const auto plugin = GetPlugin(name); - - return PluginSortingData( - plugin, PluginMetadata(), PluginMetadata(), loadOrderIndex); - } - - TestPlugin* GetPlugin(const std::string& name) { - auto it = testPlugins_.find(name); - - if (it != testPlugins_.end()) { - return it->second.get(); - } - - const auto plugin = std::make_shared(name); - - return testPlugins_.insert_or_assign(name, plugin).first->second.get(); - } - -private: - std::map> testPlugins_; -}; - -TEST_F(SortPluginsTest, shouldNotChangeTheResultIfGivenItsOwnOutputLoadOrder) { - // Can't test with the test plugin files, so use the other SortPlugins() - // overload to provide stubs. - const auto p1 = GetPlugin("1.esp"); - const auto p2 = GetPlugin("2.esp"); - const auto p3 = GetPlugin("3.esp"); - - p1->AddMaster(p3->GetName()); - - p1->AddOverlappingRecords(*p2); - p1->AddOverlappingRecords(*p3); - p2->AddOverlappingRecords(*p3); - p1->SetOverrideRecordCount(3); - p2->SetOverrideRecordCount(2); - p3->SetOverrideRecordCount(1); - - // Define the initial load order. - std::vector loadOrder{ - p1->GetName(), p2->GetName(), p3->GetName()}; - - const std::vector expectedSortedOrder{ - p3->GetName(), p1->GetName(), p2->GetName()}; - - // Now sort the plugins. - { - std::vector pluginsSortingData{ - CreatePluginSortingData(p1->GetName(), 0), - CreatePluginSortingData(p2->GetName(), 1), - CreatePluginSortingData(p3->GetName(), 2)}; - - auto sorted = SortPlugins(std::move(pluginsSortingData), {Group()}, {}, {}); - ASSERT_EQ(expectedSortedOrder, sorted); - - loadOrder = sorted; - } - - // Now do it again but supplying the sorted load order as the current load - // order. - { - std::vector pluginsSortingData{ - CreatePluginSortingData(p1->GetName(), 1), - CreatePluginSortingData(p2->GetName(), 2), - CreatePluginSortingData(p3->GetName(), 0)}; - - auto sorted = SortPlugins(std::move(pluginsSortingData), {Group()}, {}, {}); - ASSERT_EQ(expectedSortedOrder, sorted); - } -} - -TEST_F(SortPluginsTest, - shouldUseGroupMetadataWhenDecidingRelativePluginPositions) { - const auto p1 = GetPlugin("1.esp"); - const auto p2 = GetPlugin("2.esp"); - - auto p1Metadata = PluginMetadata(); - p1Metadata.SetGroup("B"); - auto p2Metadata = PluginMetadata(); - p2Metadata.SetGroup("A"); - - const auto p1Data = PluginSortingData(p1, p1Metadata, PluginMetadata(), 0); - const auto p2Data = PluginSortingData(p2, p2Metadata, PluginMetadata(), 1); - - const auto sorted = SortPlugins( - {p1Data, p2Data}, {Group(), Group("A"), Group("B", {"A"})}, {}, {}); - - const auto expected = std::vector{p2->GetName(), p1->GetName()}; - EXPECT_EQ(expected, sorted); -} - -TEST_F(SortPluginsTest, - shouldAccountForUserGroupMetadataWhenTryingToAvoidCycles) { - const std::vector masterlistGroups{Group(), Group("B", {"default"})}; - const std::vector userGroups{Group("A", {"default"}), - Group("B", {"A"})}; - - const auto p1 = GetPlugin("1.esp"); - const auto p2 = GetPlugin("2.esp"); - const auto p3 = GetPlugin("3.esp"); - - p2->AddMaster(p1->GetName()); - - auto p1Metadata = PluginMetadata(); - p1Metadata.SetGroup("B"); - auto p2Metadata = PluginMetadata(); - p2Metadata.SetGroup("default"); - auto p3Metadata = PluginMetadata(); - p3Metadata.SetGroup("A"); - - const auto p1Data = PluginSortingData(p1, p1Metadata, PluginMetadata(), 0); - const auto p2Data = PluginSortingData(p2, p2Metadata, PluginMetadata(), 1); - const auto p3Data = PluginSortingData(p3, p3Metadata, PluginMetadata(), 2); - - const auto sorted = - SortPlugins({p1Data, p2Data, p3Data}, masterlistGroups, userGroups, {}); - - const std::vector expected{ - p3->GetName(), p1->GetName(), p2->GetName()}; - - EXPECT_EQ(expected, sorted); -} - -TEST_F(SortPluginsTest, shouldThrowIfAPluginHasAGroupThatDoesNotExist) { - const auto p1 = GetPlugin("1.esp"); - - auto p1Metadata = PluginMetadata(); - p1Metadata.SetGroup("A"); - - const auto p1Data = PluginSortingData(p1, p1Metadata, PluginMetadata(), 0); - - EXPECT_THROW(SortPlugins({p1Data}, {Group()}, {}, {}), UndefinedGroupError); -} - -TEST_F(SortPluginsTest, - shouldUseLoadAfterMetadataWhenDecidingRelativePluginPositions) { - const auto p1 = GetPlugin("1.esp"); - const auto p2Data = CreatePluginSortingData("2.esp", 1); - - auto p1Metadata = PluginMetadata(); - p1Metadata.SetLoadAfterFiles({File(p2Data.GetName())}); - - const auto p1Data = PluginSortingData(p1, p1Metadata, PluginMetadata(), 0); - - const auto sorted = SortPlugins({p1Data, p2Data}, {Group()}, {}, {}); - - const auto expected = - std::vector{p2Data.GetName(), p1->GetName()}; - EXPECT_EQ(expected, sorted); -} - -TEST_F(SortPluginsTest, - shouldUseRequirementMetadataWhenDecidingRelativePluginPositions) { - const auto p1 = GetPlugin("1.esp"); - const auto p2Data = CreatePluginSortingData("2.esp", 1); - - auto p1Metadata = PluginMetadata(); - p1Metadata.SetRequirements({File(p2Data.GetName())}); - - const auto p1Data = PluginSortingData(p1, p1Metadata, PluginMetadata(), 0); - - const auto sorted = SortPlugins({p1Data, p2Data}, {Group()}, {}, {}); - - const auto expected = - std::vector{p2Data.GetName(), p1->GetName()}; - EXPECT_EQ(expected, sorted); -} - -TEST_F(SortPluginsTest, - shouldUseTheGameCCCFileToEnforceHardcodedLoadOrderPositions) { - const auto p1Data = CreatePluginSortingData("1.esp", 0); - const auto p2Data = CreatePluginSortingData("2.esp", 1); - - const auto sorted = - SortPlugins({p1Data, p2Data}, {Group()}, {}, {p2Data.GetName()}); - - const auto expected = - std::vector{p2Data.GetName(), p1Data.GetName()}; - EXPECT_EQ(expected, sorted); -} - -TEST_F(SortPluginsTest, shouldThrowIfACyclicInteractionIsEncountered) { - const auto p1 = GetPlugin("1.esp"); - const auto p2 = GetPlugin("2.esp"); - p1->AddMaster(p2->GetName()); - p2->AddMaster(p1->GetName()); - - const auto p1Data = CreatePluginSortingData(p1->GetName(), 0); - const auto p2Data = CreatePluginSortingData(p2->GetName(), 1); - - EXPECT_THROW(SortPlugins({p1Data, p2Data}, {Group()}, {}, {}), - CyclicInteractionError); -} - -TEST_F(SortPluginsTest, shouldThrowIfMasterEdgeWouldContradictMasterFlags) { - const auto p1 = GetPlugin("1.esp"); - const auto p2 = GetPlugin("2.esp"); - - p1->SetIsMaster(true); - p1->AddMaster(p2->GetName()); - - std::vector pluginsSortingData{ - CreatePluginSortingData(p1->GetName(), 0), - CreatePluginSortingData(p2->GetName(), 1)}; - - try { - SortPlugins(std::move(pluginsSortingData), {Group()}, {}, {}); - FAIL(); - } catch (const CyclicInteractionError& e) { - ASSERT_EQ(2, e.GetCycle().size()); - EXPECT_EQ(p2->GetName(), e.GetCycle()[0].GetName()); - EXPECT_EQ(EdgeType::master, e.GetCycle()[0].GetTypeOfEdgeToNextVertex()); - EXPECT_EQ(p1->GetName(), e.GetCycle()[1].GetName()); - EXPECT_EQ(EdgeType::masterFlag, - e.GetCycle()[1].GetTypeOfEdgeToNextVertex()); - } -} - -TEST_F(SortPluginsTest, - shouldThrowIfMasterlistRequirementEdgeWouldContradictMasterFlags) { - const auto p1 = GetPlugin("1.esp"); - const auto p2 = GetPlugin("2.esp"); - - p1->SetIsMaster(true); - - auto p1Metadata = PluginMetadata(); - p1Metadata.SetRequirements({File(p2->GetName())}); - - std::vector pluginsSortingData{ - PluginSortingData(p1, p1Metadata, PluginMetadata(), 0), - CreatePluginSortingData(p2->GetName(), 1)}; - - try { - SortPlugins(std::move(pluginsSortingData), {Group()}, {}, {}); - FAIL(); - } catch (const CyclicInteractionError& e) { - ASSERT_EQ(2, e.GetCycle().size()); - EXPECT_EQ(p2->GetName(), e.GetCycle()[0].GetName()); - EXPECT_EQ(EdgeType::masterlistRequirement, - e.GetCycle()[0].GetTypeOfEdgeToNextVertex()); - EXPECT_EQ(p1->GetName(), e.GetCycle()[1].GetName()); - EXPECT_EQ(EdgeType::masterFlag, - e.GetCycle()[1].GetTypeOfEdgeToNextVertex()); - } -} - -TEST_F(SortPluginsTest, - shouldThrowIfUserRequirementEdgeWouldContradictMasterFlags) { - const auto p1 = GetPlugin("1.esp"); - const auto p2 = GetPlugin("2.esp"); - - p1->SetIsMaster(true); - - auto p1Metadata = PluginMetadata(); - p1Metadata.SetRequirements({File(p2->GetName())}); - - std::vector pluginsSortingData{ - PluginSortingData(p1, PluginMetadata(), p1Metadata, 0), - CreatePluginSortingData(p2->GetName(), 1)}; - - try { - SortPlugins(std::move(pluginsSortingData), {Group()}, {}, {}); - FAIL(); - } catch (const CyclicInteractionError& e) { - ASSERT_EQ(2, e.GetCycle().size()); - EXPECT_EQ(p2->GetName(), e.GetCycle()[0].GetName()); - EXPECT_EQ(EdgeType::userRequirement, - e.GetCycle()[0].GetTypeOfEdgeToNextVertex()); - EXPECT_EQ(p1->GetName(), e.GetCycle()[1].GetName()); - EXPECT_EQ(EdgeType::masterFlag, - e.GetCycle()[1].GetTypeOfEdgeToNextVertex()); - } -} - -TEST_F(SortPluginsTest, - shouldThrowIfMasterlistLoadAfterEdgeWouldContradictMasterFlags) { - const auto p1 = GetPlugin("1.esp"); - const auto p2 = GetPlugin("2.esp"); - - p1->SetIsMaster(true); - - auto p1Metadata = PluginMetadata(); - p1Metadata.SetLoadAfterFiles({File(p2->GetName())}); - - std::vector pluginsSortingData{ - PluginSortingData(p1, p1Metadata, PluginMetadata(), 0), - CreatePluginSortingData(p2->GetName(), 1)}; - - try { - SortPlugins(std::move(pluginsSortingData), {Group()}, {}, {}); - FAIL(); - } catch (const CyclicInteractionError& e) { - ASSERT_EQ(2, e.GetCycle().size()); - EXPECT_EQ(p2->GetName(), e.GetCycle()[0].GetName()); - EXPECT_EQ(EdgeType::masterlistLoadAfter, - e.GetCycle()[0].GetTypeOfEdgeToNextVertex()); - EXPECT_EQ(p1->GetName(), e.GetCycle()[1].GetName()); - EXPECT_EQ(EdgeType::masterFlag, - e.GetCycle()[1].GetTypeOfEdgeToNextVertex()); - } -} - -TEST_F(SortPluginsTest, - shouldThrowIfUserLoadAfterEdgeWouldContradictMasterFlags) { - const auto p1 = GetPlugin("1.esp"); - const auto p2 = GetPlugin("2.esp"); - - p1->SetIsMaster(true); - - auto p1Metadata = PluginMetadata(); - p1Metadata.SetLoadAfterFiles({File(p2->GetName())}); - - std::vector pluginsSortingData{ - PluginSortingData(p1, PluginMetadata(), p1Metadata, 0), - CreatePluginSortingData(p2->GetName(), 1)}; - - try { - SortPlugins(std::move(pluginsSortingData), {Group()}, {}, {}); - FAIL(); - } catch (const CyclicInteractionError& e) { - ASSERT_EQ(2, e.GetCycle().size()); - EXPECT_EQ(p2->GetName(), e.GetCycle()[0].GetName()); - EXPECT_EQ(EdgeType::userLoadAfter, - e.GetCycle()[0].GetTypeOfEdgeToNextVertex()); - EXPECT_EQ(p1->GetName(), e.GetCycle()[1].GetName()); - EXPECT_EQ(EdgeType::masterFlag, - e.GetCycle()[1].GetTypeOfEdgeToNextVertex()); - } -} - -TEST_F(SortPluginsTest, shouldThrowIfHardcodedEdgeWouldContradictMasterFlags) { - const auto p1 = GetPlugin("1.esp"); - const auto p2 = GetPlugin("2.esp"); - - p1->SetIsMaster(true); - - std::vector pluginsSortingData{ - CreatePluginSortingData(p1->GetName(), 0), - CreatePluginSortingData(p2->GetName(), 1)}; - - try { - SortPlugins(std::move(pluginsSortingData), {Group()}, {}, {p2->GetName()}); - FAIL(); - } catch (const CyclicInteractionError& e) { - ASSERT_EQ(2, e.GetCycle().size()); - EXPECT_EQ(p2->GetName(), e.GetCycle()[0].GetName()); - EXPECT_EQ(EdgeType::hardcoded, e.GetCycle()[0].GetTypeOfEdgeToNextVertex()); - EXPECT_EQ(p1->GetName(), e.GetCycle()[1].GetName()); - EXPECT_EQ(EdgeType::masterFlag, - e.GetCycle()[1].GetTypeOfEdgeToNextVertex()); - } -} - -TEST_F(SortPluginsTest, - shouldNotThrowIfAMasterEdgeWouldPutABlueprintMasterBeforeAMaster) { - const auto p1 = GetPlugin("1.esp"); - const auto p2 = GetPlugin("2.esp"); - - p1->SetIsBlueprintPlugin(true); - p1->SetIsMaster(true); - p2->AddMaster(p1->GetName()); - p2->SetIsMaster(true); - - std::vector pluginsSortingData{ - CreatePluginSortingData(p1->GetName(), 0), - CreatePluginSortingData(p2->GetName(), 1)}; - - const auto sorted = - SortPlugins(std::move(pluginsSortingData), {Group()}, {}, {}); - - const auto expected = std::vector{p2->GetName(), p1->GetName()}; - EXPECT_EQ(expected, sorted); -} - -TEST_F(SortPluginsTest, - shouldNotThrowIfAMasterEdgeWouldPutABlueprintMasterBeforeANonMaster) { - const auto p1 = GetPlugin("1.esp"); - const auto p2 = GetPlugin("2.esp"); - - p1->SetIsBlueprintPlugin(true); - p1->SetIsMaster(true); - p2->AddMaster(p1->GetName()); - - std::vector pluginsSortingData{ - CreatePluginSortingData(p1->GetName(), 0), - CreatePluginSortingData(p2->GetName(), 1)}; - - const auto sorted = - SortPlugins(std::move(pluginsSortingData), {Group()}, {}, {}); - - const auto expected = std::vector{p2->GetName(), p1->GetName()}; - EXPECT_EQ(expected, sorted); -} - -TEST_F( - SortPluginsTest, - shouldThrowIfAMasterlistRequirementEdgeWouldPutABlueprintMasterBeforeAMaster) { - const auto p1 = GetPlugin("1.esp"); - const auto p2 = GetPlugin("2.esp"); - - p1->SetIsBlueprintPlugin(true); - p1->SetIsMaster(true); - p2->SetIsMaster(true); - - auto p2Metadata = PluginMetadata(); - p2Metadata.SetRequirements({File(p1->GetName())}); - - std::vector pluginsSortingData{ - CreatePluginSortingData(p1->GetName(), 0), - PluginSortingData(p2, p2Metadata, PluginMetadata(), 1)}; - - try { - SortPlugins(std::move(pluginsSortingData), {Group()}, {}, {}); - FAIL(); - } catch (const CyclicInteractionError& e) { - ASSERT_EQ(2, e.GetCycle().size()); - EXPECT_EQ(p1->GetName(), e.GetCycle()[0].GetName()); - EXPECT_EQ(EdgeType::masterlistRequirement, - e.GetCycle()[0].GetTypeOfEdgeToNextVertex()); - EXPECT_EQ(p2->GetName(), e.GetCycle()[1].GetName()); - EXPECT_EQ(EdgeType::blueprintMaster, - e.GetCycle()[1].GetTypeOfEdgeToNextVertex()); - } -} - -TEST_F( - SortPluginsTest, - shouldThrowIfAMasterlistRequirementEdgeWouldPutABlueprintMasterBeforeANonMaster) { - const auto p1 = GetPlugin("1.esp"); - const auto p2 = GetPlugin("2.esp"); - - p1->SetIsBlueprintPlugin(true); - p1->SetIsMaster(true); - - auto p2Metadata = PluginMetadata(); - p2Metadata.SetRequirements({File(p1->GetName())}); - - std::vector pluginsSortingData{ - CreatePluginSortingData(p1->GetName(), 0), - PluginSortingData(p2, p2Metadata, PluginMetadata(), 1)}; - - try { - SortPlugins(std::move(pluginsSortingData), {Group()}, {}, {}); - FAIL(); - } catch (const CyclicInteractionError& e) { - ASSERT_EQ(2, e.GetCycle().size()); - EXPECT_EQ(p1->GetName(), e.GetCycle()[0].GetName()); - EXPECT_EQ(EdgeType::masterlistRequirement, - e.GetCycle()[0].GetTypeOfEdgeToNextVertex()); - EXPECT_EQ(p2->GetName(), e.GetCycle()[1].GetName()); - EXPECT_EQ(EdgeType::blueprintMaster, - e.GetCycle()[1].GetTypeOfEdgeToNextVertex()); - } -} - -TEST_F(SortPluginsTest, - shouldThrowIfAUserRequirementEdgeWouldPutABlueprintMasterBeforeAMaster) { - const auto p1 = GetPlugin("1.esp"); - const auto p2 = GetPlugin("2.esp"); - - p1->SetIsBlueprintPlugin(true); - p1->SetIsMaster(true); - p2->SetIsMaster(true); - - auto p2Metadata = PluginMetadata(); - p2Metadata.SetRequirements({File(p1->GetName())}); - - std::vector pluginsSortingData{ - CreatePluginSortingData(p1->GetName(), 0), - PluginSortingData(p2, PluginMetadata(), p2Metadata, 1)}; - - try { - SortPlugins(std::move(pluginsSortingData), {Group()}, {}, {}); - FAIL(); - } catch (const CyclicInteractionError& e) { - ASSERT_EQ(2, e.GetCycle().size()); - EXPECT_EQ(p1->GetName(), e.GetCycle()[0].GetName()); - EXPECT_EQ(EdgeType::userRequirement, - e.GetCycle()[0].GetTypeOfEdgeToNextVertex()); - EXPECT_EQ(p2->GetName(), e.GetCycle()[1].GetName()); - EXPECT_EQ(EdgeType::blueprintMaster, - e.GetCycle()[1].GetTypeOfEdgeToNextVertex()); - } -} - -TEST_F( - SortPluginsTest, - shouldThrowIfAUserRequirementEdgeWouldPutABlueprintMasterBeforeANonMaster) { - const auto p1 = GetPlugin("1.esp"); - const auto p2 = GetPlugin("2.esp"); - - p1->SetIsBlueprintPlugin(true); - p1->SetIsMaster(true); - - auto p2Metadata = PluginMetadata(); - p2Metadata.SetRequirements({File(p1->GetName())}); - - std::vector pluginsSortingData{ - CreatePluginSortingData(p1->GetName(), 0), - PluginSortingData(p2, PluginMetadata(), p2Metadata, 1)}; - - try { - SortPlugins(std::move(pluginsSortingData), {Group()}, {}, {}); - FAIL(); - } catch (const CyclicInteractionError& e) { - ASSERT_EQ(2, e.GetCycle().size()); - EXPECT_EQ(p1->GetName(), e.GetCycle()[0].GetName()); - EXPECT_EQ(EdgeType::userRequirement, - e.GetCycle()[0].GetTypeOfEdgeToNextVertex()); - EXPECT_EQ(p2->GetName(), e.GetCycle()[1].GetName()); - EXPECT_EQ(EdgeType::blueprintMaster, - e.GetCycle()[1].GetTypeOfEdgeToNextVertex()); - } -} - -TEST_F( - SortPluginsTest, - shouldThrowIfAMasterlistLoadAfterEdgeWouldPutABlueprintMasterBeforeAMaster) { - const auto p1 = GetPlugin("1.esp"); - const auto p2 = GetPlugin("2.esp"); - - p1->SetIsBlueprintPlugin(true); - p1->SetIsMaster(true); - p2->SetIsMaster(true); - - auto p2Metadata = PluginMetadata(); - p2Metadata.SetLoadAfterFiles({File(p1->GetName())}); - - std::vector pluginsSortingData{ - CreatePluginSortingData(p1->GetName(), 0), - PluginSortingData(p2, p2Metadata, PluginMetadata(), 1)}; - - try { - SortPlugins(std::move(pluginsSortingData), {Group()}, {}, {}); - FAIL(); - } catch (const CyclicInteractionError& e) { - ASSERT_EQ(2, e.GetCycle().size()); - EXPECT_EQ(p1->GetName(), e.GetCycle()[0].GetName()); - EXPECT_EQ(EdgeType::masterlistLoadAfter, - e.GetCycle()[0].GetTypeOfEdgeToNextVertex()); - EXPECT_EQ(p2->GetName(), e.GetCycle()[1].GetName()); - EXPECT_EQ(EdgeType::blueprintMaster, - e.GetCycle()[1].GetTypeOfEdgeToNextVertex()); - } -} - -TEST_F( - SortPluginsTest, - shouldThrowIfAMasterlistLoadAfterEdgeWouldPutABlueprintMasterBeforeANonMaster) { - const auto p1 = GetPlugin("1.esp"); - const auto p2 = GetPlugin("2.esp"); - - p1->SetIsBlueprintPlugin(true); - p1->SetIsMaster(true); - - auto p2Metadata = PluginMetadata(); - p2Metadata.SetLoadAfterFiles({File(p1->GetName())}); - - std::vector pluginsSortingData{ - CreatePluginSortingData(p1->GetName(), 0), - PluginSortingData(p2, p2Metadata, PluginMetadata(), 1)}; - - try { - SortPlugins(std::move(pluginsSortingData), {Group()}, {}, {}); - FAIL(); - } catch (const CyclicInteractionError& e) { - ASSERT_EQ(2, e.GetCycle().size()); - EXPECT_EQ(p1->GetName(), e.GetCycle()[0].GetName()); - EXPECT_EQ(EdgeType::masterlistLoadAfter, - e.GetCycle()[0].GetTypeOfEdgeToNextVertex()); - EXPECT_EQ(p2->GetName(), e.GetCycle()[1].GetName()); - EXPECT_EQ(EdgeType::blueprintMaster, - e.GetCycle()[1].GetTypeOfEdgeToNextVertex()); - } -} - -TEST_F(SortPluginsTest, - shouldThrowIfAUserLoadAfterEdgeWouldPutABlueprintMasterBeforeAMaster) { - const auto p1 = GetPlugin("1.esp"); - const auto p2 = GetPlugin("2.esp"); - - p1->SetIsBlueprintPlugin(true); - p1->SetIsMaster(true); - p2->SetIsMaster(true); - - auto p2Metadata = PluginMetadata(); - p2Metadata.SetLoadAfterFiles({File(p1->GetName())}); - - std::vector pluginsSortingData{ - CreatePluginSortingData(p1->GetName(), 0), - PluginSortingData(p2, PluginMetadata(), p2Metadata, 1)}; - - try { - SortPlugins(std::move(pluginsSortingData), {Group()}, {}, {}); - FAIL(); - } catch (const CyclicInteractionError& e) { - ASSERT_EQ(2, e.GetCycle().size()); - EXPECT_EQ(p1->GetName(), e.GetCycle()[0].GetName()); - EXPECT_EQ(EdgeType::userLoadAfter, - e.GetCycle()[0].GetTypeOfEdgeToNextVertex()); - EXPECT_EQ(p2->GetName(), e.GetCycle()[1].GetName()); - EXPECT_EQ(EdgeType::blueprintMaster, - e.GetCycle()[1].GetTypeOfEdgeToNextVertex()); - } -} - -TEST_F( - SortPluginsTest, - shouldThrowIfAUserLoadAfterEdgeWouldPutABlueprintMasterBeforeANonMaster) { - const auto p1 = GetPlugin("1.esp"); - const auto p2 = GetPlugin("2.esp"); - - p1->SetIsBlueprintPlugin(true); - p1->SetIsMaster(true); - - auto p2Metadata = PluginMetadata(); - p2Metadata.SetLoadAfterFiles({File(p1->GetName())}); - - std::vector pluginsSortingData{ - CreatePluginSortingData(p1->GetName(), 0), - PluginSortingData(p2, PluginMetadata(), p2Metadata, 1)}; - - try { - SortPlugins(std::move(pluginsSortingData), {Group()}, {}, {}); - FAIL(); - } catch (const CyclicInteractionError& e) { - ASSERT_EQ(2, e.GetCycle().size()); - EXPECT_EQ(p1->GetName(), e.GetCycle()[0].GetName()); - EXPECT_EQ(EdgeType::userLoadAfter, - e.GetCycle()[0].GetTypeOfEdgeToNextVertex()); - EXPECT_EQ(p2->GetName(), e.GetCycle()[1].GetName()); - EXPECT_EQ(EdgeType::blueprintMaster, - e.GetCycle()[1].GetTypeOfEdgeToNextVertex()); - } -} - -TEST_F(SortPluginsTest, - shouldNotThrowIfAHardcodedEdgeWouldPutABlueprintMasterBeforeAMaster) { - const auto p1 = GetPlugin("1.esp"); - const auto p2 = GetPlugin("2.esp"); - - p1->SetIsBlueprintPlugin(true); - p1->SetIsMaster(true); - p2->SetIsMaster(true); - - std::vector pluginsSortingData{ - CreatePluginSortingData(p1->GetName(), 0), - CreatePluginSortingData(p2->GetName(), 1)}; - - const auto sorted = SortPlugins( - std::move(pluginsSortingData), {Group()}, {}, {p1->GetName()}); - - EXPECT_EQ(std::vector({ - p2->GetName(), - p1->GetName(), - }), - sorted); -} - -TEST_F(SortPluginsTest, - shouldNotThrowIfAHardcodedEdgeWouldPutABlueprintMasterBeforeANonMaster) { - const auto p1 = GetPlugin("1.esp"); - const auto p2 = GetPlugin("2.esp"); - - p1->SetIsBlueprintPlugin(true); - p1->SetIsMaster(true); - - std::vector pluginsSortingData{ - CreatePluginSortingData(p1->GetName(), 0), - CreatePluginSortingData(p2->GetName(), 1)}; - - const auto sorted = SortPlugins( - std::move(pluginsSortingData), {Group()}, {}, {p1->GetName()}); - - EXPECT_EQ(std::vector({ - p2->GetName(), - p1->GetName(), - }), - sorted); -} -} -} - -#endif diff --git a/src/tests/api/internals/sorting/plugin_sorting_data_test.h b/src/tests/api/internals/sorting/plugin_sorting_data_test.h deleted file mode 100644 index 3aecdca8..00000000 --- a/src/tests/api/internals/sorting/plugin_sorting_data_test.h +++ /dev/null @@ -1,97 +0,0 @@ -/* LOOT - -A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and -Fallout: New Vegas. - -Copyright (C) 2014-2016 WrinklyNinja - -This file is part of LOOT. - -LOOT is free software: you can redistribute -it and/or modify it under the terms of the GNU General Public License -as published by the Free Software Foundation, either version 3 of -the License, or (at your option) any later version. - -LOOT is distributed in the hope that it will -be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with LOOT. If not, see -. -*/ - -#ifndef LOOT_TESTS_API_INTERNALS_SORTING_PLUGIN_SORTING_DATA_TEST -#define LOOT_TESTS_API_INTERNALS_SORTING_PLUGIN_SORTING_DATA_TEST - -#include "api/sorting/plugin_sorting_data.h" -#include "tests/api/internals/plugin_test.h" - -namespace loot { -namespace test { -TEST(PluginSortingData, lightFlaggedEspFilesShouldNotBeTreatedAsMasters) { - TestPlugin nonMaster; - TestPlugin master; - master.SetIsMaster(true); - TestPlugin lightPlugin; - lightPlugin.SetIsLightPlugin(true); - TestPlugin lightMaster; - lightMaster.SetIsLightPlugin(true); - lightMaster.SetIsMaster(true); - - auto esp = - PluginSortingData(&nonMaster, PluginMetadata(), PluginMetadata(), {}); - EXPECT_FALSE(esp.IsMaster()); - - auto masterData = - PluginSortingData(&master, PluginMetadata(), PluginMetadata(), {}); - EXPECT_TRUE(masterData.IsMaster()); - - auto lightMasterData = - PluginSortingData(&lightMaster, PluginMetadata(), PluginMetadata(), {}); - EXPECT_TRUE(lightMasterData.IsMaster()); - - auto lightPluginData = - PluginSortingData(&lightPlugin, PluginMetadata(), PluginMetadata(), {}); - EXPECT_FALSE(lightPluginData.IsMaster()); -} - -TEST(PluginSortingData, - overrideRecordCountShouldEqualSizeOfOverlapWithThePluginsMasters) { - auto count = 4; - TestPlugin plugin; - plugin.SetOverrideRecordCount(count); - - auto pluginData = - PluginSortingData(&plugin, PluginMetadata(), PluginMetadata(), {}); - - EXPECT_EQ(count, pluginData.GetOverrideRecordCount()); -} - -TEST(PluginSortingData, - isBlueprintMasterShouldBeTrueIfPluginIsAMasterAndABlueprintPlugin) { - TestPlugin master; - TestPlugin blueprintPlugin; - blueprintPlugin.SetIsBlueprintPlugin(true); - TestPlugin blueprintMaster; - blueprintMaster.SetIsBlueprintPlugin(true); - blueprintMaster.SetIsMaster(true); - - auto plugin = - PluginSortingData(&master, PluginMetadata(), PluginMetadata(), {}); - - EXPECT_FALSE(plugin.IsBlueprintMaster()); - - plugin = PluginSortingData( - &blueprintPlugin, PluginMetadata(), PluginMetadata(), {}); - EXPECT_FALSE(plugin.IsBlueprintMaster()); - - plugin = PluginSortingData( - &blueprintMaster, PluginMetadata(), PluginMetadata(), {}); - EXPECT_TRUE(plugin.IsBlueprintMaster()); -} -} -} - -#endif diff --git a/src/version.rs b/src/version.rs new file mode 100644 index 00000000..54fca4fc --- /dev/null +++ b/src/version.rs @@ -0,0 +1,121 @@ +/// libloot's major version number. +pub const LIBLOOT_VERSION_MAJOR: u32 = parse_u32(env!("CARGO_PKG_VERSION_MAJOR")); + +/// libloot's minor version number. +pub const LIBLOOT_VERSION_MINOR: u32 = parse_u32(env!("CARGO_PKG_VERSION_MINOR")); + +/// libloot's patch version number. +pub const LIBLOOT_VERSION_PATCH: u32 = parse_u32(env!("CARGO_PKG_VERSION_PATCH")); + +/// Get the library version in the form "major.minor.patch". +pub fn libloot_version() -> String { + env!("CARGO_PKG_VERSION").to_owned() +} + +/// Get the ID of the source control revision that libloot was built from. +pub fn libloot_revision() -> String { + libloot_revision_const().to_owned() +} + +/// Checks whether the loaded API is compatible with the given version of the +/// API, abstracting API stability policy away from clients. The version +/// numbering used is major.minor.patch. +pub fn is_compatible(major: u32, minor: u32, _patch: u32) -> bool { + if major > 0 { + major == LIBLOOT_VERSION_MAJOR + } else { + minor == LIBLOOT_VERSION_MINOR + } +} + +#[expect( + clippy::as_conversions, + reason = "Can't convert the u8 to a u32 another way in a const context" +)] +#[expect( + clippy::indexing_slicing, + reason = "Can't iterate over the byte values another way in a const context" +)] +const fn parse_u32(value: &str) -> u32 { + let bytes = value.as_bytes(); + let mut acc = 0; + let mut i = 0; + while i < bytes.len() { + acc = acc * 10 + (bytes[i] - b'0') as u32; + i += 1; + } + acc +} + +const fn libloot_revision_const() -> &'static str { + if let Some(s) = option_env!("LIBLOOT_REVISION") { + s + } else { + "unknown" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod is_compatible { + use super::*; + + #[test] + fn should_return_true_if_given_the_current_version() { + assert!(is_compatible( + LIBLOOT_VERSION_MAJOR, + LIBLOOT_VERSION_MINOR, + LIBLOOT_VERSION_PATCH + )); + } + + #[test] + fn should_return_true_if_given_a_different_patch_version() { + assert!(is_compatible( + LIBLOOT_VERSION_MAJOR, + LIBLOOT_VERSION_MINOR, + LIBLOOT_VERSION_PATCH + 1 + )); + } + + #[test] + fn should_return_false_if_given_a_different_major_version() { + assert!(!is_compatible( + LIBLOOT_VERSION_MAJOR + 1, + LIBLOOT_VERSION_MINOR, + LIBLOOT_VERSION_PATCH + )); + } + + #[test] + fn should_return_false_if_given_a_different_minor_version() { + assert!(!is_compatible( + LIBLOOT_VERSION_MAJOR, + LIBLOOT_VERSION_MINOR + 1, + LIBLOOT_VERSION_PATCH + )); + } + } + mod libloot_version { + use super::*; + + #[test] + fn should_be_version_numbers_separated_by_periods() { + let expected = + format!("{LIBLOOT_VERSION_MAJOR}.{LIBLOOT_VERSION_MINOR}.{LIBLOOT_VERSION_PATCH}",); + + assert_eq!(expected, libloot_version()); + } + } + + mod libloot_revision { + use super::*; + + #[test] + fn should_not_be_empty() { + assert!(!libloot_revision().is_empty()); + } + } +}