diff --git a/.editorconfig b/.editorconfig index 2aeaeaddd..5edc247ba 100644 --- a/.editorconfig +++ b/.editorconfig @@ -79,3 +79,22 @@ csharp_prefer_braces = false:hint ########## [*.md] guidelines = 100 + +########## +## Suppress warnings in rewrite facades +## See ReSharper docs: https://www.jetbrains.com/help/resharper/Reference__Code_Inspections_CSHARP.html. +########## +[src/SMAPI/Framework/ModLoading/Rewriters/**/*Facade.cs] +dotnet_diagnostic.CS1591.severity = none # missing XML doc comment -- not meant to be used directly +resharper_identifier_typo_highlighting = none # identifier typo -- matches game code +resharper_inconsistent_naming_highlighting = none # inconsistent naming -- matches game code +resharper_local_variable_hides_member_highlighting = none # local variable hides member -- matches game code +resharper_parameter_hides_member_highlighting = none # parameter hides member -- matches game code +resharper_redundant_base_qualifier_highlighting = none # redundant base qualifier -- deliberate for clarity, and to avoid accidentally calling a facade method +resharper_unused_member_global_highlighting = none # unused member -- used via rewriting + +########## +## Suppress warnings in event interfaces +########## +[src/SMAPI/Events/I*Events.cs] +dotnet_diagnostic.CS1572.severity = none # docblock has 'param' tag for missing parameter -- this is deliberate to let mods use diff --git a/build/common.targets b/build/common.targets index f1b5e59e0..a0625d4b4 100644 --- a/build/common.targets +++ b/build/common.targets @@ -7,7 +7,7 @@ repo. It imports the other MSBuild files as needed. - 4.0.8 + 4.1.0 SMAPI latest $(AssemblySearchPaths);{GAC} diff --git a/build/deploy-local-smapi.targets b/build/deploy-local-smapi.targets index d30967fe5..bd84ee11b 100644 --- a/build/deploy-local-smapi.targets +++ b/build/deploy-local-smapi.targets @@ -40,11 +40,6 @@ This assumes `find-game-folder.targets` has already been imported and validated. - - - - - diff --git a/build/unix/prepare-install-package.sh b/build/unix/prepare-install-package.sh index cfd24afd4..c03bb09a2 100755 --- a/build/unix/prepare-install-package.sh +++ b/build/unix/prepare-install-package.sh @@ -62,7 +62,7 @@ for folder in ${folders[@]}; do echo "Compiling installer for $folder..." echo "-------------------------------------------------" - dotnet publish src/SMAPI.Installer --configuration $buildConfig -v minimal --runtime "$runtime" -p:OS="$msbuildPlatformName" -p:GamePath="$gamePath" -p:CopyToGameFolder="false" -p:PublishTrimmed=True -p:TrimMode=Link --self-contained true + dotnet publish src/SMAPI.Installer --configuration $buildConfig -v minimal --runtime "$runtime" -p:OS="$msbuildPlatformName" -p:GamePath="$gamePath" -p:CopyToGameFolder="false" --self-contained true echo "" echo "" @@ -151,11 +151,6 @@ for folder in ${folders[@]}; do cp "$smapiBin/System.Management.dll" "$bundlePath/smapi-internal" fi - # copy legacy .NET dependencies (remove in SMAPI 4.0.0) - cp "$smapiBin/System.Configuration.ConfigurationManager.dll" "$bundlePath/smapi-internal" - cp "$smapiBin/System.Runtime.Caching.dll" "$bundlePath/smapi-internal" - cp "$smapiBin/System.Security.Permissions.dll" "$bundlePath/smapi-internal" - # copy bundled mods for modName in ${bundleModNames[@]}; do fromPath="src/SMAPI.Mods.$modName/bin/$buildConfig/$runtime/publish" diff --git a/build/windows/prepare-install-package.ps1 b/build/windows/prepare-install-package.ps1 index 48c013ff0..e6d7cd474 100644 --- a/build/windows/prepare-install-package.ps1 +++ b/build/windows/prepare-install-package.ps1 @@ -79,7 +79,7 @@ foreach ($folder in $folders) { echo "Compiling installer for $folder..." echo "-------------------------------------------------" - dotnet publish src/SMAPI.Installer --configuration $buildConfig -v minimal --runtime "$runtime" --framework "$framework" -p:OS="$msbuildPlatformName" -p:TargetFrameworks="$framework" -p:GamePath="$gamePath" -p:CopyToGameFolder="false" -p:PublishTrimmed=True -p:TrimMode=Link --self-contained true + dotnet publish src/SMAPI.Installer --configuration $buildConfig -v minimal --runtime "$runtime" --framework "$framework" -p:OS="$msbuildPlatformName" -p:TargetFrameworks="$framework" -p:GamePath="$gamePath" -p:CopyToGameFolder="false" --self-contained true echo "" echo "" @@ -177,11 +177,6 @@ foreach ($folder in $folders) { cp "$smapiBin/System.Management.dll" "$bundlePath/smapi-internal" } - # copy legacy .NET dependencies (remove in SMAPI 4.0.0) - cp "$smapiBin/System.Configuration.ConfigurationManager.dll" "$bundlePath/smapi-internal" - cp "$smapiBin/System.Runtime.Caching.dll" "$bundlePath/smapi-internal" - cp "$smapiBin/System.Security.Permissions.dll" "$bundlePath/smapi-internal" - # copy bundled mods foreach ($modName in $bundleModNames) { $fromPath = "src/SMAPI.Mods.$modName/bin/$buildConfig/$runtime/publish" diff --git a/docs/release-notes.md b/docs/release-notes.md index 133389a16..212737f0b 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,6 +1,68 @@ ← [README](README.md) # Release notes +## 4.1.0 +Released 04 November 2024 for Stardew Valley 1.6.9 or later. See [release highlights](https://www.patreon.com/posts/115304143). + +* For players: + * Updated for Stardew Valley 1.6.9. + * SMAPI now auto-detects missing or modified content files, and logs a warning if found. + * SMAPI now uses iTerm2 on macOS if it's installed (thanks to yinxiangshi!). + * SMAPI now enables GameMode on Linux if it's installed (thanks to noah1510!). + * SMAPI now anonymizes paths containing your home path (thanks to AnotherPillow!). + * Removed confusing "Found X mods with warnings:" log message. + * The installer on Linux now tries to open a terminal if needed (thanks to HoodedDeath!). + * Fixed installer not detecting Linux Flatpak install paths. + * Fixed various content issues for non-English players (e.g. content packs not detecting the current festival correctly). + * Fixed dependencies on obsolete redundant mods not ignored in some cases. + * Fixed issues in Console Commands: + * Fixed `list_items` & `player_add` not handling dried items, pickled forage, smoked fish, and specific bait correctly. + * Fixed `list_items` & `player_add` listing some flooring & wallpaper items twice. + * Fixed `show_data_files` & `show_game_files` no longer working correctly (thanks to jakerosado!). + * Fixed some mod overlays mispositioned when your UI scale is non-100% and zoom level is 100%. + * Fixed incorrect 'direct console access' warnings. + * Updated mod compatibility list. + +* For mod authors: + * Added support for [private assembly references](https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Manifest#Private_assemblies) (thanks to Shockah!). + * Added support for [i18n subfolders](https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Translation#i18n_folder) (thanks to spacechase0!). + * Added asset propagation for `Data/ChairTiles`. + * Added new C# API methods: + * Added `DoesAssetExist` methods to `helper.GameContent` and `helper.ModContent` (thanks to KhloeLeclair!). + * Added scroll wheel suppression via `helper.Input.SuppressScrollWheel()` (thanks to MercuriusXeno!). + * Added `PathUtilities.AnonymizePathForDisplay` to anonymize home paths (thanks to AnotherPillow!). + * Added parameter docs to event interfaces. This lets you fully document your event handlers like `/// `. + * Translations now support [gender switch blocks](https://stardewvalleywiki.com/Modding:Dialogue#Gender_switch). + * Translations now support tokens in their placeholder text. + * SMAPI no longer blocks map edits which change the tilesheet order, since that no longer causes crashes in Stardew Valley 1.6.9. + * The SMAPI log now includes the assembly version of each loaded mod (thanks to spacechase0!). + * Updated dependencies, including... + * [FluentHttpClient](https://github.com/Pathoschild/FluentHttpClient#readme) 4.3.0 → 4.4.1 (see [changes](https://github.com/Pathoschild/FluentHttpClient/blob/develop/RELEASE-NOTES.md#441)); + * [Pintail](https://github.com/Nanoray-pl/Pintail) 2.3.0 → 2.6.0 (see [changes](https://github.com/Nanoray-pl/Pintail/blob/master/docs/release-notes.md#260)). + * Fixed `content.Load` ignoring language override in recent versions. + * Fixed player sprites and building paint masks not always propagated on change. + * Fixed `.tmx` map tile sizes being premultiplied, which is inconsistent with the game's `.tbin` maps. + * Fixed various edge cases when chaining methods on `Translation` instances. + +* For the update check server: + * Rewrote update checks for mods on CurseForge and ModDrop to use new export API endpoints. + _This should result in much faster update checks for those sites, and less chance of update-check errors when their servers are under heavy load._ + * Added workaround for CurseForge auto-syncing prerelease versions with an invalid version number. + +* For the log parser: + * Clicking a checkbox in the mod list now always only changes that checkbox, to allow hiding a single mod. + * Fixed the wrong game folder path shown if the `Mods` folder path was customized. + +* For the JSON validator: + * Updated for Content Patcher 2.1.0 – 2.4.0, and fixed validation for `Priority` fields. + * Fixed incorrect errors shown for.. + * some valid `Entries`, `Fields`, `MapProperties`, `MapTiles`, and `When` field values; + * `CustomLocations` entries which use the new [unique string ID](https://stardewvalleywiki.com/Modding:Common_data_field_types#Unique_string_ID) format; + * `AddWarps` warps when a location name contains a dot. + +* For the web API: + * The [anonymized metrics for update check requests](technical/web.md#modsmetrics) now counts requests by SMAPI and game version. + ## 4.0.8 Released 21 April 2024 for Stardew Valley 1.6.4 or later. @@ -19,7 +81,7 @@ Released 21 April 2024 for Stardew Valley 1.6.4 or later. Released 18 April 2024 for Stardew Valley 1.6.4 or later. * For players: - * Updated for Stardew Valley 1.6.4. **This drops compatibility with Stardew Valley 1.6.0–1.6.3.** + * Updated for Stardew Valley 1.6.4. * The installer now lists detected game folders with an incompatible version to simplify troubleshooting. * When the installer asks for a game folder path, entering an incorrect path to a file inside it will now still select the folder. * Fixed installer not detecting 1.6 compatibility branch. @@ -119,6 +181,7 @@ Released 19 March 2024 for Stardew Valley 1.6.0 or later. See [release highlight * For the web UI: * Updated JSON validator for Content Patcher 2.0.0. + * Added [anonymized metrics for update check requests](technical/web.md#modsmetrics). * Fixed uploaded log/JSON file expiry alway shown as renewed. * Fixed update check for mods with a prerelease version tag not recognized by the ModDrop API. SMAPI now parses the version itself if needed. diff --git a/docs/technical/mod-package-release-notes.md b/docs/technical/mod-package-release-notes.md new file mode 100644 index 000000000..2faaac475 --- /dev/null +++ b/docs/technical/mod-package-release-notes.md @@ -0,0 +1,217 @@ +← [mod build config](./mod-build-config.md) + +## Release notes +## Upcoming release +* Removed build warnings for implicit net field conversions, which were removed in Stardew Valley 1.6.9. + +## 4.3.0 +Released 06 October 2024 for SMAPI 3.13.0 or later. + +* You can now [bundle content packs with your mod](mod-package.md#bundle-content-packs) (thanks to abhiaagarwal!). + +### 4.2.0 +Released 05 September 2024 for SMAPI 3.13.0 or later. + +* Added support for `i18n` subfolders (thanks to spacechase0!). +* Updated dependencies. + +### 4.1.1 +Released 24 June 2023 for SMAPI 3.13.0 or later. + +* Replaced `.pdb` files with embedded symbols by default. This fixes logged errors not having line numbers on Linux/macOS. + +### 4.1.0 +Released 08 January 2023 for SMAPI 3.13.0 or later. + +* Added `manifest.json` format validation on build (thanks to tylergibbs2!). +* Fixed game DLLs not excluded from the release zip when they're referenced explicitly but `BundleExtraAssemblies` isn't set. + +### 4.0.2 +Released 09 October 2022 for SMAPI 3.13.0 or later. + +* Switched to the newer crossplatform `portable` debug symbols (thanks to lanturnalis!). +* Fixed `BundleExtraAssemblies` option being partly case-sensitive. +* Fixed `BundleExtraAssemblies` not applying `All` value to game assemblies. + +### 4.0.1 +Released 14 April 2022 for SMAPI 3.13.0 or later. + +* Added detection for Xbox app game folders. +* Fixed "_conflicts between different versions of Microsoft.Win32.Registry_" warnings in recent SMAPI versions. +* Internal refactoring. + +### 4.0.0 +Released 30 November 2021 for SMAPI 3.13.0 or later. + +* Updated for Stardew Valley 1.5.5 and SMAPI 3.13.0. (Older versions are no longer supported.) +* Added `IgnoreModFilePaths` option to ignore literal paths. +* Added `BundleExtraAssemblies` option to copy bundled DLLs into the mod zip/folder. +* Removed the `GameExecutableName` and `GameFramework` options (since they now have the same value + on all platforms). +* Removed the `CopyModReferencesToBuildOutput` option (superseded by `BundleExtraAssemblies`). +* Improved analyzer performance by enabling parallel execution. + +**Migration guide for mod authors:** +1. See [_migrate to 64-bit_](https://stardewvalleywiki.com/Modding:Migrate_to_64-bit_on_Windows) and + [_migrate to Stardew Valley 1.5.5_](https://stardewvalleywiki.com/Modding:Migrate_to_Stardew_Valley_1.5.5). +2. Possible changes in your `.csproj` or `.targets` files: + * Replace `$(GameExecutableName)` with `Stardew Valley`. + * Replace `$(GameFramework)` with `MonoGame` and remove any XNA Framework-specific logic. + * Replace `true` with + `Game`. + * If you need to bundle extra DLLs besides your mod DLL, see the [`BundleExtraAssemblies` + documentation](#configure). + +### 3.3.0 +Released 30 March 2021 for SMAPI 3.0.0 or later. + +* Added a build warning when the mod isn't compiled for `Any CPU`. +* Added a `GameFramework` build property set to `MonoGame` or `Xna` based on the platform. This can + be overridden to change which framework it references. +* Added support for building mods against the 64-bit Linux version of the game on Windows. +* The package now suppresses the misleading 'processor architecture mismatch' warnings. + +### 3.2.2 +Released 23 September 2020 for SMAPI 3.0.0 or later. + +* Reworked and streamlined how the package is compiled. +* Added [SMAPI-ModTranslationClassBuilder](https://github.com/Pathoschild/SMAPI-ModTranslationClassBuilder) + files to the ignore list. + +### 3.2.1 +Released 11 September 2020 for SMAPI 3.0.0 or later. + +* Added more detailed logging. +* Fixed _path's format is not supported_ error when using default `Mods` path in 3.2. + +### 3.2.0 +Released 07 September 2020 for SMAPI 3.0.0 or later. + +* Added option to change `Mods` folder path. +* Rewrote documentation to make it easier to read. + +### 3.1.0 +Released 01 February 2020 for SMAPI 3.0.0 or later. + +* Added support for semantic versioning 2.0. +* `0Harmony.dll` is now ignored if the mod references Harmony directly (it's bundled with SMAPI). + +### 3.0.0 +Released 26 November 2019 for SMAPI 3.0.0 or later. + +* Updated for SMAPI 3.0 and Stardew Valley 1.4. +* Added automatic support for `assets` folders. +* Added `$(GameExecutableName)` MSBuild variable. +* Added support for projects using the simplified `.csproj` format. +* Added option to disable game debugging config. +* Added `.pdb` files to builds by default (to enable line numbers in error stack traces). +* Added optional Harmony reference. +* Fixed `Newtonsoft.Json.pdb` included in release zips when Json.NET is referenced directly. +* Fixed `` not working for `i18n` files. +* Dropped support for older versions of SMAPI and Visual Studio. +* Migrated package icon to NuGet's new format. + +### 2.2.0 +Released 28 October 2018. + +* Added support for SMAPI 2.8+ (still compatible with earlier versions). +* Added default game paths for 32-bit Windows. +* Fixed valid manifests marked invalid in some cases. + +### 2.1.0 +Released 27 July 2018. + +* Added support for Stardew Valley 1.3. +* Added support for non-mod projects. +* Added C# analyzers to warn about implicit conversions of Netcode fields in Stardew Valley 1.3. +* Added option to ignore files by regex pattern. +* Added reference to new SMAPI DLL. +* Fixed some game paths not detected by NuGet package. + +### 2.0.2 +Released 01 November 2017. + +* Fixed compatibility issue on Linux. + +### 2.0.1 +Released 11 October 2017. + +* Fixed mod deploy failing to create subfolders if they don't already exist. + +### 2.0.0 +Released 11 October 2017. + +* Added: mods are now copied into the `Mods` folder automatically (configurable). +* Added: release zips are now created automatically in your build output folder (configurable). +* Added: mod deploy and release zips now exclude Json.NET automatically, since it's provided by SMAPI. +* Added mod's version to release zip filename. +* Improved errors to simplify troubleshooting. +* Fixed release zip not having a mod folder. +* Fixed release zip failing if mod name contains characters that aren't valid in a filename. + +### 1.7.1 +Released 28 July 2017. + +* Fixed issue where i18n folders were flattened. +* The manifest/i18n files in the project now take precedence over those in the build output if both + are present. + +### 1.7.0 +Released 28 July 2017. + +* Added option to create release zips on build. +* Added reference to XNA's XACT library for audio-related mods. + +### 1.6.2 +Released 10 July 2017. + +* Further improved crossplatform game path detection. +* Removed undocumented `GamePlatform` build property. + +### 1.6.1 +Released 09 July 2017. + +* Improved crossplatform game path detection. + +### 1.6.0 +Released 05 June 2017. + +* Added support for deploying mod files into `Mods` automatically. +* Added a build error if a game folder is found, but doesn't contain Stardew Valley or SMAPI. + +### 1.5.0 +Released 23 January 2017. + +* Added support for setting a custom game path globally. +* Added default GOG path on macOS. + +### 1.4.0 +Released 11 January 2017. + +* Fixed detection of non-default game paths on 32-bit Windows. +* Removed support for SilVerPLuM (discontinued). +* Removed support for overriding the target platform (no longer needed since SMAPI crossplatforms + mods automatically). + +### 1.3.0 +Released 31 December 2016. + +* Added support for non-default game paths on Windows. + +### 1.2.0 +Released 24 October 2016. + +* Exclude game binaries from mod build output. + +### 1.1.0 +Released 21 October 2016. + +* Added support for overriding the target platform. + +### 1.0.0 +Released 21 October 2016. + +* Initial release. +* Added support for detecting the game path automatically. +* Added support for injecting XNA/MonoGame references automatically based on the OS. +* Added support for mod builders like SilVerPLuM. diff --git a/docs/technical/mod-package.md b/docs/technical/mod-package.md index 20e8c8343..54db82e76 100644 --- a/docs/technical/mod-package.md +++ b/docs/technical/mod-package.md @@ -7,6 +7,9 @@ for SMAPI mods and related tools. The package is fully compatible with Linux, ma * [Use](#use) * [Features](#features) * [Configure](#configure) + * [How to set options](#how-to-set-options) + * [Available properties](#available-properties) +* [Bundle content packs](#bundle-content-packs) * [Code warnings](#code-warnings) * [FAQs](#faqs) * [How do I set the game path?](#custom-game-path) @@ -104,98 +107,34 @@ There are two places you can put them: `GameModsPath`. ### Available properties -These are the options you can set: - -
    -
  • Game properties: - - - - - - - - - - - - - - -
    propertyeffect
    GamePath - -The absolute path to the Stardew Valley folder. This is auto-detected, so you usually don't need to -change it. - -
    GameModsPath - -The absolute path to the folder containing the game's installed mods (defaults to -`$(GamePath)/Mods`), used when deploying the mod files. - -
    -
  • - -
  • Mod build properties: - - - - - - - - - - - - - - - - - - - - - - - - - -
    propertyeffect
    EnableHarmony - -Whether to add a reference to [Harmony](https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Harmony) -(default `false`). This is only needed if you use Harmony. - -
    EnableModDeploy - -Whether to copy the mod files into your game's `Mods` folder (default `true`). - -
    EnableModZip - -Whether to create a release-ready `.zip` file in the mod project's `bin` folder (default `true`). - -
    ModFolderName - -The mod name for its folder under `Mods` and its release zip (defaults to the project name). - -
    ModZipPath - -The folder path where the release zip is created (defaults to the project's `bin` folder). - -
    -
  • - -
  • Specialized properties: - - - - - - - - - - - - - - - - - - - - - -
    propertyeffect
    BundleExtraAssemblies - -**Most mods should not change this option.** +These are the options you can set. + +#### Common properties +property | effect +-------------- | ------ +`GamePath` | The absolute path to the Stardew Valley folder. This is auto-detected, so you usually don't need to change it. +`GameModsPath` | The absolute path to the folder containing the game's installed mods (defaults to `$(GamePath)/Mods`), used when deploying the mod files. + +#### Mod build properties +property | effect +----------------- | ------ +`EnableHarmony` | Whether to add a reference to [Harmony](https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Harmony) (default `false`). This is only needed if you use Harmony. +`EnableModDeploy` | Whether to copy the mod files into your game's `Mods` folder (default `true`). +`EnableModZip` | Whether to create a release-ready `.zip` file in the mod project's `bin` folder (default `true`). +`ModFolderName` | The mod name for its folder under `Mods` and its release zip (defaults to the project name). +`ModZipPath` | The folder path where the release zip is created (defaults to the project's `bin` folder). + +#### Specialized properties +These properties should usually be left as-is. + +property | effect +----------------------- | ------ +`EnableGameDebugging` | Whether to configure the project so you can launch or debug the game through the _Debug_ menu in Visual Studio (default `true`). There's usually no reason to change this, unless it's a unit test project. +`IgnoreModFilePaths` | A comma-delimited list of literal file paths to ignore, relative to the mod's `bin` folder. Paths are case-sensitive, but path delimiters are normalized automatically. For example, this ignores a set of tilesheets: `assets/paths.png, assets/springobjects.png`. +`IgnoreModFilePatterns` | A comma-delimited list of regex patterns matching files to ignore when deploying or zipping the mod files (default empty). For crossplatform compatibility, you should replace path delimiters with `[/\\]`. For example, this excludes all `.txt` and `.pdf` files, as well as the `assets/paths.png` file: `\.txt$, \.pdf$, assets[/\\]paths.png`. + +#### `BundleExtraAssemblies` +_(Specialized)_ **Most mods should not change this option.** By default (when this is _not_ enabled), only the mod files [normally considered part of the mod](#Features) will be added to the release `.zip` and copied into the `Mods` folder (i.e. @@ -205,44 +144,12 @@ but any other DLLs won't be deployed. Enabling this option will add _all_ dependencies to the build output, then deploy _some_ of them depending on the comma-separated value(s) you set: - - - - - - - - - - - - - - - - - - - - - -
    optionresult
    ThirdParty - -Assembly files which don't match any other category. - -
    System - -Assembly files whose names start with `Microsoft.*` or `System.*`. - -
    Game - -Assembly files which are part of MonoGame, SMAPI, or Stardew Valley. - -
    All - -Equivalent to `System, Game, ThirdParty`. - -
    +option | result +------------ | ------ +`ThirdParty` | Assembly files which don't match any other category. +`System` | Assembly files whose names start with `Microsoft.*` or `System.*`. +`Game` | Assembly files which are part of MonoGame, SMAPI, or Stardew Valley. +`All` | Equivalent to `System, Game, ThirdParty`. Most mods should omit the option. Some mods may need `ThirdParty` if they bundle third-party DLLs with their mod. The other options are mainly useful for unit tests. @@ -250,50 +157,30 @@ with their mod. The other options are mainly useful for unit tests. When enabling this option, you should **manually review which files get deployed** and use the `IgnoreModFilePaths` or `IgnoreModFilePatterns` options to exclude files as needed. -
    EnableGameDebugging - -Whether to configure the project so you can launch or debug the game through the _Debug_ menu in -Visual Studio (default `true`). There's usually no reason to change this, unless it's a unit test -project. +## Bundle content packs +You can bundle any number of [content packs](https://stardewvalleywiki.com/Modding:Content_pack_frameworks) +with your main C# mod. They'll be grouped with the main mod into a parent folder automatically, +which will be copied to the `Mods` folder and included in the release zip. -
    IgnoreModFilePaths - -A comma-delimited list of literal file paths to ignore, relative to the mod's `bin` folder. Paths -are case-sensitive, but path delimiters are normalized automatically. For example, this ignores a -set of tilesheets: +To do that, add an `ItemGroup` with a `ContentPacks` line for each content pack you want to include: ```xml -assets/paths.png, assets/springobjects.png + + + + ``` -
    IgnoreModFilePatterns - -A comma-delimited list of regex patterns matching files to ignore when deploying or zipping the mod -files (default empty). For crossplatform compatibility, you should replace path delimiters with `[/\\]`. - -For example, this excludes all `.txt` and `.pdf` files, as well as the `assets/paths.png` file: +You can use these properties for each line: -```xml -\.txt$, \.pdf$, assets[/\\]paths.png -``` - -
    -
  • -
+property | effect +----------------------- | ------ +`Include` | _(Required)_ The path to the content pack folder. This can be an absolute path, or relative to the current project. +`Version` | _(Required)_ The expected version of the content pack. This should usually be the same version as your main mod, to keep update alerts in sync. The package will validate that the included content pack's manifest version matches. +`FolderName` | _(Optional)_ The content pack folder name to create. Defaults to the folder name from `Include`. +`ValidateManifest` | _(Optional)_ Whether to validate that the included mod has a valid `manifest.json` file and version. Default `true`. +`IgnoreModFilePaths` | _(Optional)_ A list of file paths to ignore (relative to the content pack's directory); see `IgnoreModFilePaths` in the main settings. Default none. +`IgnoreModFilePatterns` | _(Optional)_ A list of file regex patterns to ignore (relative to the content pack's directory); see `IgnoreModFilePatterns` in the main settings. Default none. ## Code warnings ### Overview @@ -305,44 +192,11 @@ if needed using the warning ID (shown under 'code' in the Error List). See below for help with specific warnings. -### Avoid implicit net field cast -Warning text: -> This implicitly converts '{{expression}}' from {{net type}} to {{other type}}, but -> {{net type}} has unintuitive implicit conversion rules. Consider comparing against the actual -> value instead to avoid bugs. - -Stardew Valley uses net types (like `NetBool` and `NetInt`) to handle multiplayer sync. These types -can implicitly convert to their equivalent normal values (like `bool x = new NetBool()`), but their -conversion rules are unintuitive and error-prone. For example, -`item?.category == null && item?.category != null` can both be true at once, and -`building.indoors != null` can be true for a null value. - -Suggested fix: -* Some net fields have an equivalent non-net property like `monster.Health` (`int`) instead of - `monster.health` (`NetInt`). The package will add a separate [AvoidNetField](#avoid-net-field) warning for - these. Use the suggested property instead. -* For a reference type (i.e. one that can contain `null`), you can use the `.Value` property: - ```c# - if (building.indoors.Value == null) - ``` - Or convert the value before comparison: - ```c# - GameLocation indoors = building.indoors; - if(indoors == null) - // ... - ``` -* For a value type (i.e. one that can't contain `null`), check if the object is null (if applicable) - and compare with `.Value`: - ```cs - if (item != null && item.category.Value == 0) - ``` - ### Avoid net field Warning text: > '{{expression}}' is a {{net type}} field; consider using the {{property name}} property instead. -Your code accesses a net field, which has some unusual behavior (see [AvoidImplicitNetFieldCast](#avoid-implicit-net-field-cast)). -This field has an equivalent non-net property that avoids those issues. +Your code accesses a net field, but the game has an equivalent non-net property. Suggested fix: access the suggested property name instead. @@ -415,204 +269,5 @@ project | purpose The NuGet package is generated automatically in `StardewModdingAPI.ModBuildConfig`'s `bin` folder when you compile it. -## Release notes -## 4.1.1 -Released 24 June 2023 for SMAPI 3.13.0 or later. - -* Replaced `.pdb` files with embedded symbols by default. This fixes logged errors not having line numbers on Linux/macOS. - -### 4.1.0 -Released 08 January 2023 for SMAPI 3.13.0 or later. - -* Added `manifest.json` format validation on build (thanks to tylergibbs2!). -* Fixed game DLLs not excluded from the release zip when they're referenced explicitly but `BundleExtraAssemblies` isn't set. - -### 4.0.2 -Released 09 October 2022 for SMAPI 3.13.0 or later. - -* Switched to the newer crossplatform `portable` debug symbols (thanks to lanturnalis!). -* Fixed `BundleExtraAssemblies` option being partly case-sensitive. -* Fixed `BundleExtraAssemblies` not applying `All` value to game assemblies. - -### 4.0.1 -Released 14 April 2022 for SMAPI 3.13.0 or later. - -* Added detection for Xbox app game folders. -* Fixed "_conflicts between different versions of Microsoft.Win32.Registry_" warnings in recent SMAPI versions. -* Internal refactoring. - -### 4.0.0 -Released 30 November 2021 for SMAPI 3.13.0 or later. - -* Updated for Stardew Valley 1.5.5 and SMAPI 3.13.0. (Older versions are no longer supported.) -* Added `IgnoreModFilePaths` option to ignore literal paths. -* Added `BundleExtraAssemblies` option to copy bundled DLLs into the mod zip/folder. -* Removed the `GameExecutableName` and `GameFramework` options (since they now have the same value - on all platforms). -* Removed the `CopyModReferencesToBuildOutput` option (superseded by `BundleExtraAssemblies`). -* Improved analyzer performance by enabling parallel execution. - -**Migration guide for mod authors:** -1. See [_migrate to 64-bit_](https://stardewvalleywiki.com/Modding:Migrate_to_64-bit_on_Windows) and - [_migrate to Stardew Valley 1.5.5_](https://stardewvalleywiki.com/Modding:Migrate_to_Stardew_Valley_1.5.5). -2. Possible changes in your `.csproj` or `.targets` files: - * Replace `$(GameExecutableName)` with `Stardew Valley`. - * Replace `$(GameFramework)` with `MonoGame` and remove any XNA Framework-specific logic. - * Replace `true` with - `Game`. - * If you need to bundle extra DLLs besides your mod DLL, see the [`BundleExtraAssemblies` - documentation](#configure). - -### 3.3.0 -Released 30 March 2021 for SMAPI 3.0.0 or later. - -* Added a build warning when the mod isn't compiled for `Any CPU`. -* Added a `GameFramework` build property set to `MonoGame` or `Xna` based on the platform. This can - be overridden to change which framework it references. -* Added support for building mods against the 64-bit Linux version of the game on Windows. -* The package now suppresses the misleading 'processor architecture mismatch' warnings. - -### 3.2.2 -Released 23 September 2020 for SMAPI 3.0.0 or later. - -* Reworked and streamlined how the package is compiled. -* Added [SMAPI-ModTranslationClassBuilder](https://github.com/Pathoschild/SMAPI-ModTranslationClassBuilder) - files to the ignore list. - -### 3.2.1 -Released 11 September 2020 for SMAPI 3.0.0 or later. - -* Added more detailed logging. -* Fixed _path's format is not supported_ error when using default `Mods` path in 3.2. - -### 3.2.0 -Released 07 September 2020 for SMAPI 3.0.0 or later. - -* Added option to change `Mods` folder path. -* Rewrote documentation to make it easier to read. - -### 3.1.0 -Released 01 February 2020 for SMAPI 3.0.0 or later. - -* Added support for semantic versioning 2.0. -* `0Harmony.dll` is now ignored if the mod references Harmony directly (it's bundled with SMAPI). - -### 3.0.0 -Released 26 November 2019 for SMAPI 3.0.0 or later. - -* Updated for SMAPI 3.0 and Stardew Valley 1.4. -* Added automatic support for `assets` folders. -* Added `$(GameExecutableName)` MSBuild variable. -* Added support for projects using the simplified `.csproj` format. -* Added option to disable game debugging config. -* Added `.pdb` files to builds by default (to enable line numbers in error stack traces). -* Added optional Harmony reference. -* Fixed `Newtonsoft.Json.pdb` included in release zips when Json.NET is referenced directly. -* Fixed `` not working for `i18n` files. -* Dropped support for older versions of SMAPI and Visual Studio. -* Migrated package icon to NuGet's new format. - -### 2.2.0 -Released 28 October 2018. - -* Added support for SMAPI 2.8+ (still compatible with earlier versions). -* Added default game paths for 32-bit Windows. -* Fixed valid manifests marked invalid in some cases. - -### 2.1.0 -Released 27 July 2018. - -* Added support for Stardew Valley 1.3. -* Added support for non-mod projects. -* Added C# analyzers to warn about implicit conversions of Netcode fields in Stardew Valley 1.3. -* Added option to ignore files by regex pattern. -* Added reference to new SMAPI DLL. -* Fixed some game paths not detected by NuGet package. - -### 2.0.2 -Released 01 November 2017. - -* Fixed compatibility issue on Linux. - -### 2.0.1 -Released 11 October 2017. - -* Fixed mod deploy failing to create subfolders if they don't already exist. - -### 2.0.0 -Released 11 October 2017. - -* Added: mods are now copied into the `Mods` folder automatically (configurable). -* Added: release zips are now created automatically in your build output folder (configurable). -* Added: mod deploy and release zips now exclude Json.NET automatically, since it's provided by SMAPI. -* Added mod's version to release zip filename. -* Improved errors to simplify troubleshooting. -* Fixed release zip not having a mod folder. -* Fixed release zip failing if mod name contains characters that aren't valid in a filename. - -### 1.7.1 -Released 28 July 2017. - -* Fixed issue where i18n folders were flattened. -* The manifest/i18n files in the project now take precedence over those in the build output if both - are present. - -### 1.7.0 -Released 28 July 2017. - -* Added option to create release zips on build. -* Added reference to XNA's XACT library for audio-related mods. - -### 1.6.2 -Released 10 July 2017. - -* Further improved crossplatform game path detection. -* Removed undocumented `GamePlatform` build property. - -### 1.6.1 -Released 09 July 2017. - -* Improved crossplatform game path detection. - -### 1.6.0 -Released 05 June 2017. - -* Added support for deploying mod files into `Mods` automatically. -* Added a build error if a game folder is found, but doesn't contain Stardew Valley or SMAPI. - -### 1.5.0 -Released 23 January 2017. - -* Added support for setting a custom game path globally. -* Added default GOG path on macOS. - -### 1.4.0 -Released 11 January 2017. - -* Fixed detection of non-default game paths on 32-bit Windows. -* Removed support for SilVerPLuM (discontinued). -* Removed support for overriding the target platform (no longer needed since SMAPI crossplatforms - mods automatically). - -### 1.3.0 -Released 31 December 2016. - -* Added support for non-default game paths on Windows. - -### 1.2.0 -Released 24 October 2016. - -* Exclude game binaries from mod build output. - -### 1.1.0 -Released 21 October 2016. - -* Added support for overriding the target platform. - -### 1.0.0 -Released 21 October 2016. - -* Initial release. -* Added support for detecting the game path automatically. -* Added support for injecting XNA/MonoGame references automatically based on the OS. -* Added support for mod builders like SilVerPLuM. +## See also +* [Release notes](mod-package-release-notes.md) diff --git a/docs/technical/smapi.md b/docs/technical/smapi.md index b7b6afabb..828961570 100644 --- a/docs/technical/smapi.md +++ b/docs/technical/smapi.md @@ -86,11 +86,9 @@ folder before compiling. ## Prepare a release ### On any platform -**⚠ Ideally we'd have one set of instructions for all platforms. The instructions in this section -will produce a fully functional release for all supported platforms, _except_ that the application -icon for SMAPI on Windows will disappear due to [.NET runtime bug -3828](https://github.com/dotnet/runtime/issues/3828). Until that's fixed, see the _[on -Windows](#on-windows)_ section below to create a build that retains the icon.** +_This is the unified process that works on any platform. However, it needs a few extra steps on +Windows (e.g. running Steam in WSL); see ['On Windows'](#on-windows) below for an alternative quick +option._ #### First-time setup 1. On Windows only: @@ -136,6 +134,10 @@ Windows](#on-windows)_ section below to create a build that retains the icon.** release | `` | `4.0.0` ### On Windows +_This is the alternative quick process for Windows only. This avoids needing Steam installed on WSL, +and can be used to create Windows-only builds without using WSL at all. See ['on any platform'](#on-any-platform) +above for the unified process._ + #### First-time setup 1. Set up Windows Subsystem for Linux (WSL): 1. [Install WSL](https://docs.microsoft.com/en-us/windows/wsl/install). diff --git a/docs/technical/web.md b/docs/technical/web.md index fefe15353..4b99436b7 100644 --- a/docs/technical/web.md +++ b/docs/technical/web.md @@ -189,7 +189,7 @@ may be useful to external tools. Example request: ```js -POST https://smapi.io/api/v3.0/mods +POST https://smapi.io/api/v4.0.0/mods { "mods": [ { @@ -199,8 +199,8 @@ POST https://smapi.io/api/v3.0/mods "isBroken": false } ], - "apiVersion": "3.0.0", - "gameVersion": "1.4.0", + "apiVersion": "4.0.0", + "gameVersion": "1.6.9", "platform": "Windows", "includeExtendedMetadata": true } @@ -329,7 +329,7 @@ deployed or restarted. Example request: ```js -GET https://smapi.io/api/v3.0/mods/metrics +GET https://smapi.io/api/v4.0.0/mods/metrics ``` ## Short URLs diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props new file mode 100644 index 000000000..7efb352b8 --- /dev/null +++ b/src/Directory.Packages.props @@ -0,0 +1,39 @@ + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/SMAPI.Installer/Enums/ScriptAction.cs b/src/SMAPI.Installer/Enums/ScriptAction.cs index 27f649a6a..a812972d6 100644 --- a/src/SMAPI.Installer/Enums/ScriptAction.cs +++ b/src/SMAPI.Installer/Enums/ScriptAction.cs @@ -1,12 +1,11 @@ -namespace StardewModdingApi.Installer.Enums +namespace StardewModdingApi.Installer.Enums; + +/// The action to perform. +internal enum ScriptAction { - /// The action to perform. - internal enum ScriptAction - { - /// Install SMAPI to the game directory. - Install, + /// Install SMAPI to the game directory. + Install, - /// Remove SMAPI from the game directory. - Uninstall - } + /// Remove SMAPI from the game directory. + Uninstall } diff --git a/src/SMAPI.Installer/Framework/InstallerContext.cs b/src/SMAPI.Installer/Framework/InstallerContext.cs index 44c17d312..add7f6fae 100644 --- a/src/SMAPI.Installer/Framework/InstallerContext.cs +++ b/src/SMAPI.Installer/Framework/InstallerContext.cs @@ -3,56 +3,55 @@ using StardewModdingAPI.Toolkit.Framework.GameScanning; using StardewModdingAPI.Toolkit.Utilities; -namespace StardewModdingAPI.Installer.Framework +namespace StardewModdingAPI.Installer.Framework; + +/// The installer context. +internal class InstallerContext { - /// The installer context. - internal class InstallerContext + /********* + ** Fields + *********/ + /// The underlying toolkit game scanner. + private readonly GameScanner GameScanner = new(); + + + /********* + ** Accessors + *********/ + /// The current OS. + public Platform Platform { get; } + + /// The human-readable OS name and version. + public string PlatformName { get; } + + /// Whether the installer is running on Windows. + public bool IsWindows => this.Platform == Platform.Windows; + + /// Whether the installer is running on a Unix OS (including Linux or macOS). + public bool IsUnix => !this.IsWindows; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + public InstallerContext() + { + this.Platform = EnvironmentUtility.DetectPlatform(); + this.PlatformName = EnvironmentUtility.GetFriendlyPlatformName(this.Platform); + } + + /// Get the installer's version number. + public ISemanticVersion GetInstallerVersion() + { + var raw = this.GetType().Assembly.GetName().Version!; + return new SemanticVersion(raw); + } + + /// Get whether a folder seems to contain the game, and which version it contains if so. + /// The folder to check. + public GameFolderType GetGameFolderType(DirectoryInfo dir) { - /********* - ** Fields - *********/ - /// The underlying toolkit game scanner. - private readonly GameScanner GameScanner = new(); - - - /********* - ** Accessors - *********/ - /// The current OS. - public Platform Platform { get; } - - /// The human-readable OS name and version. - public string PlatformName { get; } - - /// Whether the installer is running on Windows. - public bool IsWindows => this.Platform == Platform.Windows; - - /// Whether the installer is running on a Unix OS (including Linux or macOS). - public bool IsUnix => !this.IsWindows; - - - /********* - ** Public methods - *********/ - /// Construct an instance. - public InstallerContext() - { - this.Platform = EnvironmentUtility.DetectPlatform(); - this.PlatformName = EnvironmentUtility.GetFriendlyPlatformName(this.Platform); - } - - /// Get the installer's version number. - public ISemanticVersion GetInstallerVersion() - { - var raw = this.GetType().Assembly.GetName().Version!; - return new SemanticVersion(raw); - } - - /// Get whether a folder seems to contain the game, and which version it contains if so. - /// The folder to check. - public GameFolderType GetGameFolderType(DirectoryInfo dir) - { - return this.GameScanner.GetGameFolderType(dir); - } + return this.GameScanner.GetGameFolderType(dir); } } diff --git a/src/SMAPI.Installer/Framework/InstallerPaths.cs b/src/SMAPI.Installer/Framework/InstallerPaths.cs index 0976ecebf..98a21d8d9 100644 --- a/src/SMAPI.Installer/Framework/InstallerPaths.cs +++ b/src/SMAPI.Installer/Framework/InstallerPaths.cs @@ -1,90 +1,89 @@ using System.IO; using StardewModdingAPI.Toolkit.Framework; -namespace StardewModdingAPI.Installer.Framework +namespace StardewModdingAPI.Installer.Framework; + +/// Manages paths for the SMAPI installer. +internal class InstallerPaths { - /// Manages paths for the SMAPI installer. - internal class InstallerPaths + /********* + ** Accessors + *********/ + /**** + ** Main folders + ****/ + /// The directory path containing the files to copy into the game folder. + public DirectoryInfo BundleDir { get; } + + /// The directory containing the installed game. + public DirectoryInfo GameDir { get; } + + /// The directory into which to install mods. + public DirectoryInfo ModsDir { get; } + + /**** + ** Installer paths + ****/ + /// The full path to directory path containing the files to copy into the game folder. + public string BundlePath => this.BundleDir.FullName; + + /// The full path to the backup API user settings folder, if applicable. + public string BundleApiUserConfigPath { get; } + + /**** + ** Game paths + ****/ + /// The full path to the directory containing the installed game. + public string GamePath => this.GameDir.FullName; + + /// The full path to the directory into which to install mods. + public string ModsPath => this.ModsDir.FullName; + + /// The full path to SMAPI's internal configuration file. + public string ApiConfigPath { get; } + + /// The full path to the user's config overrides file. + public string ApiUserConfigPath { get; } + + /// The full path to the installed game DLL. + public string GameDllPath { get; } + + /// The full path to the installed SMAPI executable file. + public string UnixSmapiExecutablePath { get; } + + /// The full path to the vanilla game launch script on Linux/macOS. + public string VanillaLaunchScriptPath { get; } + + /// The full path to the installed SMAPI launch script on Linux/macOS before it's renamed. + public string NewLaunchScriptPath { get; } + + /// The full path to the backed up game launch script on Linux/macOS after SMAPI is installed. + public string BackupLaunchScriptPath { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The directory path containing the files to copy into the game folder. + /// The directory path for the installed game. + public InstallerPaths(DirectoryInfo bundleDir, DirectoryInfo gameDir) { - /********* - ** Accessors - *********/ - /**** - ** Main folders - ****/ - /// The directory path containing the files to copy into the game folder. - public DirectoryInfo BundleDir { get; } - - /// The directory containing the installed game. - public DirectoryInfo GameDir { get; } - - /// The directory into which to install mods. - public DirectoryInfo ModsDir { get; } - - /**** - ** Installer paths - ****/ - /// The full path to directory path containing the files to copy into the game folder. - public string BundlePath => this.BundleDir.FullName; - - /// The full path to the backup API user settings folder, if applicable. - public string BundleApiUserConfigPath { get; } - - /**** - ** Game paths - ****/ - /// The full path to the directory containing the installed game. - public string GamePath => this.GameDir.FullName; - - /// The full path to the directory into which to install mods. - public string ModsPath => this.ModsDir.FullName; - - /// The full path to SMAPI's internal configuration file. - public string ApiConfigPath { get; } - - /// The full path to the user's config overrides file. - public string ApiUserConfigPath { get; } - - /// The full path to the installed game DLL. - public string GameDllPath { get; } - - /// The full path to the installed SMAPI executable file. - public string UnixSmapiExecutablePath { get; } - - /// The full path to the vanilla game launch script on Linux/macOS. - public string VanillaLaunchScriptPath { get; } - - /// The full path to the installed SMAPI launch script on Linux/macOS before it's renamed. - public string NewLaunchScriptPath { get; } - - /// The full path to the backed up game launch script on Linux/macOS after SMAPI is installed. - public string BackupLaunchScriptPath { get; } - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The directory path containing the files to copy into the game folder. - /// The directory path for the installed game. - public InstallerPaths(DirectoryInfo bundleDir, DirectoryInfo gameDir) - { - // base paths - this.BundleDir = bundleDir; - this.GameDir = gameDir; - this.ModsDir = new DirectoryInfo(Path.Combine(gameDir.FullName, "Mods")); - this.GameDllPath = Path.Combine(gameDir.FullName, Constants.GameDllName); - - // launch scripts - this.VanillaLaunchScriptPath = Path.Combine(gameDir.FullName, "StardewValley"); - this.NewLaunchScriptPath = Path.Combine(gameDir.FullName, "unix-launcher.sh"); - this.BackupLaunchScriptPath = Path.Combine(gameDir.FullName, "StardewValley-original"); - this.UnixSmapiExecutablePath = Path.Combine(gameDir.FullName, "StardewModdingAPI"); - - // internal files - this.BundleApiUserConfigPath = Path.Combine(bundleDir.FullName, "smapi-internal", "config.user.json"); - this.ApiConfigPath = Path.Combine(gameDir.FullName, "smapi-internal", "config.json"); - this.ApiUserConfigPath = Path.Combine(gameDir.FullName, "smapi-internal", "config.user.json"); - } + // base paths + this.BundleDir = bundleDir; + this.GameDir = gameDir; + this.ModsDir = new DirectoryInfo(Path.Combine(gameDir.FullName, "Mods")); + this.GameDllPath = Path.Combine(gameDir.FullName, Constants.GameDllName); + + // launch scripts + this.VanillaLaunchScriptPath = Path.Combine(gameDir.FullName, "StardewValley"); + this.NewLaunchScriptPath = Path.Combine(gameDir.FullName, "unix-launcher.sh"); + this.BackupLaunchScriptPath = Path.Combine(gameDir.FullName, "StardewValley-original"); + this.UnixSmapiExecutablePath = Path.Combine(gameDir.FullName, "StardewModdingAPI"); + + // internal files + this.BundleApiUserConfigPath = Path.Combine(bundleDir.FullName, "smapi-internal", "config.user.json"); + this.ApiConfigPath = Path.Combine(gameDir.FullName, "smapi-internal", "config.json"); + this.ApiUserConfigPath = Path.Combine(gameDir.FullName, "smapi-internal", "config.user.json"); } } diff --git a/src/SMAPI.Installer/InteractiveInstaller.cs b/src/SMAPI.Installer/InteractiveInstaller.cs index bfdac4949..f25f7cfac 100644 --- a/src/SMAPI.Installer/InteractiveInstaller.cs +++ b/src/SMAPI.Installer/InteractiveInstaller.cs @@ -15,864 +15,863 @@ using StardewModdingAPI.Toolkit.Framework.ModScanning; using StardewModdingAPI.Toolkit.Utilities; -namespace StardewModdingApi.Installer +namespace StardewModdingApi.Installer; + +/// Interactively performs the install and uninstall logic. +internal class InteractiveInstaller { - /// Interactively performs the install and uninstall logic. - internal class InteractiveInstaller + /********* + ** Fields + *********/ + /// The absolute path to the directory containing the files to copy into the game folder. + private readonly string BundlePath; + + /// The mod IDs which the installer should allow as bundled mods. + private readonly string[] BundledModIDs = { + "SMAPI.SaveBackup", + "SMAPI.ConsoleCommands" + }; + + /// Get the absolute file or folder paths to remove when uninstalling SMAPI. + /// The folder for Stardew Valley and SMAPI. + /// The folder for SMAPI mods. + [SuppressMessage("ReSharper", "StringLiteralTypo", Justification = "These are valid file names.")] + private IEnumerable GetUninstallPaths(DirectoryInfo installDir, DirectoryInfo modsDir) + { + string GetInstallPath(string path) => Path.Combine(installDir.FullName, path); + + // installed files + yield return GetInstallPath("StardewModdingAPI"); // Linux/macOS only + yield return GetInstallPath("StardewModdingAPI.deps.json"); + yield return GetInstallPath("StardewModdingAPI.dll"); + yield return GetInstallPath("StardewModdingAPI.exe"); + yield return GetInstallPath("StardewModdingAPI.exe.config"); + yield return GetInstallPath("StardewModdingAPI.exe.mdb"); // before 3.18.4 (Linux/macOS only) + yield return GetInstallPath("StardewModdingAPI.pdb"); // before 3.18.4 (Windows only) + yield return GetInstallPath("StardewModdingAPI.runtimeconfig.json"); + yield return GetInstallPath("StardewModdingAPI.xml"); + yield return GetInstallPath("smapi-internal"); + yield return GetInstallPath("steam_appid.txt"); + + // obsolete files + yield return GetInstallPath("libgdiplus.dylib"); // before 3.13 (macOS only) + yield return GetInstallPath(Path.Combine("Mods", ".cache")); // 1.3-1.4 + yield return GetInstallPath(Path.Combine("Mods", "ErrorHandler")); // before 4.0 (no longer needed) + yield return GetInstallPath(Path.Combine("Mods", "TrainerMod")); // before 2.0 (renamed to ConsoleCommands) + yield return GetInstallPath("Mono.Cecil.Rocks.dll"); // 1.3-1.8 + yield return GetInstallPath("StardewModdingAPI-settings.json"); // 1.0-1.4 + yield return GetInstallPath("StardewModdingAPI.AssemblyRewriters.dll"); // 1.3-2.5.5 + yield return GetInstallPath("0Harmony.dll"); // moved in 2.8 + yield return GetInstallPath("0Harmony.pdb"); // moved in 2.8 + yield return GetInstallPath("Mono.Cecil.dll"); // moved in 2.8 + yield return GetInstallPath("Newtonsoft.Json.dll"); // moved in 2.8 + yield return GetInstallPath("StardewModdingAPI.config.json"); // moved in 2.8 + yield return GetInstallPath("StardewModdingAPI.crash.marker"); // moved in 2.8 + yield return GetInstallPath("StardewModdingAPI.metadata.json"); // moved in 2.8 + yield return GetInstallPath("StardewModdingAPI.update.marker"); // moved in 2.8 + yield return GetInstallPath("StardewModdingAPI.Toolkit.dll"); // moved in 2.8 + yield return GetInstallPath("StardewModdingAPI.Toolkit.pdb"); // moved in 2.8 + yield return GetInstallPath("StardewModdingAPI.Toolkit.xml"); // moved in 2.8 + yield return GetInstallPath("StardewModdingAPI.Toolkit.CoreInterfaces.dll"); // moved in 2.8 + yield return GetInstallPath("StardewModdingAPI.Toolkit.CoreInterfaces.pdb"); // moved in 2.8 + yield return GetInstallPath("StardewModdingAPI.Toolkit.CoreInterfaces.xml"); // moved in 2.8 + yield return GetInstallPath("StardewModdingAPI-x64.exe"); // before 3.13 + + // old log files + yield return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "StardewValley", "ErrorLogs"); + } + + /// Handles writing text to the console. + private IConsoleWriter ConsoleWriter; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The absolute path to the directory containing the files to copy into the game folder. + public InteractiveInstaller(string bundlePath) + { + this.BundlePath = bundlePath; + this.ConsoleWriter = new ColorfulConsoleWriter(EnvironmentUtility.DetectPlatform()); + } + + /// Run the install or uninstall script. + /// The command line arguments. + /// + /// Initialization flow: + /// 1. Collect information (mainly OS and install path) and validate it. + /// 2. Ask the user whether to install or uninstall. + /// + /// Uninstall logic: + /// 1. On Linux/macOS: if a backup of the launcher exists, delete the launcher and restore the backup. + /// 2. Delete all files and folders in the game directory matching one of the values returned by . + /// + /// Install flow: + /// 1. Run the uninstall flow. + /// 2. Copy the SMAPI files from package/Windows or package/Mono into the game directory. + /// 3. On Linux/macOS: back up the game launcher and replace it with the SMAPI launcher. (This isn't possible on Windows, so the user needs to configure it manually.) + /// 4. Create the 'Mods' directory. + /// 5. Copy the bundled mods into the 'Mods' directory (deleting any existing versions). + /// 6. Move any mods from app data into game's mods directory. + /// + public void Run(string[] args) { /********* - ** Fields + ** Step 1: initial setup *********/ - /// The absolute path to the directory containing the files to copy into the game folder. - private readonly string BundlePath; - - /// The mod IDs which the installer should allow as bundled mods. - private readonly string[] BundledModIDs = { - "SMAPI.SaveBackup", - "SMAPI.ConsoleCommands" - }; + /**** + ** Get basic info & set window title + ****/ + ModToolkit toolkit = new(); + var context = new InstallerContext(); + Console.Title = $"SMAPI {context.GetInstallerVersion()} installer on {context.Platform} {context.PlatformName}"; + Console.WriteLine(); + + /**** + ** read command-line arguments + ****/ + // get input mode + bool allowUserInput = !args.Contains("--no-prompt"); + + // get action + bool installArg = args.Contains("--install"); + bool uninstallArg = args.Contains("--uninstall"); + if (installArg && uninstallArg) + { + this.PrintError("You can't specify both --install and --uninstall command-line flags."); + this.AwaitConfirmation(allowUserInput); + return; + } + if (!allowUserInput && !installArg && !uninstallArg) + { + this.PrintError("You must specify --install or --uninstall when running with --no-prompt."); + return; + } - /// Get the absolute file or folder paths to remove when uninstalling SMAPI. - /// The folder for Stardew Valley and SMAPI. - /// The folder for SMAPI mods. - [SuppressMessage("ReSharper", "StringLiteralTypo", Justification = "These are valid file names.")] - private IEnumerable GetUninstallPaths(DirectoryInfo installDir, DirectoryInfo modsDir) + // get game path from CLI + string? gamePathArg = null; { - string GetInstallPath(string path) => Path.Combine(installDir.FullName, path); - - // installed files - yield return GetInstallPath("StardewModdingAPI"); // Linux/macOS only - yield return GetInstallPath("StardewModdingAPI.deps.json"); - yield return GetInstallPath("StardewModdingAPI.dll"); - yield return GetInstallPath("StardewModdingAPI.exe"); - yield return GetInstallPath("StardewModdingAPI.exe.config"); - yield return GetInstallPath("StardewModdingAPI.exe.mdb"); // before 3.18.4 (Linux/macOS only) - yield return GetInstallPath("StardewModdingAPI.pdb"); // before 3.18.4 (Windows only) - yield return GetInstallPath("StardewModdingAPI.runtimeconfig.json"); - yield return GetInstallPath("StardewModdingAPI.xml"); - yield return GetInstallPath("smapi-internal"); - yield return GetInstallPath("steam_appid.txt"); - - // obsolete files - yield return GetInstallPath("libgdiplus.dylib"); // before 3.13 (macOS only) - yield return GetInstallPath(Path.Combine("Mods", ".cache")); // 1.3-1.4 - yield return GetInstallPath(Path.Combine("Mods", "ErrorHandler")); // before 4.0 (no longer needed) - yield return GetInstallPath(Path.Combine("Mods", "TrainerMod")); // before 2.0 (renamed to ConsoleCommands) - yield return GetInstallPath("Mono.Cecil.Rocks.dll"); // 1.3-1.8 - yield return GetInstallPath("StardewModdingAPI-settings.json"); // 1.0-1.4 - yield return GetInstallPath("StardewModdingAPI.AssemblyRewriters.dll"); // 1.3-2.5.5 - yield return GetInstallPath("0Harmony.dll"); // moved in 2.8 - yield return GetInstallPath("0Harmony.pdb"); // moved in 2.8 - yield return GetInstallPath("Mono.Cecil.dll"); // moved in 2.8 - yield return GetInstallPath("Newtonsoft.Json.dll"); // moved in 2.8 - yield return GetInstallPath("StardewModdingAPI.config.json"); // moved in 2.8 - yield return GetInstallPath("StardewModdingAPI.crash.marker"); // moved in 2.8 - yield return GetInstallPath("StardewModdingAPI.metadata.json"); // moved in 2.8 - yield return GetInstallPath("StardewModdingAPI.update.marker"); // moved in 2.8 - yield return GetInstallPath("StardewModdingAPI.Toolkit.dll"); // moved in 2.8 - yield return GetInstallPath("StardewModdingAPI.Toolkit.pdb"); // moved in 2.8 - yield return GetInstallPath("StardewModdingAPI.Toolkit.xml"); // moved in 2.8 - yield return GetInstallPath("StardewModdingAPI.Toolkit.CoreInterfaces.dll"); // moved in 2.8 - yield return GetInstallPath("StardewModdingAPI.Toolkit.CoreInterfaces.pdb"); // moved in 2.8 - yield return GetInstallPath("StardewModdingAPI.Toolkit.CoreInterfaces.xml"); // moved in 2.8 - yield return GetInstallPath("StardewModdingAPI-x64.exe"); // before 3.13 - - // old log files - yield return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "StardewValley", "ErrorLogs"); + int pathIndex = Array.LastIndexOf(args, "--game-path") + 1; + if (pathIndex >= 1 && args.Length >= pathIndex) + gamePathArg = args[pathIndex]; } - /// Handles writing text to the console. - private IConsoleWriter ConsoleWriter; + /**** + ** Check if correct installer + ****/ +#if SMAPI_FOR_WINDOWS + if (context.IsUnix) + { + this.PrintError($"This is the installer for Windows. Run the 'install on {context.Platform}.{(context.Platform == Platform.Mac ? "command" : "sh")}' file instead."); + this.AwaitConfirmation(allowUserInput); + return; + } +#else + if (context.IsWindows) + { + this.PrintError($"This is the installer for Linux/macOS. Run the 'install on Windows.exe' file instead."); + this.AwaitConfirmation(allowUserInput); + return; + } +#endif /********* - ** Public methods + ** Step 2: choose a theme (can't auto-detect on Linux/macOS) *********/ - /// Construct an instance. - /// The absolute path to the directory containing the files to copy into the game folder. - public InteractiveInstaller(string bundlePath) - { - this.BundlePath = bundlePath; - this.ConsoleWriter = new ColorfulConsoleWriter(EnvironmentUtility.DetectPlatform()); - } - - /// Run the install or uninstall script. - /// The command line arguments. - /// - /// Initialization flow: - /// 1. Collect information (mainly OS and install path) and validate it. - /// 2. Ask the user whether to install or uninstall. - /// - /// Uninstall logic: - /// 1. On Linux/macOS: if a backup of the launcher exists, delete the launcher and restore the backup. - /// 2. Delete all files and folders in the game directory matching one of the values returned by . - /// - /// Install flow: - /// 1. Run the uninstall flow. - /// 2. Copy the SMAPI files from package/Windows or package/Mono into the game directory. - /// 3. On Linux/macOS: back up the game launcher and replace it with the SMAPI launcher. (This isn't possible on Windows, so the user needs to configure it manually.) - /// 4. Create the 'Mods' directory. - /// 5. Copy the bundled mods into the 'Mods' directory (deleting any existing versions). - /// 6. Move any mods from app data into game's mods directory. - /// - public void Run(string[] args) + MonitorColorScheme scheme = MonitorColorScheme.AutoDetect; + if (context.IsUnix && allowUserInput) { - /********* - ** Step 1: initial setup - *********/ /**** - ** Get basic info & set window title + ** print header ****/ - ModToolkit toolkit = new(); - var context = new InstallerContext(); - Console.Title = $"SMAPI {context.GetInstallerVersion()} installer on {context.Platform} {context.PlatformName}"; + this.PrintPlain("Hi there! I'll help you install or remove SMAPI. Just a few questions first."); + this.PrintPlain("----------------------------------------------------------------------------"); Console.WriteLine(); /**** - ** read command-line arguments + ** show theme selector ****/ - // get input mode - bool allowUserInput = !args.Contains("--no-prompt"); + // get theme writers + ColorfulConsoleWriter lightBackgroundWriter = new(context.Platform, ColorfulConsoleWriter.GetDefaultColorSchemeConfig(MonitorColorScheme.LightBackground)); + ColorfulConsoleWriter darkBackgroundWriter = new(context.Platform, ColorfulConsoleWriter.GetDefaultColorSchemeConfig(MonitorColorScheme.DarkBackground)); - // get action - bool installArg = args.Contains("--install"); - bool uninstallArg = args.Contains("--uninstall"); - if (installArg && uninstallArg) - { - this.PrintError("You can't specify both --install and --uninstall command-line flags."); - this.AwaitConfirmation(allowUserInput); - return; - } - if (!allowUserInput && !installArg && !uninstallArg) - { - this.PrintError("You must specify --install or --uninstall when running with --no-prompt."); - return; - } + // print question + this.PrintPlain("Which text looks more readable?"); + Console.WriteLine(); + Console.Write(" [1] "); + lightBackgroundWriter.WriteLine("Dark text on light background", ConsoleLogLevel.Info); + Console.Write(" [2] "); + darkBackgroundWriter.WriteLine("Light text on dark background", ConsoleLogLevel.Info); + Console.WriteLine(); - // get game path from CLI - string? gamePathArg = null; + // handle choice + string choice = this.InteractivelyChoose("Type 1 or 2, then press enter.", new[] { "1", "2" }, printLine: Console.WriteLine); + switch (choice) { - int pathIndex = Array.LastIndexOf(args, "--game-path") + 1; - if (pathIndex >= 1 && args.Length >= pathIndex) - gamePathArg = args[pathIndex]; + case "1": + scheme = MonitorColorScheme.LightBackground; + this.ConsoleWriter = lightBackgroundWriter; + break; + case "2": + scheme = MonitorColorScheme.DarkBackground; + this.ConsoleWriter = darkBackgroundWriter; + break; + default: + throw new InvalidOperationException($"Unexpected action key '{choice}'."); } + } + Console.Clear(); + + /********* + ** Step 3: find game folder + *********/ + InstallerPaths paths; + { /**** - ** Check if correct installer + ** print header ****/ -#if SMAPI_FOR_WINDOWS - if (context.IsUnix) - { - this.PrintError($"This is the installer for Windows. Run the 'install on {context.Platform}.{(context.Platform == Platform.Mac ? "command" : "sh")}' file instead."); - this.AwaitConfirmation(allowUserInput); - return; - } -#else - if (context.IsWindows) + this.PrintInfo("Hi there! I'll help you install or remove SMAPI. Just a few questions first."); + this.PrintDebug($"Color scheme: {this.GetDisplayText(scheme)}"); + this.PrintDebug("----------------------------------------------------------------------------"); + Console.WriteLine(); + + /**** + ** collect details + ****/ + // get game path + DirectoryInfo? installDir = this.InteractivelyGetInstallPath(toolkit, context, gamePathArg); + if (installDir == null) { - this.PrintError($"This is the installer for Linux/macOS. Run the 'install on Windows.exe' file instead."); + this.PrintError("Failed finding your game path."); this.AwaitConfirmation(allowUserInput); return; } -#endif + // get folders + DirectoryInfo bundleDir = new(this.BundlePath); + paths = new InstallerPaths(bundleDir, installDir); + } - /********* - ** Step 2: choose a theme (can't auto-detect on Linux/macOS) - *********/ - MonitorColorScheme scheme = MonitorColorScheme.AutoDetect; - if (context.IsUnix && allowUserInput) - { - /**** - ** print header - ****/ - this.PrintPlain("Hi there! I'll help you install or remove SMAPI. Just a few questions first."); - this.PrintPlain("----------------------------------------------------------------------------"); - Console.WriteLine(); - /**** - ** show theme selector - ****/ - // get theme writers - ColorfulConsoleWriter lightBackgroundWriter = new(context.Platform, ColorfulConsoleWriter.GetDefaultColorSchemeConfig(MonitorColorScheme.LightBackground)); - ColorfulConsoleWriter darkBackgroundWriter = new(context.Platform, ColorfulConsoleWriter.GetDefaultColorSchemeConfig(MonitorColorScheme.DarkBackground)); + /********* + ** Step 4: validate assumptions + *********/ + // executable exists + if (!File.Exists(paths.GameDllPath)) + { + this.PrintError("The detected game install path doesn't contain a Stardew Valley executable."); + this.AwaitConfirmation(allowUserInput); + return; + } + Console.Clear(); + - // print question - this.PrintPlain("Which text looks more readable?"); + /********* + ** Step 5: ask what to do + *********/ + ScriptAction action; + { + /**** + ** print header + ****/ + this.PrintInfo("Hi there! I'll help you install or remove SMAPI. Just one question first."); + this.PrintDebug($"Game path: {paths.GamePath}"); + this.PrintDebug($"Color scheme: {this.GetDisplayText(scheme)}"); + this.PrintDebug("----------------------------------------------------------------------------"); + Console.WriteLine(); + + /**** + ** ask what to do + ****/ + if (installArg) + action = ScriptAction.Install; + else if (uninstallArg) + action = ScriptAction.Uninstall; + else + { + this.PrintInfo("What do you want to do?"); Console.WriteLine(); - Console.Write(" [1] "); - lightBackgroundWriter.WriteLine("Dark text on light background", ConsoleLogLevel.Info); - Console.Write(" [2] "); - darkBackgroundWriter.WriteLine("Light text on dark background", ConsoleLogLevel.Info); + this.PrintInfo("[1] Install SMAPI."); + this.PrintInfo("[2] Uninstall SMAPI."); Console.WriteLine(); - // handle choice - string choice = this.InteractivelyChoose("Type 1 or 2, then press enter.", new[] { "1", "2" }, printLine: Console.WriteLine); + string choice = this.InteractivelyChoose("Type 1 or 2, then press enter.", new[] { "1", "2" }); switch (choice) { case "1": - scheme = MonitorColorScheme.LightBackground; - this.ConsoleWriter = lightBackgroundWriter; + action = ScriptAction.Install; break; case "2": - scheme = MonitorColorScheme.DarkBackground; - this.ConsoleWriter = darkBackgroundWriter; + action = ScriptAction.Uninstall; break; default: throw new InvalidOperationException($"Unexpected action key '{choice}'."); } } - Console.Clear(); + } + Console.Clear(); - /********* - ** Step 3: find game folder - *********/ - InstallerPaths paths; - { - /**** - ** print header - ****/ - this.PrintInfo("Hi there! I'll help you install or remove SMAPI. Just a few questions first."); - this.PrintDebug($"Color scheme: {this.GetDisplayText(scheme)}"); - this.PrintDebug("----------------------------------------------------------------------------"); - Console.WriteLine(); + /********* + ** Step 6: apply + *********/ + { + /**** + ** print header + ****/ + this.PrintInfo($"That's all I need! I'll {action.ToString().ToLower()} SMAPI now."); + this.PrintDebug($"Game path: {paths.GamePath}"); + this.PrintDebug($"Color scheme: {this.GetDisplayText(scheme)}"); + this.PrintDebug("----------------------------------------------------------------------------"); + Console.WriteLine(); - /**** - ** collect details - ****/ - // get game path - DirectoryInfo? installDir = this.InteractivelyGetInstallPath(toolkit, context, gamePathArg); - if (installDir == null) - { - this.PrintError("Failed finding your game path."); - this.AwaitConfirmation(allowUserInput); - return; - } + /**** + ** Back up user settings + ****/ + if (File.Exists(paths.ApiUserConfigPath)) + File.Copy(paths.ApiUserConfigPath, paths.BundleApiUserConfigPath); - // get folders - DirectoryInfo bundleDir = new(this.BundlePath); - paths = new InstallerPaths(bundleDir, installDir); + /**** + ** Always uninstall old files + ****/ + // restore game launcher + if (context.IsUnix && File.Exists(paths.BackupLaunchScriptPath)) + { + this.PrintDebug("Removing SMAPI launcher..."); + this.InteractivelyDelete(paths.VanillaLaunchScriptPath, allowUserInput); + File.Move(paths.BackupLaunchScriptPath, paths.VanillaLaunchScriptPath); } - - /********* - ** Step 4: validate assumptions - *********/ - // executable exists - if (!File.Exists(paths.GameDllPath)) + // remove old files + string[] removePaths = this.GetUninstallPaths(paths.GameDir, paths.ModsDir) + .Where(path => Directory.Exists(path) || File.Exists(path)) + .ToArray(); + if (removePaths.Any()) { - this.PrintError("The detected game install path doesn't contain a Stardew Valley executable."); - this.AwaitConfirmation(allowUserInput); - return; + this.PrintDebug(action == ScriptAction.Install ? "Removing previous SMAPI files..." : "Removing SMAPI files..."); + foreach (string path in removePaths) + this.InteractivelyDelete(path, allowUserInput); } - Console.Clear(); - - /********* - ** Step 5: ask what to do - *********/ - ScriptAction action; + // move global save data folder (changed in 3.2) { - /**** - ** print header - ****/ - this.PrintInfo("Hi there! I'll help you install or remove SMAPI. Just one question first."); - this.PrintDebug($"Game path: {paths.GamePath}"); - this.PrintDebug($"Color scheme: {this.GetDisplayText(scheme)}"); - this.PrintDebug("----------------------------------------------------------------------------"); - Console.WriteLine(); + string dataPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "StardewValley"); + DirectoryInfo oldDir = new(Path.Combine(dataPath, "Saves", ".smapi")); + DirectoryInfo newDir = new(Path.Combine(dataPath, ".smapi")); - /**** - ** ask what to do - ****/ - if (installArg) - action = ScriptAction.Install; - else if (uninstallArg) - action = ScriptAction.Uninstall; - else + if (oldDir.Exists) { - this.PrintInfo("What do you want to do?"); - Console.WriteLine(); - this.PrintInfo("[1] Install SMAPI."); - this.PrintInfo("[2] Uninstall SMAPI."); - Console.WriteLine(); - - string choice = this.InteractivelyChoose("Type 1 or 2, then press enter.", new[] { "1", "2" }); - switch (choice) - { - case "1": - action = ScriptAction.Install; - break; - case "2": - action = ScriptAction.Uninstall; - break; - default: - throw new InvalidOperationException($"Unexpected action key '{choice}'."); - } + if (newDir.Exists) + this.InteractivelyDelete(oldDir.FullName, allowUserInput); + else + oldDir.MoveTo(newDir.FullName); } } - Console.Clear(); - - /********* - ** Step 6: apply - *********/ + /**** + ** Install new files + ****/ + if (action == ScriptAction.Install) { - /**** - ** print header - ****/ - this.PrintInfo($"That's all I need! I'll {action.ToString().ToLower()} SMAPI now."); - this.PrintDebug($"Game path: {paths.GamePath}"); - this.PrintDebug($"Color scheme: {this.GetDisplayText(scheme)}"); - this.PrintDebug("----------------------------------------------------------------------------"); - Console.WriteLine(); - - /**** - ** Back up user settings - ****/ - if (File.Exists(paths.ApiUserConfigPath)) - File.Copy(paths.ApiUserConfigPath, paths.BundleApiUserConfigPath); - - /**** - ** Always uninstall old files - ****/ - // restore game launcher - if (context.IsUnix && File.Exists(paths.BackupLaunchScriptPath)) - { - this.PrintDebug("Removing SMAPI launcher..."); - this.InteractivelyDelete(paths.VanillaLaunchScriptPath, allowUserInput); - File.Move(paths.BackupLaunchScriptPath, paths.VanillaLaunchScriptPath); - } - - // remove old files - string[] removePaths = this.GetUninstallPaths(paths.GameDir, paths.ModsDir) - .Where(path => Directory.Exists(path) || File.Exists(path)) - .ToArray(); - if (removePaths.Any()) + // copy SMAPI files to game dir + this.PrintDebug("Adding SMAPI files..."); + foreach (FileSystemInfo sourceEntry in paths.BundleDir.EnumerateFileSystemInfos().Where(this.ShouldCopy)) { - this.PrintDebug(action == ScriptAction.Install ? "Removing previous SMAPI files..." : "Removing SMAPI files..."); - foreach (string path in removePaths) - this.InteractivelyDelete(path, allowUserInput); + this.InteractivelyDelete(Path.Combine(paths.GameDir.FullName, sourceEntry.Name), allowUserInput); + this.RecursiveCopy(sourceEntry, paths.GameDir); } - // move global save data folder (changed in 3.2) + // replace mod launcher (if possible) + if (context.IsUnix) { - string dataPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "StardewValley"); - DirectoryInfo oldDir = new(Path.Combine(dataPath, "Saves", ".smapi")); - DirectoryInfo newDir = new(Path.Combine(dataPath, ".smapi")); + this.PrintDebug("Safely replacing game launcher..."); - if (oldDir.Exists) + // back up & remove current launcher + if (File.Exists(paths.VanillaLaunchScriptPath)) { - if (newDir.Exists) - this.InteractivelyDelete(oldDir.FullName, allowUserInput); + if (!File.Exists(paths.BackupLaunchScriptPath)) + File.Move(paths.VanillaLaunchScriptPath, paths.BackupLaunchScriptPath); else - oldDir.MoveTo(newDir.FullName); + this.InteractivelyDelete(paths.VanillaLaunchScriptPath, allowUserInput); } - } - /**** - ** Install new files - ****/ - if (action == ScriptAction.Install) - { - // copy SMAPI files to game dir - this.PrintDebug("Adding SMAPI files..."); - foreach (FileSystemInfo sourceEntry in paths.BundleDir.EnumerateFileSystemInfos().Where(this.ShouldCopy)) + // add new launcher + File.Move(paths.NewLaunchScriptPath, paths.VanillaLaunchScriptPath); + + // mark files executable + // (MSBuild doesn't keep permission flags for files zipped in a build task.) + foreach (string path in new[] { paths.VanillaLaunchScriptPath, paths.UnixSmapiExecutablePath }) { - this.InteractivelyDelete(Path.Combine(paths.GameDir.FullName, sourceEntry.Name), allowUserInput); - this.RecursiveCopy(sourceEntry, paths.GameDir); + new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "chmod", + Arguments = $"755 \"{path}\"", + CreateNoWindow = true + } + }.Start(); } + } - // replace mod launcher (if possible) - if (context.IsUnix) - { - this.PrintDebug("Safely replacing game launcher..."); + // copy the game's deps.json file + // (This is needed to resolve native DLLs like libSkiaSharp.) + File.Copy( + sourceFileName: Path.Combine(paths.GamePath, "Stardew Valley.deps.json"), + destFileName: Path.Combine(paths.GamePath, "StardewModdingAPI.deps.json"), + overwrite: true + ); - // back up & remove current launcher - if (File.Exists(paths.VanillaLaunchScriptPath)) - { - if (!File.Exists(paths.BackupLaunchScriptPath)) - File.Move(paths.VanillaLaunchScriptPath, paths.BackupLaunchScriptPath); - else - this.InteractivelyDelete(paths.VanillaLaunchScriptPath, allowUserInput); - } + // create mods directory (if needed) + if (!paths.ModsDir.Exists) + { + this.PrintDebug("Creating mods directory..."); + paths.ModsDir.Create(); + } - // add new launcher - File.Move(paths.NewLaunchScriptPath, paths.VanillaLaunchScriptPath); + // add or replace bundled mods + DirectoryInfo bundledModsDir = new(Path.Combine(paths.BundlePath, "Mods")); + if (bundledModsDir.Exists && bundledModsDir.EnumerateDirectories().Any()) + { + this.PrintDebug("Adding bundled mods..."); - // mark files executable - // (MSBuild doesn't keep permission flags for files zipped in a build task.) - foreach (string path in new[] { paths.VanillaLaunchScriptPath, paths.UnixSmapiExecutablePath }) + ModFolder[] targetMods = toolkit.GetModFolders(paths.ModsPath, useCaseInsensitiveFilePaths: true).ToArray(); + foreach (ModFolder sourceMod in toolkit.GetModFolders(bundledModsDir.FullName, useCaseInsensitiveFilePaths: true)) + { + // validate source mod + if (sourceMod.Manifest == null) { - new Process - { - StartInfo = new ProcessStartInfo - { - FileName = "chmod", - Arguments = $"755 \"{path}\"", - CreateNoWindow = true - } - }.Start(); + this.PrintWarning($" ignored invalid bundled mod {sourceMod.DisplayName}: {sourceMod.ManifestParseError}"); + continue; + } + if (!this.BundledModIDs.Contains(sourceMod.Manifest.UniqueID)) + { + this.PrintWarning($" ignored unknown '{sourceMod.DisplayName}' mod in the installer folder. To add mods, put them here instead: {paths.ModsPath}"); + continue; } - } - - // copy the game's deps.json file - // (This is needed to resolve native DLLs like libSkiaSharp.) - File.Copy( - sourceFileName: Path.Combine(paths.GamePath, "Stardew Valley.deps.json"), - destFileName: Path.Combine(paths.GamePath, "StardewModdingAPI.deps.json"), - overwrite: true - ); - // create mods directory (if needed) - if (!paths.ModsDir.Exists) - { - this.PrintDebug("Creating mods directory..."); - paths.ModsDir.Create(); - } + // get mod info + string modId = sourceMod.Manifest.UniqueID; + string modName = sourceMod.Manifest.Name; + DirectoryInfo fromDir = sourceMod.Directory; - // add or replace bundled mods - DirectoryInfo bundledModsDir = new(Path.Combine(paths.BundlePath, "Mods")); - if (bundledModsDir.Exists && bundledModsDir.EnumerateDirectories().Any()) - { - this.PrintDebug("Adding bundled mods..."); + // get target path + // ReSharper disable once ConditionalAccessQualifierIsNonNullableAccordingToAPIContract -- avoid error if the Mods folder has invalid mods, since they're not validated yet + ModFolder? targetMod = targetMods.FirstOrDefault(p => p.Manifest?.UniqueID?.Equals(modId, StringComparison.OrdinalIgnoreCase) == true); + DirectoryInfo targetDir = new(Path.Combine(paths.ModsPath, fromDir.Name)); // replace existing folder if possible - ModFolder[] targetMods = toolkit.GetModFolders(paths.ModsPath, useCaseInsensitiveFilePaths: true).ToArray(); - foreach (ModFolder sourceMod in toolkit.GetModFolders(bundledModsDir.FullName, useCaseInsensitiveFilePaths: true)) + // if we found the mod in a custom location, replace that copy + if (targetMod != null && targetMod.Directory.FullName != targetDir.FullName) { - // validate source mod - if (sourceMod.Manifest == null) - { - this.PrintWarning($" ignored invalid bundled mod {sourceMod.DisplayName}: {sourceMod.ManifestParseError}"); - continue; - } - if (!this.BundledModIDs.Contains(sourceMod.Manifest.UniqueID)) - { - this.PrintWarning($" ignored unknown '{sourceMod.DisplayName}' mod in the installer folder. To add mods, put them here instead: {paths.ModsPath}"); - continue; - } - - // get mod info - string modId = sourceMod.Manifest.UniqueID; - string modName = sourceMod.Manifest.Name; - DirectoryInfo fromDir = sourceMod.Directory; + targetDir = targetMod.Directory; + DirectoryInfo parentDir = targetDir.Parent!; - // get target path - // ReSharper disable once ConditionalAccessQualifierIsNonNullableAccordingToAPIContract -- avoid error if the Mods folder has invalid mods, since they're not validated yet - ModFolder? targetMod = targetMods.FirstOrDefault(p => p.Manifest?.UniqueID?.Equals(modId, StringComparison.OrdinalIgnoreCase) == true); - DirectoryInfo targetDir = new(Path.Combine(paths.ModsPath, fromDir.Name)); // replace existing folder if possible + this.PrintDebug($" adding {modName} to {Path.Combine(paths.ModsDir.Name, PathUtilities.GetRelativePath(paths.ModsPath, targetDir.FullName))}..."); - // if we found the mod in a custom location, replace that copy - if (targetMod != null && targetMod.Directory.FullName != targetDir.FullName) + if (targetDir.Name != fromDir.Name) { - targetDir = targetMod.Directory; - DirectoryInfo parentDir = targetDir.Parent!; - - this.PrintDebug($" adding {modName} to {Path.Combine(paths.ModsDir.Name, PathUtilities.GetRelativePath(paths.ModsPath, targetDir.FullName))}..."); - - if (targetDir.Name != fromDir.Name) - { - // in case the user does weird things like swap folder names, rename the bundled - // mod in a unique staging folder within the temporary package: - string stagingPath = Path.Combine(fromDir.Parent!.Parent!.FullName, $"renamed-mod-{fromDir.Name}"); - Directory.CreateDirectory(stagingPath); - fromDir.MoveTo( - Path.Combine(stagingPath, targetDir.Name) - ); - } - - this.InteractivelyDelete(targetDir.FullName, allowUserInput); - this.RecursiveCopy(fromDir, parentDir, filter: this.ShouldCopy); + // in case the user does weird things like swap folder names, rename the bundled + // mod in a unique staging folder within the temporary package: + string stagingPath = Path.Combine(fromDir.Parent!.Parent!.FullName, $"renamed-mod-{fromDir.Name}"); + Directory.CreateDirectory(stagingPath); + fromDir.MoveTo( + Path.Combine(stagingPath, targetDir.Name) + ); } - // else add it to default location - else - { - DirectoryInfo parentDir = targetDir.Parent!; + this.InteractivelyDelete(targetDir.FullName, allowUserInput); + this.RecursiveCopy(fromDir, parentDir, filter: this.ShouldCopy); + } - this.PrintDebug($" adding {modName}..."); + // else add it to default location + else + { + DirectoryInfo parentDir = targetDir.Parent!; - if (targetDir.Exists) - this.InteractivelyDelete(targetDir.FullName, allowUserInput); + this.PrintDebug($" adding {modName}..."); - this.RecursiveCopy(fromDir, parentDir, filter: this.ShouldCopy); - } - } - } + if (targetDir.Exists) + this.InteractivelyDelete(targetDir.FullName, allowUserInput); - // set SMAPI's color scheme if defined - if (scheme != MonitorColorScheme.AutoDetect) - { - string text = File - .ReadAllText(paths.ApiConfigPath) - .Replace(@"""UseScheme"": ""AutoDetect""", $@"""UseScheme"": ""{scheme}"""); - File.WriteAllText(paths.ApiConfigPath, text); + this.RecursiveCopy(fromDir, parentDir, filter: this.ShouldCopy); + } } } - } - Console.WriteLine(); - Console.WriteLine(); - - /********* - ** Step 7: final instructions - *********/ - if (context.IsWindows) - { - if (action == ScriptAction.Install) + // set SMAPI's color scheme if defined + if (scheme != MonitorColorScheme.AutoDetect) { - this.PrintSuccess("SMAPI is installed! If you use Steam, set your launch options to enable achievements (see smapi.io/install):"); - this.PrintSuccess($" \"{Path.Combine(paths.GamePath, "StardewModdingAPI.exe")}\" %command%"); - Console.WriteLine(); - this.PrintSuccess("If you don't use Steam, launch StardewModdingAPI.exe in your game folder to play with mods."); + string text = File + .ReadAllText(paths.ApiConfigPath) + .Replace(@"""UseScheme"": ""AutoDetect""", $@"""UseScheme"": ""{scheme}"""); + File.WriteAllText(paths.ApiConfigPath, text); } - else - this.PrintSuccess("SMAPI is removed! If you configured Steam to launch SMAPI, don't forget to clear your launch options."); - } - else - { - this.PrintSuccess(action == ScriptAction.Install - ? "SMAPI is installed! Launch the game the same way as before to play with mods." - : "SMAPI is removed! Launch the game the same way as before to play without mods." - ); } - - this.AwaitConfirmation(allowUserInput); } + Console.WriteLine(); + Console.WriteLine(); /********* - ** Private methods + ** Step 7: final instructions *********/ - /// Get the display text for a color scheme. - /// The color scheme. - private string GetDisplayText(MonitorColorScheme scheme) + if (context.IsWindows) { - switch (scheme) + if (action == ScriptAction.Install) { - case MonitorColorScheme.AutoDetect: - return "auto-detect"; - case MonitorColorScheme.DarkBackground: - return "light text on dark background"; - case MonitorColorScheme.LightBackground: - return "dark text on light background"; - default: - return scheme.ToString(); + this.PrintSuccess("SMAPI is installed! If you use Steam, set your launch options to enable achievements (see smapi.io/install):"); + this.PrintSuccess($" \"{Path.Combine(paths.GamePath, "StardewModdingAPI.exe")}\" %command%"); + Console.WriteLine(); + this.PrintSuccess("If you don't use Steam, launch StardewModdingAPI.exe in your game folder to play with mods."); } + else + this.PrintSuccess("SMAPI is removed! If you configured Steam to launch SMAPI, don't forget to clear your launch options."); } - - /// Print a message without formatting. - /// The text to print. - private void PrintPlain(string text) + else { - Console.WriteLine(text); + this.PrintSuccess(action == ScriptAction.Install + ? "SMAPI is installed! Launch the game the same way as before to play with mods." + : "SMAPI is removed! Launch the game the same way as before to play without mods." + ); } - /// Print a debug message. - /// The text to print. - private void PrintDebug(string text) - { - this.ConsoleWriter.WriteLine(text, ConsoleLogLevel.Debug); - } + this.AwaitConfirmation(allowUserInput); + } - /// Print a debug message. - /// The text to print. - private void PrintInfo(string text) - { - this.ConsoleWriter.WriteLine(text, ConsoleLogLevel.Info); - } - /// Print a warning message. - /// The text to print. - private void PrintWarning(string text) + /********* + ** Private methods + *********/ + /// Get the display text for a color scheme. + /// The color scheme. + private string GetDisplayText(MonitorColorScheme scheme) + { + switch (scheme) { - this.ConsoleWriter.WriteLine(text, ConsoleLogLevel.Warn); + case MonitorColorScheme.AutoDetect: + return "auto-detect"; + case MonitorColorScheme.DarkBackground: + return "light text on dark background"; + case MonitorColorScheme.LightBackground: + return "dark text on light background"; + default: + return scheme.ToString(); } + } + + /// Print a message without formatting. + /// The text to print. + private void PrintPlain(string text) + { + Console.WriteLine(text); + } + + /// Print a debug message. + /// The text to print. + private void PrintDebug(string text) + { + this.ConsoleWriter.WriteLine(text, ConsoleLogLevel.Debug); + } + + /// Print a debug message. + /// The text to print. + private void PrintInfo(string text) + { + this.ConsoleWriter.WriteLine(text, ConsoleLogLevel.Info); + } + + /// Print a warning message. + /// The text to print. + private void PrintWarning(string text) + { + this.ConsoleWriter.WriteLine(text, ConsoleLogLevel.Warn); + } + + /// Print a warning message. + /// The text to print. + private void PrintError(string text) + { + this.ConsoleWriter.WriteLine(text, ConsoleLogLevel.Error); + } + + /// Print a success message. + /// The text to print. + private void PrintSuccess(string text) + { + this.ConsoleWriter.WriteLine(text, ConsoleLogLevel.Success); + } - /// Print a warning message. - /// The text to print. - private void PrintError(string text) + /// Interactively delete a file or folder path, and block until deletion completes. + /// The file or folder path. + /// Whether the installer can ask for user input from the terminal. + private void InteractivelyDelete(string path, bool allowUserInput) + { + while (true) { - this.ConsoleWriter.WriteLine(text, ConsoleLogLevel.Error); + try + { + FileUtilities.ForceDelete(Directory.Exists(path) ? new DirectoryInfo(path) : new FileInfo(path)); + break; + } + catch (Exception ex) + { + this.PrintError($"Oops! The installer couldn't delete {path}: [{ex.GetType().Name}] {ex.Message}."); + this.PrintError("Try rebooting your computer and then run the installer again. If that doesn't work, try deleting it yourself then press any key to retry."); + this.AwaitConfirmation(allowUserInput); + } } + } - /// Print a success message. - /// The text to print. - private void PrintSuccess(string text) + /// Recursively copy a directory or file. + /// The file or folder to copy. + /// The folder to copy into. + /// A filter which matches directories and files to copy, or null to match all. + private void RecursiveCopy(FileSystemInfo source, DirectoryInfo targetFolder, Func? filter = null) + { + if (filter != null && !filter(source)) + return; + + if (!targetFolder.Exists) + targetFolder.Create(); + + switch (source) { - this.ConsoleWriter.WriteLine(text, ConsoleLogLevel.Success); + case FileInfo sourceFile: + sourceFile.CopyTo(Path.Combine(targetFolder.FullName, sourceFile.Name)); + break; + + case DirectoryInfo sourceDir: + DirectoryInfo targetSubfolder = new(Path.Combine(targetFolder.FullName, sourceDir.Name)); + foreach (FileSystemInfo entry in sourceDir.EnumerateFileSystemInfos()) + this.RecursiveCopy(entry, targetSubfolder, filter); + break; + + default: + throw new NotSupportedException($"Unknown filesystem info type '{source.GetType().FullName}'."); } + } + + /// Interactively ask the user to choose a value. + /// A callback which prints a message to the console. + /// The message to print. + /// The allowed options (not case sensitive). + /// The indentation to prefix to output. + private string InteractivelyChoose(string message, string[] options, string indent = "", Action? printLine = null) + { + printLine ??= this.PrintInfo; - /// Interactively delete a file or folder path, and block until deletion completes. - /// The file or folder path. - /// Whether the installer can ask for user input from the terminal. - private void InteractivelyDelete(string path, bool allowUserInput) + while (true) { - while (true) + printLine(indent + message); + Console.Write(indent); + string? input = Console.ReadLine()?.Trim().ToLowerInvariant(); + if (input == null || !options.Contains(input)) { - try - { - FileUtilities.ForceDelete(Directory.Exists(path) ? new DirectoryInfo(path) : new FileInfo(path)); - break; - } - catch (Exception ex) - { - this.PrintError($"Oops! The installer couldn't delete {path}: [{ex.GetType().Name}] {ex.Message}."); - this.PrintError("Try rebooting your computer and then run the installer again. If that doesn't work, try deleting it yourself then press any key to retry."); - this.AwaitConfirmation(allowUserInput); - } + printLine($"{indent}That's not a valid option."); + continue; } + return input; } + } - /// Recursively copy a directory or file. - /// The file or folder to copy. - /// The folder to copy into. - /// A filter which matches directories and files to copy, or null to match all. - private void RecursiveCopy(FileSystemInfo source, DirectoryInfo targetFolder, Func? filter = null) + /// Interactively locate the game install path to update. + /// The mod toolkit. + /// The installer context. + /// The path specified as a command-line argument (if any), which should override automatic path detection. + private DirectoryInfo? InteractivelyGetInstallPath(ModToolkit toolkit, InstallerContext context, string? specifiedPath) + { + // use specified path + if (specifiedPath != null) { - if (filter != null && !filter(source)) - return; - - if (!targetFolder.Exists) - targetFolder.Create(); + string errorPrefix = $"You specified --game-path \"{specifiedPath}\", but"; - switch (source) + var dir = new DirectoryInfo(specifiedPath); + if (!dir.Exists) { - case FileInfo sourceFile: - sourceFile.CopyTo(Path.Combine(targetFolder.FullName, sourceFile.Name)); - break; + this.PrintError($"{errorPrefix} that folder doesn't exist."); + return null; + } - case DirectoryInfo sourceDir: - DirectoryInfo targetSubfolder = new(Path.Combine(targetFolder.FullName, sourceDir.Name)); - foreach (FileSystemInfo entry in sourceDir.EnumerateFileSystemInfos()) - this.RecursiveCopy(entry, targetSubfolder, filter); - break; + GameFolderType type = context.GetGameFolderType(dir); + switch (type) + { + case GameFolderType.Valid: + return dir; default: - throw new NotSupportedException($"Unknown filesystem info type '{source.GetType().FullName}'."); + foreach (string message in this.GetInvalidFolderWarning(type)) + this.PrintWarning(message); + return null; } } - /// Interactively ask the user to choose a value. - /// A callback which prints a message to the console. - /// The message to print. - /// The allowed options (not case sensitive). - /// The indentation to prefix to output. - private string InteractivelyChoose(string message, string[] options, string indent = "", Action? printLine = null) + // get valid install paths & log invalid ones + List defaultPaths = new(); + foreach ((DirectoryInfo dir, GameFolderType type) in this.DetectGameFolders(toolkit, context)) { - printLine ??= this.PrintInfo; - - while (true) + if (type is GameFolderType.Valid) { - printLine(indent + message); - Console.Write(indent); - string? input = Console.ReadLine()?.Trim().ToLowerInvariant(); - if (input == null || !options.Contains(input)) - { - printLine($"{indent}That's not a valid option."); - continue; - } - return input; + defaultPaths.Add(dir); + continue; } + + this.PrintDebug($"Ignored game folder: {dir.FullName}"); + foreach (string message in this.GetInvalidFolderWarning(type)) + this.PrintDebug(message); + this.PrintDebug("\n"); } - /// Interactively locate the game install path to update. - /// The mod toolkit. - /// The installer context. - /// The path specified as a command-line argument (if any), which should override automatic path detection. - private DirectoryInfo? InteractivelyGetInstallPath(ModToolkit toolkit, InstallerContext context, string? specifiedPath) + // let user choose detected path + if (defaultPaths.Any()) { - // use specified path - if (specifiedPath != null) - { - string errorPrefix = $"You specified --game-path \"{specifiedPath}\", but"; - - var dir = new DirectoryInfo(specifiedPath); - if (!dir.Exists) - { - this.PrintError($"{errorPrefix} that folder doesn't exist."); - return null; - } + this.PrintInfo("Where do you want to add or remove SMAPI?"); + Console.WriteLine(); + for (int i = 0; i < defaultPaths.Count; i++) + this.PrintInfo($"[{i + 1}] {defaultPaths[i].FullName}"); + this.PrintInfo($"[{defaultPaths.Count + 1}] Enter a custom game path."); + Console.WriteLine(); - GameFolderType type = context.GetGameFolderType(dir); - switch (type) - { - case GameFolderType.Valid: - return dir; + string[] validOptions = Enumerable.Range(1, defaultPaths.Count + 1).Select(p => p.ToString(CultureInfo.InvariantCulture)).ToArray(); + string choice = this.InteractivelyChoose("Type the number next to your choice, then press enter.", validOptions); + int index = int.Parse(choice, CultureInfo.InvariantCulture) - 1; - default: - foreach (string message in this.GetInvalidFolderWarning(type)) - this.PrintWarning(message); - return null; - } - } + if (index < defaultPaths.Count) + return defaultPaths[index]; + } + else + this.PrintInfo("Oops, couldn't find the game automatically."); - // get valid install paths & log invalid ones - List defaultPaths = new(); - foreach ((DirectoryInfo dir, GameFolderType type) in this.DetectGameFolders(toolkit, context)) + // let user enter manual path + while (true) + { + // get path from user + Console.WriteLine(); + this.PrintInfo($"Type the file path to the game directory (the one containing '{Constants.GameDllName}'), then press enter."); + string? path = Console.ReadLine()?.Trim(); + if (string.IsNullOrWhiteSpace(path)) { - if (type is GameFolderType.Valid) - { - defaultPaths.Add(dir); - continue; - } - - this.PrintDebug($"Ignored game folder: {dir.FullName}"); - foreach (string message in this.GetInvalidFolderWarning(type)) - this.PrintDebug(message); - this.PrintDebug("\n"); + this.PrintWarning("You must specify a directory path to continue."); + continue; } - // let user choose detected path - if (defaultPaths.Any()) + // normalize path + path = context.IsWindows + ? path.Replace("\"", "") // in Windows, quotes are used to escape spaces and aren't part of the file path + : path.Replace("\\ ", " "); // in Linux/macOS, spaces in paths may be escaped if copied from the command line + if (path.StartsWith("~/")) { - this.PrintInfo("Where do you want to add or remove SMAPI?"); - Console.WriteLine(); - for (int i = 0; i < defaultPaths.Count; i++) - this.PrintInfo($"[{i + 1}] {defaultPaths[i].FullName}"); - this.PrintInfo($"[{defaultPaths.Count + 1}] Enter a custom game path."); - Console.WriteLine(); + string home = Environment.GetEnvironmentVariable("HOME") ?? Environment.GetEnvironmentVariable("USERPROFILE")!; + path = Path.Combine(home, path.Substring(2)); + } - string[] validOptions = Enumerable.Range(1, defaultPaths.Count + 1).Select(p => p.ToString(CultureInfo.InvariantCulture)).ToArray(); - string choice = this.InteractivelyChoose("Type the number next to your choice, then press enter.", validOptions); - int index = int.Parse(choice, CultureInfo.InvariantCulture) - 1; + // get directory + DirectoryInfo directory = new(path); + if (!directory.Exists && (path.EndsWith(".dll") || path.EndsWith(".exe") || File.Exists(path)) && directory.Parent is { Exists: true }) + directory = directory.Parent; - if (index < defaultPaths.Count) - return defaultPaths[index]; + // validate path + if (!directory.Exists) + { + this.PrintWarning("That directory doesn't seem to exist."); + continue; } - else - this.PrintInfo("Oops, couldn't find the game automatically."); - // let user enter manual path - while (true) + GameFolderType type = context.GetGameFolderType(directory); + switch (type) { - // get path from user - Console.WriteLine(); - this.PrintInfo($"Type the file path to the game directory (the one containing '{Constants.GameDllName}'), then press enter."); - string? path = Console.ReadLine()?.Trim(); - if (string.IsNullOrWhiteSpace(path)) - { - this.PrintWarning("You must specify a directory path to continue."); - continue; - } - - // normalize path - path = context.IsWindows - ? path.Replace("\"", "") // in Windows, quotes are used to escape spaces and aren't part of the file path - : path.Replace("\\ ", " "); // in Linux/macOS, spaces in paths may be escaped if copied from the command line - if (path.StartsWith("~/")) - { - string home = Environment.GetEnvironmentVariable("HOME") ?? Environment.GetEnvironmentVariable("USERPROFILE")!; - path = Path.Combine(home, path.Substring(2)); - } - - // get directory - DirectoryInfo directory = new(path); - if (!directory.Exists && (path.EndsWith(".dll") || path.EndsWith(".exe") || File.Exists(path)) && directory.Parent is { Exists: true }) - directory = directory.Parent; + case GameFolderType.Valid: + this.PrintInfo(" OK!"); + return directory; - // validate path - if (!directory.Exists) - { - this.PrintWarning("That directory doesn't seem to exist."); + default: + foreach (string message in this.GetInvalidFolderWarning(type)) + this.PrintWarning(message); continue; - } - - GameFolderType type = context.GetGameFolderType(directory); - switch (type) - { - case GameFolderType.Valid: - this.PrintInfo(" OK!"); - return directory; - - default: - foreach (string message in this.GetInvalidFolderWarning(type)) - this.PrintWarning(message); - continue; - } } } + } - /// Get the possible game paths to update. - /// The mod toolkit. - /// The installer context. - private IEnumerable<(DirectoryInfo, GameFolderType)> DetectGameFolders(ModToolkit toolkit, InstallerContext context) - { - HashSet foundPaths = new HashSet(); + /// Get the possible game paths to update. + /// The mod toolkit. + /// The installer context. + private IEnumerable<(DirectoryInfo, GameFolderType)> DetectGameFolders(ModToolkit toolkit, InstallerContext context) + { + HashSet foundPaths = new HashSet(); - // game folder which contains the installer, if any + // game folder which contains the installer, if any + { + DirectoryInfo? curPath = new FileInfo(Assembly.GetExecutingAssembly().Location).Directory; + while (curPath?.Parent != null) // must be in a folder (not at the root) { - DirectoryInfo? curPath = new FileInfo(Assembly.GetExecutingAssembly().Location).Directory; - while (curPath?.Parent != null) // must be in a folder (not at the root) + if (context.GetGameFolderType(curPath) == GameFolderType.Valid) { - if (context.GetGameFolderType(curPath) == GameFolderType.Valid) - { - foundPaths.Add(curPath.FullName); - yield return (curPath, GameFolderType.Valid); - break; - } - - curPath = curPath.Parent; + foundPaths.Add(curPath.FullName); + yield return (curPath, GameFolderType.Valid); + break; } - } - // game paths detected by toolkit - foreach ((DirectoryInfo, GameFolderType) pair in toolkit.GetGameFoldersIncludingInvalid()) - { - if (foundPaths.Add(pair.Item1.FullName)) - yield return pair; + curPath = curPath.Parent; } } - private string[] GetInvalidFolderWarning(GameFolderType type) + // game paths detected by toolkit + foreach ((DirectoryInfo, GameFolderType) pair in toolkit.GetGameFoldersIncludingInvalid()) { - switch (type) - { - case GameFolderType.Valid: - return new[] { "OK!" }; // should never happen + if (foundPaths.Add(pair.Item1.FullName)) + yield return pair; + } + } - case GameFolderType.LegacyVersion: - return new[] - { - "That directory seems to have Stardew Valley 1.5.6 or earlier.", - "Please update your game to the latest version to use SMAPI." - }; + private string[] GetInvalidFolderWarning(GameFolderType type) + { + switch (type) + { + case GameFolderType.Valid: + return new[] { "OK!" }; // should never happen - case GameFolderType.LegacyCompatibilityBranch: - return new[] - { - "That directory seems to have the Stardew Valley legacy 'compatibility' branch.", - "Unfortunately SMAPI is only compatible with the modern version of the game.", - "Please update your game to the main branch to use SMAPI." - }; + case GameFolderType.LegacyVersion: + return new[] + { + "That directory seems to have Stardew Valley 1.5.6 or earlier.", + "Please update your game to the latest version to use SMAPI." + }; - case GameFolderType.NoGameFound: - return new[] { "That directory doesn't contain a Stardew Valley executable." }; + case GameFolderType.LegacyCompatibilityBranch: + return new[] + { + "That directory seems to have the Stardew Valley legacy 'compatibility' branch.", + "Unfortunately SMAPI is only compatible with the modern version of the game.", + "Please update your game to the main branch to use SMAPI." + }; - default: - return new[] { "That directory doesn't seem to contain a valid game install." }; - } - } + case GameFolderType.NoGameFound: + return new[] { "That directory doesn't contain a Stardew Valley executable." }; - /// Get whether a file or folder should be copied from the installer files. - /// The file or folder info. - private bool ShouldCopy(FileSystemInfo entry) - { - return entry.Name switch - { - "mcs" => false, // ignore macOS symlink - "Mods" => false, // Mods folder handled separately - _ => true - }; + default: + return new[] { "That directory doesn't seem to contain a valid game install." }; } + } - /// Wait until the user presses enter to confirm, if user input is allowed. - /// Whether the installer can ask for user input from the terminal. - private void AwaitConfirmation(bool allowUserInput) + /// Get whether a file or folder should be copied from the installer files. + /// The file or folder info. + private bool ShouldCopy(FileSystemInfo entry) + { + return entry.Name switch { - if (allowUserInput) - Console.ReadLine(); - } + "mcs" => false, // ignore macOS symlink + "Mods" => false, // Mods folder handled separately + _ => true + }; + } + + /// Wait until the user presses enter to confirm, if user input is allowed. + /// Whether the installer can ask for user input from the terminal. + private void AwaitConfirmation(bool allowUserInput) + { + if (allowUserInput) + Console.ReadLine(); } } diff --git a/src/SMAPI.Installer/Program.cs b/src/SMAPI.Installer/Program.cs index dc452a46e..2d6ec45ac 100644 --- a/src/SMAPI.Installer/Program.cs +++ b/src/SMAPI.Installer/Program.cs @@ -5,98 +5,97 @@ using System.Reflection; using System.Threading; -namespace StardewModdingApi.Installer +namespace StardewModdingApi.Installer; + +/// The entry point for SMAPI's install and uninstall console app. +internal class Program { - /// The entry point for SMAPI's install and uninstall console app. - internal class Program - { - /********* - ** Fields - *********/ - /// The absolute path of the installer folder. - [SuppressMessage("ReSharper", "AssignNullToNotNullAttribute", Justification = "The assembly location is never null in this context.")] - private static readonly string InstallerPath = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!; + /********* + ** Fields + *********/ + /// The absolute path of the installer folder. + [SuppressMessage("ReSharper", "AssignNullToNotNullAttribute", Justification = "The assembly location is never null in this context.")] + private static readonly string InstallerPath = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!; - /// The absolute path of the folder containing the unzipped installer files. - private static readonly string ExtractedBundlePath = Path.Combine(Path.GetTempPath(), $"SMAPI-installer-{Guid.NewGuid():N}"); + /// The absolute path of the folder containing the unzipped installer files. + private static readonly string ExtractedBundlePath = Path.Combine(Path.GetTempPath(), $"SMAPI-installer-{Guid.NewGuid():N}"); - /// The absolute path for referenced assemblies. - private static readonly string InternalFilesPath = Path.Combine(Program.ExtractedBundlePath, "smapi-internal"); + /// The absolute path for referenced assemblies. + private static readonly string InternalFilesPath = Path.Combine(Program.ExtractedBundlePath, "smapi-internal"); - /********* - ** Public methods - *********/ - /// Run the install or uninstall script. - /// The command line arguments. - public static void Main(string[] args) + /********* + ** Public methods + *********/ + /// Run the install or uninstall script. + /// The command line arguments. + public static void Main(string[] args) + { + // find install bundle + FileInfo zipFile = new(Path.Combine(Program.InstallerPath, "install.dat")); + if (!zipFile.Exists) { - // find install bundle - FileInfo zipFile = new(Path.Combine(Program.InstallerPath, "install.dat")); - if (!zipFile.Exists) - { - Console.WriteLine($"Oops! Some of the installer files are missing; try re-downloading the installer. (Missing file: {zipFile.FullName})"); - Console.ReadLine(); - return; - } + Console.WriteLine($"Oops! Some of the installer files are missing; try re-downloading the installer. (Missing file: {zipFile.FullName})"); + Console.ReadLine(); + return; + } - // unzip bundle into temp folder - DirectoryInfo bundleDir = new(Program.ExtractedBundlePath); - Console.WriteLine("Extracting install files..."); - ZipFile.ExtractToDirectory(zipFile.FullName, bundleDir.FullName); + // unzip bundle into temp folder + DirectoryInfo bundleDir = new(Program.ExtractedBundlePath); + Console.WriteLine("Extracting install files..."); + ZipFile.ExtractToDirectory(zipFile.FullName, bundleDir.FullName); - // set up assembly resolution - AppDomain.CurrentDomain.AssemblyResolve += Program.CurrentDomain_AssemblyResolve; + // set up assembly resolution + AppDomain.CurrentDomain.AssemblyResolve += Program.CurrentDomain_AssemblyResolve; - // launch installer - var installer = new InteractiveInstaller(bundleDir.FullName); + // launch installer + var installer = new InteractiveInstaller(bundleDir.FullName); - try - { - installer.Run(args); - } - catch (Exception ex) - { - Program.PrintErrorAndExit($"The installer failed with an unexpected exception.\nIf you need help fixing this error, see https://smapi.io/help\n\n{ex}"); - } + try + { + installer.Run(args); } + catch (Exception ex) + { + Program.PrintErrorAndExit($"The installer failed with an unexpected exception.\nIf you need help fixing this error, see https://smapi.io/help\n\n{ex}"); + } + } - /********* - ** Private methods - *********/ - /// Method called when assembly resolution fails, which may return a manually resolved assembly. - /// The event sender. - /// The event arguments. - private static Assembly? CurrentDomain_AssemblyResolve(object? sender, ResolveEventArgs e) + /********* + ** Private methods + *********/ + /// Method called when assembly resolution fails, which may return a manually resolved assembly. + /// The event sender. + /// The event arguments. + private static Assembly? CurrentDomain_AssemblyResolve(object? sender, ResolveEventArgs e) + { + try { - try + AssemblyName name = new(e.Name); + foreach (FileInfo dll in new DirectoryInfo(Program.InternalFilesPath).EnumerateFiles("*.dll")) { - AssemblyName name = new(e.Name); - foreach (FileInfo dll in new DirectoryInfo(Program.InternalFilesPath).EnumerateFiles("*.dll")) - { - if (name.Name != null && name.Name.Equals(AssemblyName.GetAssemblyName(dll.FullName).Name, StringComparison.OrdinalIgnoreCase)) - return Assembly.LoadFrom(dll.FullName); - } - return null; - } - catch (Exception ex) - { - Console.WriteLine($"Error resolving assembly: {ex}"); - return null; + if (name.Name != null && name.Name.Equals(AssemblyName.GetAssemblyName(dll.FullName).Name, StringComparison.OrdinalIgnoreCase)) + return Assembly.LoadFrom(dll.FullName); } + return null; } - - /// Write an error directly to the console and exit. - /// The error message to display. - private static void PrintErrorAndExit(string message) + catch (Exception ex) { - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine(message); - Console.ResetColor(); - - Console.WriteLine("Game has ended. Press any key to exit."); - Thread.Sleep(100); - Console.ReadKey(); - Environment.Exit(0); + Console.WriteLine($"Error resolving assembly: {ex}"); + return null; } } + + /// Write an error directly to the console and exit. + /// The error message to display. + private static void PrintErrorAndExit(string message) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine(message); + Console.ResetColor(); + + Console.WriteLine("Game has ended. Press any key to exit."); + Thread.Sleep(100); + Console.ReadKey(); + Environment.Exit(0); + } } diff --git a/src/SMAPI.Installer/SMAPI.Installer.csproj b/src/SMAPI.Installer/SMAPI.Installer.csproj index 4d4d0214c..c588562da 100644 --- a/src/SMAPI.Installer/SMAPI.Installer.csproj +++ b/src/SMAPI.Installer/SMAPI.Installer.csproj @@ -5,6 +5,8 @@ net6.0 Exe false + + true
diff --git a/src/SMAPI.Installer/assets/install on Linux.sh b/src/SMAPI.Installer/assets/install on Linux.sh index 70b215215..07929fdfa 100644 --- a/src/SMAPI.Installer/assets/install on Linux.sh +++ b/src/SMAPI.Installer/assets/install on Linux.sh @@ -1,4 +1,22 @@ #!/usr/bin/env bash +function open_in_terminal { # Checks for a few different terminal emulators to launch the installer through + if which konsole 2&>1 >/dev/null; then # KDE Konsole + konsole -e $1 + elif which alacritty 2&>1 >/dev/null; then # Alacritty + alacritty -e $1 + elif which gnome-terminal 2&>1 >/dev/null; then # GNOME Terminal + gnome-terminal -- $1 + elif which xterm 2&>1 >/dev/null; then # Xterm + xterm -e $1 + else # Use notify-send to send a message that none of these terminals were found installed and instruct the user to manually invoke this script through a terminal + notify-send --app-name="SMAPI Installer" --urgency=critical "Failed to find a terminal to open installer with. Please use a terminal program to open the 'install on Linux.sh' script" + fi +} + cd "`dirname "$0"`" -internal/linux/SMAPI.Installer +if [ -t 0 ]; then # Uses `test` to check if the File Descriptor for Standard Input is valid -- STDIN should be valid if script was invoked through a terminal, and invalid if the file was invoked through a file explorer that didn't wrap it in a terminal emulator + ./internal/linux/SMAPI.Installer +else + open_in_terminal ./internal/linux/SMAPI.Installer +fi diff --git a/src/SMAPI.Installer/assets/unix-launcher.sh b/src/SMAPI.Installer/assets/unix-launcher.sh index 2e98e9668..6984d7bad 100644 --- a/src/SMAPI.Installer/assets/unix-launcher.sh +++ b/src/SMAPI.Installer/assets/unix-launcher.sh @@ -59,13 +59,22 @@ if [ "$(uname)" == "Darwin" ]; then # reopen in Terminal if needed # https://stackoverflow.com/a/29511052/262123 if [ "$USE_CURRENT_SHELL" == "false" ]; then - echo "Reopening in the Terminal app..." echo '#!/bin/sh' > /tmp/open-smapi-terminal.command echo "\"$0\" $@ --use-current-shell" >> /tmp/open-smapi-terminal.command chmod +x /tmp/open-smapi-terminal.command cat /tmp/open-smapi-terminal.command - open -W /tmp/open-smapi-terminal.command - rm /tmp/open-smapi-terminal.command + + # open in ITerm2 if installed, else the default Terminal + if [ -d "/Applications/iTerm.app" ]; then + echo "Reopening in iTerm2..." + open -a "/Applications/iTerm.app" /tmp/open-smapi-terminal.command + else + echo "Reopening in the Terminal app..." + open -W /tmp/open-smapi-terminal.command + fi + + # remove temporary script after a delay + (sleep 10; rm /tmp/open-smapi-terminal.command) exit 0 fi fi @@ -92,9 +101,16 @@ if [ "$(uname)" == "Darwin" ]; then # Linux else - # choose binary file to launch - LAUNCH_FILE="./StardewModdingAPI" - export LAUNCH_FILE + # check if gamemoderun exists, if that is not the case start SMAPI normally + if command -v gamemoderun &> /dev/null + then + # Run SMAPI with using gamemoderun, which automatically boosts the system for the game + LAUNCH_FILE="gamemoderun ./StardewModdingAPI" + export LAUNCH_FILE + else + LAUNCH_FILE="./StardewModdingAPI" + export LAUNCH_FILE + fi # run in terminal if [ "$USE_CURRENT_SHELL" == "false" ]; then diff --git a/src/SMAPI.Internal/ConsoleWriting/ColorSchemeConfig.cs b/src/SMAPI.Internal/ConsoleWriting/ColorSchemeConfig.cs index 4e5850ea4..b4247017a 100644 --- a/src/SMAPI.Internal/ConsoleWriting/ColorSchemeConfig.cs +++ b/src/SMAPI.Internal/ConsoleWriting/ColorSchemeConfig.cs @@ -1,31 +1,30 @@ using System; using System.Collections.Generic; -namespace StardewModdingAPI.Internal.ConsoleWriting +namespace StardewModdingAPI.Internal.ConsoleWriting; + +/// The console color scheme options. +internal class ColorSchemeConfig { - /// The console color scheme options. - internal class ColorSchemeConfig - { - /********* - ** Accessors - *********/ - /// The default color scheme ID to use, or to select one automatically. - public MonitorColorScheme UseScheme { get; } + /********* + ** Accessors + *********/ + /// The default color scheme ID to use, or to select one automatically. + public MonitorColorScheme UseScheme { get; } - /// The available console color schemes. - public IDictionary> Schemes { get; } + /// The available console color schemes. + public IDictionary> Schemes { get; } - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The default color scheme ID to use, or to select one automatically. - /// The available console color schemes. - public ColorSchemeConfig(MonitorColorScheme useScheme, IDictionary> schemes) - { - this.UseScheme = useScheme; - this.Schemes = schemes; - } + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The default color scheme ID to use, or to select one automatically. + /// The available console color schemes. + public ColorSchemeConfig(MonitorColorScheme useScheme, IDictionary> schemes) + { + this.UseScheme = useScheme; + this.Schemes = schemes; } } diff --git a/src/SMAPI.Internal/ConsoleWriting/ColorfulConsoleWriter.cs b/src/SMAPI.Internal/ConsoleWriting/ColorfulConsoleWriter.cs index 78db0d659..30c85404e 100644 --- a/src/SMAPI.Internal/ConsoleWriting/ColorfulConsoleWriter.cs +++ b/src/SMAPI.Internal/ConsoleWriting/ColorfulConsoleWriter.cs @@ -3,160 +3,159 @@ using System.Diagnostics.CodeAnalysis; using StardewModdingAPI.Toolkit.Utilities; -namespace StardewModdingAPI.Internal.ConsoleWriting +namespace StardewModdingAPI.Internal.ConsoleWriting; + +/// Writes color-coded text to the console. +internal class ColorfulConsoleWriter : IConsoleWriter { - /// Writes color-coded text to the console. - internal class ColorfulConsoleWriter : IConsoleWriter - { - /********* - ** Fields - *********/ - /// The console text color for each log level. - private readonly IDictionary? Colors; + /********* + ** Fields + *********/ + /// The console text color for each log level. + private readonly IDictionary? Colors; - /// Whether the current console supports color formatting. - [MemberNotNullWhen(true, nameof(ColorfulConsoleWriter.Colors))] - private bool SupportsColor { get; } + /// Whether the current console supports color formatting. + [MemberNotNullWhen(true, nameof(ColorfulConsoleWriter.Colors))] + private bool SupportsColor { get; } - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The target platform. - public ColorfulConsoleWriter(Platform platform) - : this(platform, ColorfulConsoleWriter.GetDefaultColorSchemeConfig(MonitorColorScheme.AutoDetect)) { } + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The target platform. + public ColorfulConsoleWriter(Platform platform) + : this(platform, ColorfulConsoleWriter.GetDefaultColorSchemeConfig(MonitorColorScheme.AutoDetect)) { } - /// Construct an instance. - /// The target platform. - /// The colors to use for text written to the SMAPI console. - public ColorfulConsoleWriter(Platform platform, ColorSchemeConfig colorConfig) + /// Construct an instance. + /// The target platform. + /// The colors to use for text written to the SMAPI console. + public ColorfulConsoleWriter(Platform platform, ColorSchemeConfig colorConfig) + { + if (colorConfig.UseScheme == MonitorColorScheme.None) { - if (colorConfig.UseScheme == MonitorColorScheme.None) + this.SupportsColor = false; + this.Colors = null; + } + else + { + this.SupportsColor = this.TestColorSupport(); + this.Colors = this.GetConsoleColorScheme(platform, colorConfig); + } + } + + /// Write a message line to the log. + /// The message to log. + /// The log level. + public void WriteLine(string message, ConsoleLogLevel level) + { + if (this.SupportsColor) + { + if (level == ConsoleLogLevel.Critical) { - this.SupportsColor = false; - this.Colors = null; + Console.BackgroundColor = ConsoleColor.Red; + Console.ForegroundColor = ConsoleColor.White; + Console.WriteLine(message); + Console.ResetColor(); } else { - this.SupportsColor = this.TestColorSupport(); - this.Colors = this.GetConsoleColorScheme(platform, colorConfig); + Console.ForegroundColor = this.Colors[level]; + Console.WriteLine(message); + Console.ResetColor(); } } + else + Console.WriteLine(message); + } - /// Write a message line to the log. - /// The message to log. - /// The log level. - public void WriteLine(string message, ConsoleLogLevel level) - { - if (this.SupportsColor) + /// Get the default color scheme config for cases where it's not configurable (e.g. the installer). + /// The default color scheme ID to use, or to select one automatically. + /// The colors here should be kept in sync with the SMAPI config file. + public static ColorSchemeConfig GetDefaultColorSchemeConfig(MonitorColorScheme useScheme) + { + return new ColorSchemeConfig( + useScheme: useScheme, + schemes: new Dictionary> { - if (level == ConsoleLogLevel.Critical) + [MonitorColorScheme.DarkBackground] = new Dictionary { - Console.BackgroundColor = ConsoleColor.Red; - Console.ForegroundColor = ConsoleColor.White; - Console.WriteLine(message); - Console.ResetColor(); - } - else + [ConsoleLogLevel.Trace] = ConsoleColor.DarkGray, + [ConsoleLogLevel.Debug] = ConsoleColor.DarkGray, + [ConsoleLogLevel.Info] = ConsoleColor.White, + [ConsoleLogLevel.Warn] = ConsoleColor.Yellow, + [ConsoleLogLevel.Error] = ConsoleColor.Red, + [ConsoleLogLevel.Alert] = ConsoleColor.Magenta, + [ConsoleLogLevel.Success] = ConsoleColor.DarkGreen + }, + [MonitorColorScheme.LightBackground] = new Dictionary { - Console.ForegroundColor = this.Colors[level]; - Console.WriteLine(message); - Console.ResetColor(); + [ConsoleLogLevel.Trace] = ConsoleColor.DarkGray, + [ConsoleLogLevel.Debug] = ConsoleColor.DarkGray, + [ConsoleLogLevel.Info] = ConsoleColor.Black, + [ConsoleLogLevel.Warn] = ConsoleColor.DarkYellow, + [ConsoleLogLevel.Error] = ConsoleColor.Red, + [ConsoleLogLevel.Alert] = ConsoleColor.DarkMagenta, + [ConsoleLogLevel.Success] = ConsoleColor.DarkGreen } } - else - Console.WriteLine(message); - } + ); + } + - /// Get the default color scheme config for cases where it's not configurable (e.g. the installer). - /// The default color scheme ID to use, or to select one automatically. - /// The colors here should be kept in sync with the SMAPI config file. - public static ColorSchemeConfig GetDefaultColorSchemeConfig(MonitorColorScheme useScheme) + /********* + ** Private methods + *********/ + /// Test whether the current console supports color formatting. + private bool TestColorSupport() + { + try { - return new ColorSchemeConfig( - useScheme: useScheme, - schemes: new Dictionary> - { - [MonitorColorScheme.DarkBackground] = new Dictionary - { - [ConsoleLogLevel.Trace] = ConsoleColor.DarkGray, - [ConsoleLogLevel.Debug] = ConsoleColor.DarkGray, - [ConsoleLogLevel.Info] = ConsoleColor.White, - [ConsoleLogLevel.Warn] = ConsoleColor.Yellow, - [ConsoleLogLevel.Error] = ConsoleColor.Red, - [ConsoleLogLevel.Alert] = ConsoleColor.Magenta, - [ConsoleLogLevel.Success] = ConsoleColor.DarkGreen - }, - [MonitorColorScheme.LightBackground] = new Dictionary - { - [ConsoleLogLevel.Trace] = ConsoleColor.DarkGray, - [ConsoleLogLevel.Debug] = ConsoleColor.DarkGray, - [ConsoleLogLevel.Info] = ConsoleColor.Black, - [ConsoleLogLevel.Warn] = ConsoleColor.DarkYellow, - [ConsoleLogLevel.Error] = ConsoleColor.Red, - [ConsoleLogLevel.Alert] = ConsoleColor.DarkMagenta, - [ConsoleLogLevel.Success] = ConsoleColor.DarkGreen - } - } - ); + Console.ForegroundColor = Console.ForegroundColor; + return true; } - - - /********* - ** Private methods - *********/ - /// Test whether the current console supports color formatting. - private bool TestColorSupport() + catch (Exception) { - try - { - Console.ForegroundColor = Console.ForegroundColor; - return true; - } - catch (Exception) - { - return false; // Mono bug - } + return false; // Mono bug } + } - /// Get the color scheme to use for the current console. - /// The target platform. - /// The colors to use for text written to the SMAPI console. - private IDictionary GetConsoleColorScheme(Platform platform, ColorSchemeConfig colorConfig) + /// Get the color scheme to use for the current console. + /// The target platform. + /// The colors to use for text written to the SMAPI console. + private IDictionary GetConsoleColorScheme(Platform platform, ColorSchemeConfig colorConfig) + { + // get color scheme ID + MonitorColorScheme schemeID = colorConfig.UseScheme; + if (schemeID == MonitorColorScheme.AutoDetect) { - // get color scheme ID - MonitorColorScheme schemeID = colorConfig.UseScheme; - if (schemeID == MonitorColorScheme.AutoDetect) - { - schemeID = platform == Platform.Mac - ? MonitorColorScheme.LightBackground // macOS doesn't provide console background color info, but it's usually white. - : ColorfulConsoleWriter.IsDark(Console.BackgroundColor) ? MonitorColorScheme.DarkBackground : MonitorColorScheme.LightBackground; - } - - // get colors for scheme - return colorConfig.Schemes.TryGetValue(schemeID, out IDictionary? scheme) - ? scheme - : throw new NotSupportedException($"Unknown color scheme '{schemeID}'."); + schemeID = platform == Platform.Mac + ? MonitorColorScheme.LightBackground // macOS doesn't provide console background color info, but it's usually white. + : ColorfulConsoleWriter.IsDark(Console.BackgroundColor) ? MonitorColorScheme.DarkBackground : MonitorColorScheme.LightBackground; } - /// Get whether a console color should be considered dark, which is subjectively defined as 'white looks better than black on this text'. - /// The color to check. - private static bool IsDark(ConsoleColor color) + // get colors for scheme + return colorConfig.Schemes.TryGetValue(schemeID, out IDictionary? scheme) + ? scheme + : throw new NotSupportedException($"Unknown color scheme '{schemeID}'."); + } + + /// Get whether a console color should be considered dark, which is subjectively defined as 'white looks better than black on this text'. + /// The color to check. + private static bool IsDark(ConsoleColor color) + { + switch (color) { - switch (color) - { - case ConsoleColor.Black: - case ConsoleColor.Blue: - case ConsoleColor.DarkBlue: - case ConsoleColor.DarkMagenta: // PowerShell - case ConsoleColor.DarkRed: - case ConsoleColor.Red: - return true; + case ConsoleColor.Black: + case ConsoleColor.Blue: + case ConsoleColor.DarkBlue: + case ConsoleColor.DarkMagenta: // PowerShell + case ConsoleColor.DarkRed: + case ConsoleColor.Red: + return true; - default: - return false; - } + default: + return false; } } } diff --git a/src/SMAPI.Internal/ConsoleWriting/ConsoleLogLevel.cs b/src/SMAPI.Internal/ConsoleWriting/ConsoleLogLevel.cs index 545641114..d6dc2a3f7 100644 --- a/src/SMAPI.Internal/ConsoleWriting/ConsoleLogLevel.cs +++ b/src/SMAPI.Internal/ConsoleWriting/ConsoleLogLevel.cs @@ -1,30 +1,29 @@ -namespace StardewModdingAPI.Internal.ConsoleWriting +namespace StardewModdingAPI.Internal.ConsoleWriting; + +/// The log severity levels. +internal enum ConsoleLogLevel { - /// The log severity levels. - internal enum ConsoleLogLevel - { - /// Tracing info intended for developers. - Trace, + /// Tracing info intended for developers. + Trace, - /// Troubleshooting info that may be relevant to the player. - Debug, + /// Troubleshooting info that may be relevant to the player. + Debug, - /// Info relevant to the player. This should be used judiciously. - Info, + /// Info relevant to the player. This should be used judiciously. + Info, - /// An issue the player should be aware of. This should be used rarely. - Warn, + /// An issue the player should be aware of. This should be used rarely. + Warn, - /// A message indicating something went wrong. - Error, + /// A message indicating something went wrong. + Error, - /// Important information to highlight for the player when player action is needed (e.g. new version available). This should be used rarely to avoid alert fatigue. - Alert, + /// Important information to highlight for the player when player action is needed (e.g. new version available). This should be used rarely to avoid alert fatigue. + Alert, - /// A critical issue that generally signals an immediate end to the application. - Critical, + /// A critical issue that generally signals an immediate end to the application. + Critical, - /// A success message that generally signals a successful end to a task. - Success - } + /// A success message that generally signals a successful end to a task. + Success } diff --git a/src/SMAPI.Internal/ConsoleWriting/IConsoleWriter.cs b/src/SMAPI.Internal/ConsoleWriting/IConsoleWriter.cs index fbcf161cc..2217de805 100644 --- a/src/SMAPI.Internal/ConsoleWriting/IConsoleWriter.cs +++ b/src/SMAPI.Internal/ConsoleWriting/IConsoleWriter.cs @@ -1,11 +1,10 @@ -namespace StardewModdingAPI.Internal.ConsoleWriting +namespace StardewModdingAPI.Internal.ConsoleWriting; + +/// Writes text to the console. +internal interface IConsoleWriter { - /// Writes text to the console. - internal interface IConsoleWriter - { - /// Write a message line to the log. - /// The message to log. - /// The log level. - void WriteLine(string message, ConsoleLogLevel level); - } + /// Write a message line to the log. + /// The message to log. + /// The log level. + void WriteLine(string message, ConsoleLogLevel level); } diff --git a/src/SMAPI.Internal/ConsoleWriting/MonitorColorScheme.cs b/src/SMAPI.Internal/ConsoleWriting/MonitorColorScheme.cs index 994ea6a5d..d075f39d4 100644 --- a/src/SMAPI.Internal/ConsoleWriting/MonitorColorScheme.cs +++ b/src/SMAPI.Internal/ConsoleWriting/MonitorColorScheme.cs @@ -1,18 +1,17 @@ -namespace StardewModdingAPI.Internal.ConsoleWriting +namespace StardewModdingAPI.Internal.ConsoleWriting; + +/// A monitor color scheme to use. +internal enum MonitorColorScheme { - /// A monitor color scheme to use. - internal enum MonitorColorScheme - { - /// Choose a color scheme automatically. - AutoDetect, + /// Choose a color scheme automatically. + AutoDetect, - /// Use lighter text colors that look better on a black or dark background. - DarkBackground, + /// Use lighter text colors that look better on a black or dark background. + DarkBackground, - /// Use darker text colors that look better on a white or light background. - LightBackground, + /// Use darker text colors that look better on a white or light background. + LightBackground, - /// Disable console color. - None - } + /// Disable console color. + None } diff --git a/src/SMAPI.Internal/ExceptionHelper.cs b/src/SMAPI.Internal/ExceptionHelper.cs index 7edc0f624..a41085ac4 100644 --- a/src/SMAPI.Internal/ExceptionHelper.cs +++ b/src/SMAPI.Internal/ExceptionHelper.cs @@ -2,66 +2,65 @@ using System.Reflection; using System.Text.RegularExpressions; -namespace StardewModdingAPI.Internal +namespace StardewModdingAPI.Internal; + +/// Provides extension methods for handling exceptions. +internal static class ExceptionHelper { - /// Provides extension methods for handling exceptions. - internal static class ExceptionHelper + /********* + ** Public methods + *********/ + /// Get a string representation of an exception suitable for writing to the error log. + /// The error to summarize. + public static string GetLogSummary(this Exception? exception) { - /********* - ** Public methods - *********/ - /// Get a string representation of an exception suitable for writing to the error log. - /// The error to summarize. - public static string GetLogSummary(this Exception? exception) + try { - try + string message; + switch (exception) { - string message; - switch (exception) - { - case TypeLoadException ex: - message = $"Failed loading type '{ex.TypeName}': {exception}"; - break; - - case ReflectionTypeLoadException ex: - string summary = ex.ToString(); - foreach (Exception? childEx in ex.LoaderExceptions) - summary += $"\n\n{childEx?.GetLogSummary()}"; - message = summary; - break; + case TypeLoadException ex: + message = $"Failed loading type '{ex.TypeName}': {exception}"; + break; - default: - message = exception?.ToString() ?? $"\n{Environment.StackTrace}"; - break; - } + case ReflectionTypeLoadException ex: + string summary = ex.ToString(); + foreach (Exception? childEx in ex.LoaderExceptions) + summary += $"\n\n{childEx?.GetLogSummary()}"; + message = summary; + break; - return ExceptionHelper.SimplifyExtensionMessage(message); - } - catch (Exception ex) - { - throw new InvalidOperationException($"Failed handling {exception?.GetType().FullName} (original message: {exception?.Message})", ex); + default: + message = exception?.ToString() ?? $"\n{Environment.StackTrace}"; + break; } - } - /// Simplify common patterns in exception log messages that don't convey useful info. - /// The log message to simplify. - public static string SimplifyExtensionMessage(string message) + return ExceptionHelper.SimplifyExtensionMessage(message); + } + catch (Exception ex) { - // remove namespace for core exception types - message = Regex.Replace( - message, - @"(?:StardewModdingAPI\.Framework\.Exceptions|Microsoft\.Xna\.Framework|System|System\.IO)\.([a-zA-Z]+Exception):", - "$1:" - ); + throw new InvalidOperationException($"Failed handling {exception?.GetType().FullName} (original message: {exception?.Message})", ex); + } + } - // remove unneeded root build paths for SMAPI and Stardew Valley - message = message - .Replace(@"E:\source\_Stardew\SMAPI\src\", "") - .Replace(@"C:\GitlabRunner\builds\Gq5qA5P4\0\ConcernedApe\", ""); + /// Simplify common patterns in exception log messages that don't convey useful info. + /// The log message to simplify. + public static string SimplifyExtensionMessage(string message) + { + // remove namespace for core exception types + message = Regex.Replace( + message, + @"(?:StardewModdingAPI\.Framework\.Exceptions|Microsoft\.Xna\.Framework|System|System\.IO)\.([a-zA-Z]+Exception):", + "$1:" + ); - // remove placeholder info in Linux/macOS stack traces - return message - .Replace(@":0", ""); - } + // remove unneeded root build paths for SMAPI and Stardew Valley + message = message + .Replace(@"E:\source\_Stardew\SMAPI\src\", "") + .Replace(@"C:\GitlabRunner\builds\Gq5qA5P4\0\ConcernedApe\", ""); + + // remove placeholder info in Linux/macOS stack traces + return message + .Replace(@":0", ""); } } diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Framework/DiagnosticResult.cs b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Framework/DiagnosticResult.cs index 845149bd1..8cbce4f6a 100644 --- a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Framework/DiagnosticResult.cs +++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Framework/DiagnosticResult.cs @@ -3,87 +3,86 @@ using System; using Microsoft.CodeAnalysis; -namespace SMAPI.ModBuildConfig.Analyzer.Tests.Framework +namespace SMAPI.ModBuildConfig.Analyzer.Tests.Framework; + +/// +/// Location where the diagnostic appears, as determined by path, line number, and column number. +/// +public struct DiagnosticResultLocation { - /// - /// Location where the diagnostic appears, as determined by path, line number, and column number. - /// - public struct DiagnosticResultLocation + public DiagnosticResultLocation(string path, int line, int column) { - public DiagnosticResultLocation(string path, int line, int column) + if (line < -1) { - if (line < -1) - { - throw new ArgumentOutOfRangeException(nameof(line), "line must be >= -1"); - } - - if (column < -1) - { - throw new ArgumentOutOfRangeException(nameof(column), "column must be >= -1"); - } + throw new ArgumentOutOfRangeException(nameof(line), "line must be >= -1"); + } - this.Path = path; - this.Line = line; - this.Column = column; + if (column < -1) + { + throw new ArgumentOutOfRangeException(nameof(column), "column must be >= -1"); } - public string Path { get; } - public int Line { get; } - public int Column { get; } + this.Path = path; + this.Line = line; + this.Column = column; } - /// - /// Struct that stores information about a Diagnostic appearing in a source - /// - public struct DiagnosticResult - { - private DiagnosticResultLocation[] locations; + public string Path { get; } + public int Line { get; } + public int Column { get; } +} + +/// +/// Struct that stores information about a Diagnostic appearing in a source +/// +public struct DiagnosticResult +{ + private DiagnosticResultLocation[] locations; - public DiagnosticResultLocation[] Locations + public DiagnosticResultLocation[] Locations + { + get { - get + if (this.locations == null) { - if (this.locations == null) - { - this.locations = new DiagnosticResultLocation[] { }; - } - return this.locations; + this.locations = new DiagnosticResultLocation[] { }; } + return this.locations; + } - set - { - this.locations = value; - } + set + { + this.locations = value; } + } - public DiagnosticSeverity Severity { get; set; } + public DiagnosticSeverity Severity { get; set; } - public string Id { get; set; } + public string Id { get; set; } - public string Message { get; set; } + public string Message { get; set; } - public string Path + public string Path + { + get { - get - { - return this.Locations.Length > 0 ? this.Locations[0].Path : ""; - } + return this.Locations.Length > 0 ? this.Locations[0].Path : ""; } + } - public int Line + public int Line + { + get { - get - { - return this.Locations.Length > 0 ? this.Locations[0].Line : -1; - } + return this.Locations.Length > 0 ? this.Locations[0].Line : -1; } + } - public int Column + public int Column + { + get { - get - { - return this.Locations.Length > 0 ? this.Locations[0].Column : -1; - } + return this.Locations.Length > 0 ? this.Locations[0].Column : -1; } } } diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Framework/DiagnosticVerifier.Helper.cs b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Framework/DiagnosticVerifier.Helper.cs index 4bda70ff9..f0d8ae40e 100644 --- a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Framework/DiagnosticVerifier.Helper.cs +++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Framework/DiagnosticVerifier.Helper.cs @@ -10,155 +10,153 @@ using Microsoft.CodeAnalysis.Diagnostics; using Microsoft.CodeAnalysis.Text; -namespace SMAPI.ModBuildConfig.Analyzer.Tests.Framework +namespace SMAPI.ModBuildConfig.Analyzer.Tests.Framework; + +/// +/// Class for turning strings into documents and getting the diagnostics on them +/// All methods are static +/// +public abstract partial class DiagnosticVerifier { + private static readonly MetadataReference CorlibReference = MetadataReference.CreateFromFile(typeof(object).Assembly.Location); + private static readonly MetadataReference SystemCoreReference = MetadataReference.CreateFromFile(typeof(Enumerable).Assembly.Location); + private static readonly MetadataReference CSharpSymbolsReference = MetadataReference.CreateFromFile(typeof(CSharpCompilation).Assembly.Location); + private static readonly MetadataReference CodeAnalysisReference = MetadataReference.CreateFromFile(typeof(Compilation).Assembly.Location); + private static readonly MetadataReference SelfReference = MetadataReference.CreateFromFile(typeof(DiagnosticVerifier).Assembly.Location); + + internal static string DefaultFilePathPrefix = "Test"; + internal static string CSharpDefaultFileExt = "cs"; + internal static string VisualBasicDefaultExt = "vb"; + internal static string TestProjectName = "TestProject"; + + #region Get Diagnostics + /// - /// Class for turning strings into documents and getting the diagnostics on them - /// All methods are static + /// Given classes in the form of strings, their language, and an IDiagnosticAnalyzer to apply to it, return the diagnostics found in the string after converting it to a document. /// - public abstract partial class DiagnosticVerifier + /// Classes in the form of strings + /// The language the source classes are in + /// The analyzer to be run on the sources + /// An IEnumerable of Diagnostics that surfaced in the source code, sorted by Location + private static Diagnostic[] GetSortedDiagnostics(string[] sources, string language, DiagnosticAnalyzer analyzer) { - private static readonly MetadataReference CorlibReference = MetadataReference.CreateFromFile(typeof(object).Assembly.Location); - private static readonly MetadataReference SystemCoreReference = MetadataReference.CreateFromFile(typeof(Enumerable).Assembly.Location); - private static readonly MetadataReference CSharpSymbolsReference = MetadataReference.CreateFromFile(typeof(CSharpCompilation).Assembly.Location); - private static readonly MetadataReference CodeAnalysisReference = MetadataReference.CreateFromFile(typeof(Compilation).Assembly.Location); - private static readonly MetadataReference SelfReference = MetadataReference.CreateFromFile(typeof(DiagnosticVerifier).Assembly.Location); - - internal static string DefaultFilePathPrefix = "Test"; - internal static string CSharpDefaultFileExt = "cs"; - internal static string VisualBasicDefaultExt = "vb"; - internal static string TestProjectName = "TestProject"; - - #region Get Diagnostics - - /// - /// Given classes in the form of strings, their language, and an IDiagnosticAnalyzer to apply to it, return the diagnostics found in the string after converting it to a document. - /// - /// Classes in the form of strings - /// The language the source classes are in - /// The analyzer to be run on the sources - /// An IEnumerable of Diagnostics that surfaced in the source code, sorted by Location - private static Diagnostic[] GetSortedDiagnostics(string[] sources, string language, DiagnosticAnalyzer analyzer) + return GetSortedDiagnosticsFromDocuments(analyzer, GetDocuments(sources, language)); + } + + /// + /// Given an analyzer and a document to apply it to, run the analyzer and gather an array of diagnostics found in it. + /// The returned diagnostics are then ordered by location in the source document. + /// + /// The analyzer to run on the documents + /// The Documents that the analyzer will be run on + /// An IEnumerable of Diagnostics that surfaced in the source code, sorted by Location + protected static Diagnostic[] GetSortedDiagnosticsFromDocuments(DiagnosticAnalyzer analyzer, Document[] documents) + { + var projects = new HashSet(); + foreach (Document document in documents) { - return GetSortedDiagnosticsFromDocuments(analyzer, GetDocuments(sources, language)); + projects.Add(document.Project); } - /// - /// Given an analyzer and a document to apply it to, run the analyzer and gather an array of diagnostics found in it. - /// The returned diagnostics are then ordered by location in the source document. - /// - /// The analyzer to run on the documents - /// The Documents that the analyzer will be run on - /// An IEnumerable of Diagnostics that surfaced in the source code, sorted by Location - protected static Diagnostic[] GetSortedDiagnosticsFromDocuments(DiagnosticAnalyzer analyzer, Document[] documents) + var diagnostics = new List(); + foreach (Project project in projects) { - var projects = new HashSet(); - foreach (Document document in documents) - { - projects.Add(document.Project); - } - - var diagnostics = new List(); - foreach (Project project in projects) + CompilationWithAnalyzers compilationWithAnalyzers = project.GetCompilationAsync().Result!.WithAnalyzers(ImmutableArray.Create(analyzer)); + var diags = compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync().Result; + foreach (Diagnostic diag in diags) { - CompilationWithAnalyzers compilationWithAnalyzers = project.GetCompilationAsync().Result!.WithAnalyzers(ImmutableArray.Create(analyzer)); - var diags = compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync().Result; - foreach (Diagnostic diag in diags) + if (diag.Location == Location.None || diag.Location.IsInMetadata) { - if (diag.Location == Location.None || diag.Location.IsInMetadata) - { - diagnostics.Add(diag); - } - else + diagnostics.Add(diag); + } + else + { + for (int i = 0; i < documents.Length; i++) { - for (int i = 0; i < documents.Length; i++) + Document document = documents[i]; + SyntaxTree? tree = document.GetSyntaxTreeAsync().Result; + if (tree == diag.Location.SourceTree) { - Document document = documents[i]; - SyntaxTree? tree = document.GetSyntaxTreeAsync().Result; - if (tree == diag.Location.SourceTree) - { - diagnostics.Add(diag); - } + diagnostics.Add(diag); } } } } - - var results = SortDiagnostics(diagnostics); - diagnostics.Clear(); - return results; } - /// - /// Sort diagnostics by location in source document - /// - /// The list of Diagnostics to be sorted - /// An IEnumerable containing the Diagnostics in order of Location - private static Diagnostic[] SortDiagnostics(IEnumerable diagnostics) - { - return diagnostics.OrderBy(d => d.Location.SourceSpan.Start).ToArray(); - } + var results = SortDiagnostics(diagnostics); + diagnostics.Clear(); + return results; + } - #endregion + /// + /// Sort diagnostics by location in source document + /// + /// The list of Diagnostics to be sorted + /// An IEnumerable containing the Diagnostics in order of Location + private static Diagnostic[] SortDiagnostics(IEnumerable diagnostics) + { + return diagnostics.OrderBy(d => d.Location.SourceSpan.Start).ToArray(); + } + + #endregion - #region Set up compilation and documents - /// - /// Given an array of strings as sources and a language, turn them into a project and return the documents and spans of it. - /// - /// Classes in the form of strings - /// The language the source code is in - /// A Tuple containing the Documents produced from the sources and their TextSpans if relevant - private static Document[] GetDocuments(string[] sources, string language) + #region Set up compilation and documents + /// + /// Given an array of strings as sources and a language, turn them into a project and return the documents and spans of it. + /// + /// Classes in the form of strings + /// The language the source code is in + /// A Tuple containing the Documents produced from the sources and their TextSpans if relevant + private static Document[] GetDocuments(string[] sources, string language) + { + if (language != LanguageNames.CSharp && language != LanguageNames.VisualBasic) { - if (language != LanguageNames.CSharp && language != LanguageNames.VisualBasic) - { - throw new ArgumentException("Unsupported Language"); - } + throw new ArgumentException("Unsupported Language"); + } - Project project = CreateProject(sources, language); - var documents = project.Documents.ToArray(); + Project project = CreateProject(sources, language); + var documents = project.Documents.ToArray(); - if (sources.Length != documents.Length) - { - throw new InvalidOperationException("Amount of sources did not match amount of Documents created"); - } - - return documents; + if (sources.Length != documents.Length) + { + throw new InvalidOperationException("Amount of sources did not match amount of Documents created"); } - /// - /// Create a project using the inputted strings as sources. - /// - /// Classes in the form of strings - /// The language the source code is in - /// A Project created out of the Documents created from the source strings - private static Project CreateProject(string[] sources, string language = LanguageNames.CSharp) + return documents; + } + + /// + /// Create a project using the inputted strings as sources. + /// + /// Classes in the form of strings + /// The language the source code is in + /// A Project created out of the Documents created from the source strings + private static Project CreateProject(string[] sources, string language = LanguageNames.CSharp) + { + string fileNamePrefix = DefaultFilePathPrefix; + string fileExt = language == LanguageNames.CSharp ? CSharpDefaultFileExt : VisualBasicDefaultExt; + + ProjectId projectId = ProjectId.CreateNewId(debugName: TestProjectName); + + Solution solution = new AdhocWorkspace() + .CurrentSolution + .AddProject(projectId, TestProjectName, TestProjectName, language) + .AddMetadataReference(projectId, DiagnosticVerifier.SelfReference) + .AddMetadataReference(projectId, CorlibReference) + .AddMetadataReference(projectId, SystemCoreReference) + .AddMetadataReference(projectId, CSharpSymbolsReference) + .AddMetadataReference(projectId, CodeAnalysisReference); + + int count = 0; + foreach (string source in sources) { - string fileNamePrefix = DefaultFilePathPrefix; - string fileExt = language == LanguageNames.CSharp ? CSharpDefaultFileExt : VisualBasicDefaultExt; - - ProjectId projectId = ProjectId.CreateNewId(debugName: TestProjectName); - - Solution solution = new AdhocWorkspace() - .CurrentSolution - .AddProject(projectId, TestProjectName, TestProjectName, language) - .AddMetadataReference(projectId, DiagnosticVerifier.SelfReference) - .AddMetadataReference(projectId, CorlibReference) - .AddMetadataReference(projectId, SystemCoreReference) - .AddMetadataReference(projectId, CSharpSymbolsReference) - .AddMetadataReference(projectId, CodeAnalysisReference); - - int count = 0; - foreach (string source in sources) - { - string newFileName = fileNamePrefix + count + "." + fileExt; - DocumentId documentId = DocumentId.CreateNewId(projectId, debugName: newFileName); - solution = solution.AddDocument(documentId, newFileName, SourceText.From(source)); - count++; - } - return solution.GetProject(projectId)!; + string newFileName = fileNamePrefix + count + "." + fileExt; + DocumentId documentId = DocumentId.CreateNewId(projectId, debugName: newFileName); + solution = solution.AddDocument(documentId, newFileName, SourceText.From(source)); + count++; } - #endregion + return solution.GetProject(projectId)!; } + #endregion } - diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Framework/DiagnosticVerifier.cs b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Framework/DiagnosticVerifier.cs index efe69e4a6..67ac85d75 100644 --- a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Framework/DiagnosticVerifier.cs +++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Framework/DiagnosticVerifier.cs @@ -8,220 +8,219 @@ using Microsoft.CodeAnalysis.Diagnostics; using NUnit.Framework; -namespace SMAPI.ModBuildConfig.Analyzer.Tests.Framework +namespace SMAPI.ModBuildConfig.Analyzer.Tests.Framework; + +/// +/// Superclass of all Unit Tests for DiagnosticAnalyzers +/// +public abstract partial class DiagnosticVerifier { + #region To be implemented by Test classes + /// + /// Get the CSharp analyzer being tested - to be implemented in non-abstract class + /// + protected abstract DiagnosticAnalyzer GetCSharpDiagnosticAnalyzer(); + #endregion + + #region Verifier wrappers + /// - /// Superclass of all Unit Tests for DiagnosticAnalyzers + /// Called to test a C# DiagnosticAnalyzer when applied on the single inputted string as a source + /// Note: input a DiagnosticResult for each Diagnostic expected /// - public abstract partial class DiagnosticVerifier + /// A class in the form of a string to run the analyzer on + /// DiagnosticResults that should appear after the analyzer is run on the source + protected void VerifyCSharpDiagnostic(string source, params DiagnosticResult[] expected) { - #region To be implemented by Test classes - /// - /// Get the CSharp analyzer being tested - to be implemented in non-abstract class - /// - protected abstract DiagnosticAnalyzer GetCSharpDiagnosticAnalyzer(); - #endregion - - #region Verifier wrappers - - /// - /// Called to test a C# DiagnosticAnalyzer when applied on the single inputted string as a source - /// Note: input a DiagnosticResult for each Diagnostic expected - /// - /// A class in the form of a string to run the analyzer on - /// DiagnosticResults that should appear after the analyzer is run on the source - protected void VerifyCSharpDiagnostic(string source, params DiagnosticResult[] expected) - { - this.VerifyDiagnostics(new[] { source }, LanguageNames.CSharp, this.GetCSharpDiagnosticAnalyzer(), expected); - } + this.VerifyDiagnostics(new[] { source }, LanguageNames.CSharp, this.GetCSharpDiagnosticAnalyzer(), expected); + } + + /// + /// General method that gets a collection of actual diagnostics found in the source after the analyzer is run, + /// then verifies each of them. + /// + /// An array of strings to create source documents from to run the analyzers on + /// The language of the classes represented by the source strings + /// The analyzer to be run on the source code + /// DiagnosticResults that should appear after the analyzer is run on the sources + private void VerifyDiagnostics(string[] sources, string language, DiagnosticAnalyzer analyzer, params DiagnosticResult[] expected) + { + var diagnostics = DiagnosticVerifier.GetSortedDiagnostics(sources, language, analyzer); + DiagnosticVerifier.VerifyDiagnosticResults(diagnostics, analyzer, expected); + } + + #endregion + + #region Actual comparisons and verifications + /// + /// Checks each of the actual Diagnostics found and compares them with the corresponding DiagnosticResult in the array of expected results. + /// Diagnostics are considered equal only if the DiagnosticResultLocation, Id, Severity, and Message of the DiagnosticResult match the actual diagnostic. + /// + /// The Diagnostics found by the compiler after running the analyzer on the source code + /// The analyzer that was being run on the sources + /// Diagnostic Results that should have appeared in the code + private static void VerifyDiagnosticResults(IEnumerable actualResults, DiagnosticAnalyzer analyzer, params DiagnosticResult[] expectedResults) + { + int expectedCount = expectedResults.Count(); + int actualCount = actualResults.Count(); - /// - /// General method that gets a collection of actual diagnostics found in the source after the analyzer is run, - /// then verifies each of them. - /// - /// An array of strings to create source documents from to run the analyzers on - /// The language of the classes represented by the source strings - /// The analyzer to be run on the source code - /// DiagnosticResults that should appear after the analyzer is run on the sources - private void VerifyDiagnostics(string[] sources, string language, DiagnosticAnalyzer analyzer, params DiagnosticResult[] expected) + if (expectedCount != actualCount) { - var diagnostics = DiagnosticVerifier.GetSortedDiagnostics(sources, language, analyzer); - DiagnosticVerifier.VerifyDiagnosticResults(diagnostics, analyzer, expected); + string diagnosticsOutput = actualResults.Any() ? DiagnosticVerifier.FormatDiagnostics(analyzer, actualResults.ToArray()) : " NONE."; + + Assert.Fail( + string.Format("Mismatch between number of diagnostics returned, expected \"{0}\" actual \"{1}\"\r\n\r\nDiagnostics:\r\n{2}\r\n", expectedCount, actualCount, diagnosticsOutput)); } - #endregion - - #region Actual comparisons and verifications - /// - /// Checks each of the actual Diagnostics found and compares them with the corresponding DiagnosticResult in the array of expected results. - /// Diagnostics are considered equal only if the DiagnosticResultLocation, Id, Severity, and Message of the DiagnosticResult match the actual diagnostic. - /// - /// The Diagnostics found by the compiler after running the analyzer on the source code - /// The analyzer that was being run on the sources - /// Diagnostic Results that should have appeared in the code - private static void VerifyDiagnosticResults(IEnumerable actualResults, DiagnosticAnalyzer analyzer, params DiagnosticResult[] expectedResults) + for (int i = 0; i < expectedResults.Length; i++) { - int expectedCount = expectedResults.Count(); - int actualCount = actualResults.Count(); + var actual = actualResults.ElementAt(i); + var expected = expectedResults[i]; - if (expectedCount != actualCount) + if (expected.Line == -1 && expected.Column == -1) { - string diagnosticsOutput = actualResults.Any() ? DiagnosticVerifier.FormatDiagnostics(analyzer, actualResults.ToArray()) : " NONE."; - - Assert.IsTrue(false, - string.Format("Mismatch between number of diagnostics returned, expected \"{0}\" actual \"{1}\"\r\n\r\nDiagnostics:\r\n{2}\r\n", expectedCount, actualCount, diagnosticsOutput)); + if (actual.Location != Location.None) + { + Assert.Fail( + string.Format("Expected:\nA project diagnostic with No location\nActual:\n{0}", + DiagnosticVerifier.FormatDiagnostics(analyzer, actual))); + } } - - for (int i = 0; i < expectedResults.Length; i++) + else { - var actual = actualResults.ElementAt(i); - var expected = expectedResults[i]; + DiagnosticVerifier.VerifyDiagnosticLocation(analyzer, actual, actual.Location, expected.Locations.First()); + var additionalLocations = actual.AdditionalLocations.ToArray(); - if (expected.Line == -1 && expected.Column == -1) + if (additionalLocations.Length != expected.Locations.Length - 1) { - if (actual.Location != Location.None) - { - Assert.IsTrue(false, - string.Format("Expected:\nA project diagnostic with No location\nActual:\n{0}", + Assert.Fail( + string.Format("Expected {0} additional locations but got {1} for Diagnostic:\r\n {2}\r\n", + expected.Locations.Length - 1, additionalLocations.Length, DiagnosticVerifier.FormatDiagnostics(analyzer, actual))); - } - } - else - { - DiagnosticVerifier.VerifyDiagnosticLocation(analyzer, actual, actual.Location, expected.Locations.First()); - var additionalLocations = actual.AdditionalLocations.ToArray(); - - if (additionalLocations.Length != expected.Locations.Length - 1) - { - Assert.IsTrue(false, - string.Format("Expected {0} additional locations but got {1} for Diagnostic:\r\n {2}\r\n", - expected.Locations.Length - 1, additionalLocations.Length, - DiagnosticVerifier.FormatDiagnostics(analyzer, actual))); - } - - for (int j = 0; j < additionalLocations.Length; ++j) - { - DiagnosticVerifier.VerifyDiagnosticLocation(analyzer, actual, additionalLocations[j], expected.Locations[j + 1]); - } } - if (actual.Id != expected.Id) + for (int j = 0; j < additionalLocations.Length; ++j) { - Assert.IsTrue(false, - string.Format("Expected diagnostic id to be \"{0}\" was \"{1}\"\r\n\r\nDiagnostic:\r\n {2}\r\n", - expected.Id, actual.Id, DiagnosticVerifier.FormatDiagnostics(analyzer, actual))); + DiagnosticVerifier.VerifyDiagnosticLocation(analyzer, actual, additionalLocations[j], expected.Locations[j + 1]); } + } - if (actual.Severity != expected.Severity) - { - Assert.IsTrue(false, - string.Format("Expected diagnostic severity to be \"{0}\" was \"{1}\"\r\n\r\nDiagnostic:\r\n {2}\r\n", - expected.Severity, actual.Severity, DiagnosticVerifier.FormatDiagnostics(analyzer, actual))); - } + if (actual.Id != expected.Id) + { + Assert.Fail( + string.Format("Expected diagnostic id to be \"{0}\" was \"{1}\"\r\n\r\nDiagnostic:\r\n {2}\r\n", + expected.Id, actual.Id, DiagnosticVerifier.FormatDiagnostics(analyzer, actual))); + } - if (actual.GetMessage() != expected.Message) - { - Assert.IsTrue(false, - string.Format("Expected diagnostic message to be \"{0}\" was \"{1}\"\r\n\r\nDiagnostic:\r\n {2}\r\n", - expected.Message, actual.GetMessage(), DiagnosticVerifier.FormatDiagnostics(analyzer, actual))); - } + if (actual.Severity != expected.Severity) + { + Assert.Fail( + string.Format("Expected diagnostic severity to be \"{0}\" was \"{1}\"\r\n\r\nDiagnostic:\r\n {2}\r\n", + expected.Severity, actual.Severity, DiagnosticVerifier.FormatDiagnostics(analyzer, actual))); + } + + if (actual.GetMessage() != expected.Message) + { + Assert.Fail( + string.Format("Expected diagnostic message to be \"{0}\" was \"{1}\"\r\n\r\nDiagnostic:\r\n {2}\r\n", + expected.Message, actual.GetMessage(), DiagnosticVerifier.FormatDiagnostics(analyzer, actual))); } } + } - /// - /// Helper method to VerifyDiagnosticResult that checks the location of a diagnostic and compares it with the location in the expected DiagnosticResult. - /// - /// The analyzer that was being run on the sources - /// The diagnostic that was found in the code - /// The Location of the Diagnostic found in the code - /// The DiagnosticResultLocation that should have been found - private static void VerifyDiagnosticLocation(DiagnosticAnalyzer analyzer, Diagnostic diagnostic, Location actual, DiagnosticResultLocation expected) - { - var actualSpan = actual.GetLineSpan(); + /// + /// Helper method to VerifyDiagnosticResult that checks the location of a diagnostic and compares it with the location in the expected DiagnosticResult. + /// + /// The analyzer that was being run on the sources + /// The diagnostic that was found in the code + /// The Location of the Diagnostic found in the code + /// The DiagnosticResultLocation that should have been found + private static void VerifyDiagnosticLocation(DiagnosticAnalyzer analyzer, Diagnostic diagnostic, Location actual, DiagnosticResultLocation expected) + { + var actualSpan = actual.GetLineSpan(); - Assert.IsTrue(actualSpan.Path == expected.Path || (actualSpan.Path != null && actualSpan.Path.Contains("Test0.") && expected.Path.Contains("Test.")), - string.Format("Expected diagnostic to be in file \"{0}\" was actually in file \"{1}\"\r\n\r\nDiagnostic:\r\n {2}\r\n", - expected.Path, actualSpan.Path, DiagnosticVerifier.FormatDiagnostics(analyzer, diagnostic))); + Assert.That(actualSpan.Path == expected.Path || (actualSpan.Path != null && actualSpan.Path.Contains("Test0.") && expected.Path.Contains("Test.")), + string.Format("Expected diagnostic to be in file \"{0}\" was actually in file \"{1}\"\r\n\r\nDiagnostic:\r\n {2}\r\n", + expected.Path, actualSpan.Path, DiagnosticVerifier.FormatDiagnostics(analyzer, diagnostic))); - var actualLinePosition = actualSpan.StartLinePosition; + var actualLinePosition = actualSpan.StartLinePosition; - // Only check line position if there is an actual line in the real diagnostic - if (actualLinePosition.Line > 0) + // Only check line position if there is an actual line in the real diagnostic + if (actualLinePosition.Line > 0) + { + if (actualLinePosition.Line + 1 != expected.Line) { - if (actualLinePosition.Line + 1 != expected.Line) - { - Assert.IsTrue(false, - string.Format("Expected diagnostic to be on line \"{0}\" was actually on line \"{1}\"\r\n\r\nDiagnostic:\r\n {2}\r\n", - expected.Line, actualLinePosition.Line + 1, DiagnosticVerifier.FormatDiagnostics(analyzer, diagnostic))); - } + Assert.Fail( + string.Format("Expected diagnostic to be on line \"{0}\" was actually on line \"{1}\"\r\n\r\nDiagnostic:\r\n {2}\r\n", + expected.Line, actualLinePosition.Line + 1, DiagnosticVerifier.FormatDiagnostics(analyzer, diagnostic))); } + } - // Only check column position if there is an actual column position in the real diagnostic - if (actualLinePosition.Character > 0) + // Only check column position if there is an actual column position in the real diagnostic + if (actualLinePosition.Character > 0) + { + if (actualLinePosition.Character + 1 != expected.Column) { - if (actualLinePosition.Character + 1 != expected.Column) - { - Assert.IsTrue(false, - string.Format("Expected diagnostic to start at column \"{0}\" was actually at column \"{1}\"\r\n\r\nDiagnostic:\r\n {2}\r\n", - expected.Column, actualLinePosition.Character + 1, DiagnosticVerifier.FormatDiagnostics(analyzer, diagnostic))); - } + Assert.Fail( + string.Format("Expected diagnostic to start at column \"{0}\" was actually at column \"{1}\"\r\n\r\nDiagnostic:\r\n {2}\r\n", + expected.Column, actualLinePosition.Character + 1, DiagnosticVerifier.FormatDiagnostics(analyzer, diagnostic))); } } - #endregion - - #region Formatting Diagnostics - /// - /// Helper method to format a Diagnostic into an easily readable string - /// - /// The analyzer that this verifier tests - /// The Diagnostics to be formatted - /// The Diagnostics formatted as a string - private static string FormatDiagnostics(DiagnosticAnalyzer analyzer, params Diagnostic[] diagnostics) + } + #endregion + + #region Formatting Diagnostics + /// + /// Helper method to format a Diagnostic into an easily readable string + /// + /// The analyzer that this verifier tests + /// The Diagnostics to be formatted + /// The Diagnostics formatted as a string + private static string FormatDiagnostics(DiagnosticAnalyzer analyzer, params Diagnostic[] diagnostics) + { + var builder = new StringBuilder(); + for (int i = 0; i < diagnostics.Length; ++i) { - var builder = new StringBuilder(); - for (int i = 0; i < diagnostics.Length; ++i) - { - builder.AppendLine("// " + diagnostics[i]); + builder.AppendLine("// " + diagnostics[i]); - var analyzerType = analyzer.GetType(); - var rules = analyzer.SupportedDiagnostics; + var analyzerType = analyzer.GetType(); + var rules = analyzer.SupportedDiagnostics; - foreach (var rule in rules) + foreach (var rule in rules) + { + if (rule != null && rule.Id == diagnostics[i].Id) { - if (rule != null && rule.Id == diagnostics[i].Id) + var location = diagnostics[i].Location; + if (location == Location.None) { - var location = diagnostics[i].Location; - if (location == Location.None) - { - builder.AppendFormat("GetGlobalResult({0}.{1})", analyzerType.Name, rule.Id); - } - else - { - Assert.IsTrue(location.IsInSource, - $"Test base does not currently handle diagnostics in metadata locations. Diagnostic in metadata: {diagnostics[i]}\r\n"); - - var linePosition = diagnostics[i].Location.GetLineSpan().StartLinePosition; - - builder.AppendFormat("{0}({1}, {2}, {3}.{4})", - "GetCSharpResultAt", - linePosition.Line + 1, - linePosition.Character + 1, - analyzerType.Name, - rule.Id); - } - - if (i != diagnostics.Length - 1) - { - builder.Append(','); - } - - builder.AppendLine(); - break; + builder.AppendFormat("GetGlobalResult({0}.{1})", analyzerType.Name, rule.Id); } + else + { + Assert.That(location.IsInSource, + $"Test base does not currently handle diagnostics in metadata locations. Diagnostic in metadata: {diagnostics[i]}\r\n"); + + var linePosition = diagnostics[i].Location.GetLineSpan().StartLinePosition; + + builder.AppendFormat("{0}({1}, {2}, {3}.{4})", + "GetCSharpResultAt", + linePosition.Line + 1, + linePosition.Character + 1, + analyzerType.Name, + rule.Id); + } + + if (i != diagnostics.Length - 1) + { + builder.Append(','); + } + + builder.AppendLine(); + break; } } - return builder.ToString(); } - #endregion + return builder.ToString(); } + #endregion } diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetCollection.cs b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetCollection.cs index 8bedd5839..79ea95c5d 100644 --- a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetCollection.cs +++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetCollection.cs @@ -1,8 +1,7 @@ // ReSharper disable CheckNamespace -- matches Stardew Valley's code using System.Collections.ObjectModel; -namespace Netcode -{ - /// A simplified version of Stardew Valley's Netcode.NetCollection for unit testing. - public class NetCollection : Collection { } -} +namespace Netcode; + +/// A simplified version of Stardew Valley's Netcode.NetCollection for unit testing. +public class NetCollection : Collection { } diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetFieldBase.cs b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetFieldBase.cs index 8f6b89877..d7441dc7c 100644 --- a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetFieldBase.cs +++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetFieldBase.cs @@ -1,19 +1,18 @@ // ReSharper disable CheckNamespace -- matches Stardew Valley's code -namespace Netcode +namespace Netcode; + +/// A simplified version of Stardew Valley's Netcode.NetFieldBase for unit testing. +/// The type of the synchronized value. +/// The type of the current instance. +public class NetFieldBase where TSelf : NetFieldBase { - /// A simplified version of Stardew Valley's Netcode.NetFieldBase for unit testing. - /// The type of the synchronized value. - /// The type of the current instance. - public class NetFieldBase where TSelf : NetFieldBase - { - /// The synchronised value. - public T? Value { get; set; } + /// The synchronised value. + public T? Value { get; set; } - /// Implicitly convert a net field to the its type. - /// The field to convert. - public static implicit operator T?(NetFieldBase field) - { - return field.Value; - } + /// Implicitly convert a net field to the its type. + /// The field to convert. + public static implicit operator T?(NetFieldBase field) + { + return field.Value; } } diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetInt.cs b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetInt.cs index b3abc4671..7b38156f5 100644 --- a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetInt.cs +++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetInt.cs @@ -1,6 +1,5 @@ // ReSharper disable CheckNamespace -- matches Stardew Valley's code -namespace Netcode -{ - /// A simplified version of Stardew Valley's Netcode.NetInt for unit testing. - public class NetInt : NetFieldBase { } -} +namespace Netcode; + +/// A simplified version of Stardew Valley's Netcode.NetInt for unit testing. +public class NetInt : NetFieldBase { } diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetList.cs b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetList.cs index 33e616fbd..70dd2826b 100644 --- a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetList.cs +++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetList.cs @@ -1,8 +1,7 @@ // ReSharper disable CheckNamespace -- matches Stardew Valley's code using System.Collections.Generic; -namespace Netcode -{ - /// A simplified version of Stardew Valley's Netcode.NetObjectList for unit testing. - public class NetList : List { } -} +namespace Netcode; + +/// A simplified version of Stardew Valley's Netcode.NetObjectList for unit testing. +public class NetList : List { } diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetObjectList.cs b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetObjectList.cs index 7814e7d65..88b296397 100644 --- a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetObjectList.cs +++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetObjectList.cs @@ -1,6 +1,5 @@ // ReSharper disable CheckNamespace -- matches Stardew Valley's code -namespace Netcode -{ - /// A simplified version of Stardew Valley's Netcode.NetObjectList for unit testing. - public class NetObjectList : NetList { } -} +namespace Netcode; + +/// A simplified version of Stardew Valley's Netcode.NetObjectList for unit testing. +public class NetObjectList : NetList { } diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetRef.cs b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetRef.cs index be2459ccc..0d46075de 100644 --- a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetRef.cs +++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetRef.cs @@ -1,6 +1,5 @@ // ReSharper disable CheckNamespace -- matches Stardew Valley's code -namespace Netcode -{ - /// A simplified version of Stardew Valley's Netcode.NetRef for unit testing. - public class NetRef : NetFieldBase> where T : class { } -} +namespace Netcode; + +/// A simplified version of Stardew Valley's Netcode.NetRef for unit testing. +public class NetRef : NetFieldBase> where T : class { } diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/Farmer.cs b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/Farmer.cs index dbd057929..cebf80d0e 100644 --- a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/Farmer.cs +++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/Farmer.cs @@ -2,12 +2,11 @@ // ReSharper disable UnusedMember.Global -- used dynamically for unit tests using System.Collections.Generic; -namespace StardewValley +namespace StardewValley; + +/// A simplified version of Stardew Valley's StardewValley.Farmer class for unit testing. +internal class Farmer { - /// A simplified version of Stardew Valley's StardewValley.Farmer class for unit testing. - internal class Farmer - { - /// A sample field which should be replaced with a different property. - public readonly IDictionary friendships = new Dictionary(); - } + /// A sample field which should be replaced with a different property. + public readonly IDictionary friendships = new Dictionary(); } diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/Item.cs b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/Item.cs index d50deb729..f05b740a0 100644 --- a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/Item.cs +++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/Item.cs @@ -2,33 +2,32 @@ // ReSharper disable UnusedMember.Global -- used dynamically for unit tests using Netcode; -namespace StardewValley +namespace StardewValley; + +/// A simplified version of Stardew Valley's StardewValley.Item class for unit testing. +public class Item { - /// A simplified version of Stardew Valley's StardewValley.Item class for unit testing. - public class Item - { - /// A net int field with an equivalent non-net Category property. - public readonly NetInt category = new() { Value = 42 }; + /// A net int field with an equivalent non-net Category property. + public readonly NetInt category = new() { Value = 42 }; - /// A generic net int field with no equivalent non-net property. - public readonly NetInt netIntField = new() { Value = 42 }; + /// A generic net int field with no equivalent non-net property. + public readonly NetInt netIntField = new() { Value = 42 }; - /// A generic net ref field with no equivalent non-net property. - public readonly NetRef netRefField = new(); + /// A generic net ref field with no equivalent non-net property. + public readonly NetRef netRefField = new(); - /// A generic net int property with no equivalent non-net property. - public NetInt netIntProperty = new() { Value = 42 }; + /// A generic net int property with no equivalent non-net property. + public NetInt netIntProperty = new() { Value = 42 }; - /// A generic net ref property with no equivalent non-net property. - public NetRef netRefProperty { get; } = new(); + /// A generic net ref property with no equivalent non-net property. + public NetRef netRefProperty { get; } = new(); - /// A sample net list. - public readonly NetList netList = new(); + /// A sample net list. + public readonly NetList netList = new(); - /// A sample net object list. - public readonly NetObjectList netObjectList = new(); + /// A sample net object list. + public readonly NetObjectList netObjectList = new(); - /// A sample net collection. - public readonly NetCollection netCollection = new(); - } + /// A sample net collection. + public readonly NetCollection netCollection = new(); } diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/Object.cs b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/Object.cs index 151010a78..202fde953 100644 --- a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/Object.cs +++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/Object.cs @@ -1,12 +1,11 @@ // ReSharper disable CheckNamespace, InconsistentNaming -- matches Stardew Valley's code using Netcode; -namespace StardewValley +namespace StardewValley; + +/// A simplified version of Stardew Valley's StardewValley.Object class for unit testing. +public class Object : Item { - /// A simplified version of Stardew Valley's StardewValley.Object class for unit testing. - public class Object : Item - { - /// A net int field with an equivalent non-net property. - public NetInt type = new() { Value = 42 }; - } + /// A net int field with an equivalent non-net property. + public NetInt type = new() { Value = 42 }; } diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/NetFieldAnalyzerTests.cs b/src/SMAPI.ModBuildConfig.Analyzer.Tests/NetFieldAnalyzerTests.cs index a6fa56339..c287f0621 100644 --- a/src/SMAPI.ModBuildConfig.Analyzer.Tests/NetFieldAnalyzerTests.cs +++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/NetFieldAnalyzerTests.cs @@ -4,157 +4,104 @@ using SMAPI.ModBuildConfig.Analyzer.Tests.Framework; using StardewModdingAPI.ModBuildConfig.Analyzer; -namespace SMAPI.ModBuildConfig.Analyzer.Tests +namespace SMAPI.ModBuildConfig.Analyzer.Tests; + +/// Unit tests for . +[TestFixture] +public class NetFieldAnalyzerTests : DiagnosticVerifier { - /// Unit tests for . - [TestFixture] - public class NetFieldAnalyzerTests : DiagnosticVerifier - { - /********* - ** Fields - *********/ - /// Sample C# mod code, with a {{test-code}} placeholder for the code in the Entry method to test. - const string SampleProgram = @" - using System; - using StardewValley; - using Netcode; - using SObject = StardewValley.Object; + /********* + ** Fields + *********/ + /// Sample C# mod code, with a {{test-code}} placeholder for the code in the Entry method to test. + const string SampleProgram = @" + using System; + using StardewValley; + using Netcode; + using SObject = StardewValley.Object; - namespace SampleMod + namespace SampleMod + { + class ModEntry { - class ModEntry + public void Entry() { - public void Entry() - { - {{test-code}} - } + {{test-code}} } } - "; - - /// The line number where the unit tested code is injected into . - private const int SampleCodeLine = 13; - - /// The column number where the unit tested code is injected into . - private const int SampleCodeColumn = 25; + } + "; + /// The line number where the unit tested code is injected into . + private const int SampleCodeLine = 13; - /********* - ** Unit tests - *********/ - /// Test that no diagnostics are raised for an empty code block. - [TestCase] - public void EmptyCode_HasNoDiagnostics() - { - // arrange - string test = @""; + /// The column number where the unit tested code is injected into . + private const int SampleCodeColumn = 25; - // assert - this.VerifyCSharpDiagnostic(test); - } - /// Test that the expected diagnostic message is raised for implicit net field comparisons. - /// The code line to test. - /// The column within the code line where the diagnostic message should be reported. - /// The expression which should be reported. - /// The source type name which should be reported. - /// The target type name which should be reported. - [TestCase("Item item = null; if (item.netIntField < 42);", 22, "item.netIntField", "NetInt", "int")] // ↓ implicit conversion - [TestCase("Item item = null; if (item.netIntField <= 42);", 22, "item.netIntField", "NetInt", "int")] - [TestCase("Item item = null; if (item.netIntField > 42);", 22, "item.netIntField", "NetInt", "int")] - [TestCase("Item item = null; if (item.netIntField >= 42);", 22, "item.netIntField", "NetInt", "int")] - [TestCase("Item item = null; if (item.netIntField == 42);", 22, "item.netIntField", "NetInt", "int")] - [TestCase("Item item = null; if (item.netIntField != 42);", 22, "item.netIntField", "NetInt", "int")] - [TestCase("Item item = null; if (item?.netIntField != 42);", 22, "item?.netIntField", "NetInt", "int")] - [TestCase("Item item = null; if (item?.netIntField != null);", 22, "item?.netIntField", "NetInt", "object")] - [TestCase("Item item = null; if (item.netIntProperty < 42);", 22, "item.netIntProperty", "NetInt", "int")] - [TestCase("Item item = null; if (item.netIntProperty <= 42);", 22, "item.netIntProperty", "NetInt", "int")] - [TestCase("Item item = null; if (item.netIntProperty > 42);", 22, "item.netIntProperty", "NetInt", "int")] - [TestCase("Item item = null; if (item.netIntProperty >= 42);", 22, "item.netIntProperty", "NetInt", "int")] - [TestCase("Item item = null; if (item.netIntProperty == 42);", 22, "item.netIntProperty", "NetInt", "int")] - [TestCase("Item item = null; if (item.netIntProperty != 42);", 22, "item.netIntProperty", "NetInt", "int")] - [TestCase("Item item = null; if (item?.netIntProperty != 42);", 22, "item?.netIntProperty", "NetInt", "int")] - [TestCase("Item item = null; if (item?.netIntProperty != null);", 22, "item?.netIntProperty", "NetInt", "object")] - [TestCase("Item item = null; if (item.netRefField == null);", 22, "item.netRefField", "NetRef", "object")] - [TestCase("Item item = null; if (item.netRefField != null);", 22, "item.netRefField", "NetRef", "object")] - [TestCase("Item item = null; if (item.netRefProperty == null);", 22, "item.netRefProperty", "NetRef", "object")] - [TestCase("Item item = null; if (item.netRefProperty != null);", 22, "item.netRefProperty", "NetRef", "object")] - [TestCase("SObject obj = null; if (obj.netIntField != 42);", 24, "obj.netIntField", "NetInt", "int")] // ↓ implicit conversion for parent field - [TestCase("SObject obj = null; if (obj.netIntProperty != 42);", 24, "obj.netIntProperty", "NetInt", "int")] - [TestCase("SObject obj = null; if (obj.netRefField == null);", 24, "obj.netRefField", "NetRef", "object")] - [TestCase("SObject obj = null; if (obj.netRefField != null);", 24, "obj.netRefField", "NetRef", "object")] - [TestCase("SObject obj = null; if (obj.netRefProperty == null);", 24, "obj.netRefProperty", "NetRef", "object")] - [TestCase("SObject obj = null; if (obj.netRefProperty != null);", 24, "obj.netRefProperty", "NetRef", "object")] - [TestCase("Item item = new Item(); object list = item.netList;", 38, "item.netList", "NetList", "object")] // ↓ NetList field converted to a non-interface type - [TestCase("Item item = new Item(); object list = item.netCollection;", 38, "item.netCollection", "NetCollection", "object")] - [TestCase("Item item = new Item(); int x = (int)item.netIntField;", 32, "item.netIntField", "NetFieldBase", "int")] // ↓ explicit conversion to invalid type - [TestCase("Item item = new Item(); int x = item.netRefField as object;", 32, "item.netRefField", "NetRef", "object")] - public void AvoidImplicitNetFieldComparisons_RaisesDiagnostic(string codeText, int column, string expression, string fromType, string toType) - { - // arrange - string code = NetFieldAnalyzerTests.SampleProgram.Replace("{{test-code}}", codeText); - DiagnosticResult expected = new() - { - Id = "AvoidImplicitNetFieldCast", - Message = $"This implicitly converts '{expression}' from {fromType} to {toType}, but {fromType} has unintuitive implicit conversion rules. Consider comparing against the actual value instead to avoid bugs. See https://smapi.io/package/avoid-implicit-net-field-cast for details.", - Severity = DiagnosticSeverity.Warning, - Locations = new[] { new DiagnosticResultLocation("Test0.cs", NetFieldAnalyzerTests.SampleCodeLine, NetFieldAnalyzerTests.SampleCodeColumn + column) } - }; + /********* + ** Unit tests + *********/ + /// Test that no diagnostics are raised for an empty code block. + [TestCase] + public void EmptyCode_HasNoDiagnostics() + { + // arrange + string test = @""; - // assert - this.VerifyCSharpDiagnostic(code, expected); - } + // assert + this.VerifyCSharpDiagnostic(test); + } - /// Test that the net field analyzer doesn't raise any warnings for safe member access. - /// The code line to test. - [TestCase("Item item = new Item(); System.Collections.IEnumerable list = farmer.eventsSeen;")] - [TestCase("Item item = new Item(); System.Collections.Generic.IEnumerable list = farmer.netList;")] - [TestCase("Item item = new Item(); System.Collections.Generic.IList list = farmer.netList;")] - [TestCase("Item item = new Item(); System.Collections.Generic.ICollection list = farmer.netCollection;")] - [TestCase("Item item = new Item(); System.Collections.Generic.IList list = farmer.netObjectList;")] // subclass of NetList - public void AvoidImplicitNetFieldComparisons_AllowsSafeAccess(string codeText) - { - // arrange - string code = NetFieldAnalyzerTests.SampleProgram.Replace("{{test-code}}", codeText); + /// Test that the net field analyzer doesn't raise any warnings for safe member access. + /// The code line to test. + [TestCase("Item item = new Item(); System.Collections.IEnumerable list = farmer.eventsSeen;")] + [TestCase("Item item = new Item(); System.Collections.Generic.IEnumerable list = farmer.netList;")] + [TestCase("Item item = new Item(); System.Collections.Generic.IList list = farmer.netList;")] + [TestCase("Item item = new Item(); System.Collections.Generic.ICollection list = farmer.netCollection;")] + [TestCase("Item item = new Item(); System.Collections.Generic.IList list = farmer.netObjectList;")] // subclass of NetList + public void AvoidImplicitNetFieldComparisons_AllowsSafeAccess(string codeText) + { + // arrange + string code = NetFieldAnalyzerTests.SampleProgram.Replace("{{test-code}}", codeText); - // assert - this.VerifyCSharpDiagnostic(code); - } + // assert + this.VerifyCSharpDiagnostic(code); + } - /// Test that the expected diagnostic message is raised for avoidable net field references. - /// The code line to test. - /// The column within the code line where the diagnostic message should be reported. - /// The expression which should be reported. - /// The net type name which should be reported. - /// The suggested property name which should be reported. - [TestCase("Item item = null; int category = item.category;", 33, "item.category", "NetInt", "Category")] - [TestCase("Item item = null; int category = (item).category;", 33, "(item).category", "NetInt", "Category")] - [TestCase("Item item = null; int category = ((Item)item).category;", 33, "((Item)item).category", "NetInt", "Category")] - [TestCase("SObject obj = null; int category = obj.category;", 35, "obj.category", "NetInt", "Category")] - public void AvoidNetFields_RaisesDiagnostic(string codeText, int column, string expression, string netType, string suggestedProperty) + /// Test that the expected diagnostic message is raised for avoidable net field references. + /// The code line to test. + /// The column within the code line where the diagnostic message should be reported. + /// The expression which should be reported. + /// The net type name which should be reported. + /// The suggested property name which should be reported. + [TestCase("Item item = null; int category = item.category;", 33, "item.category", "NetInt", "Category")] + [TestCase("Item item = null; int category = (item).category;", 33, "(item).category", "NetInt", "Category")] + [TestCase("Item item = null; int category = ((Item)item).category;", 33, "((Item)item).category", "NetInt", "Category")] + [TestCase("SObject obj = null; int category = obj.category;", 35, "obj.category", "NetInt", "Category")] + public void AvoidNetFields_RaisesDiagnostic(string codeText, int column, string expression, string netType, string suggestedProperty) + { + // arrange + string code = NetFieldAnalyzerTests.SampleProgram.Replace("{{test-code}}", codeText); + DiagnosticResult expected = new() { - // arrange - string code = NetFieldAnalyzerTests.SampleProgram.Replace("{{test-code}}", codeText); - DiagnosticResult expected = new() - { - Id = "AvoidNetField", - Message = $"'{expression}' is a {netType} field; consider using the {suggestedProperty} property instead. See https://smapi.io/package/avoid-net-field for details.", - Severity = DiagnosticSeverity.Warning, - Locations = new[] { new DiagnosticResultLocation("Test0.cs", NetFieldAnalyzerTests.SampleCodeLine, NetFieldAnalyzerTests.SampleCodeColumn + column) } - }; + Id = "AvoidNetField", + Message = $"'{expression}' is a {netType} field; consider using the {suggestedProperty} property instead. See https://smapi.io/package/avoid-net-field for details.", + Severity = DiagnosticSeverity.Warning, + Locations = new[] { new DiagnosticResultLocation("Test0.cs", NetFieldAnalyzerTests.SampleCodeLine, NetFieldAnalyzerTests.SampleCodeColumn + column) } + }; - // assert - this.VerifyCSharpDiagnostic(code, expected); - } + // assert + this.VerifyCSharpDiagnostic(code, expected); + } - /********* - ** Helpers - *********/ - /// Get the analyzer being tested. - protected override DiagnosticAnalyzer GetCSharpDiagnosticAnalyzer() - { - return new NetFieldAnalyzer(); - } + /********* + ** Helpers + *********/ + /// Get the analyzer being tested. + protected override DiagnosticAnalyzer GetCSharpDiagnosticAnalyzer() + { + return new NetFieldAnalyzer(); } } diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/ObsoleteFieldAnalyzerTests.cs b/src/SMAPI.ModBuildConfig.Analyzer.Tests/ObsoleteFieldAnalyzerTests.cs index 76607b8e6..06e741ca1 100644 --- a/src/SMAPI.ModBuildConfig.Analyzer.Tests/ObsoleteFieldAnalyzerTests.cs +++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/ObsoleteFieldAnalyzerTests.cs @@ -4,86 +4,85 @@ using SMAPI.ModBuildConfig.Analyzer.Tests.Framework; using StardewModdingAPI.ModBuildConfig.Analyzer; -namespace SMAPI.ModBuildConfig.Analyzer.Tests +namespace SMAPI.ModBuildConfig.Analyzer.Tests; + +/// Unit tests for . +[TestFixture] +public class ObsoleteFieldAnalyzerTests : DiagnosticVerifier { - /// Unit tests for . - [TestFixture] - public class ObsoleteFieldAnalyzerTests : DiagnosticVerifier - { - /********* - ** Fields - *********/ - /// Sample C# mod code, with a {{test-code}} placeholder for the code in the Entry method to test. - const string SampleProgram = @" - using System; - using StardewValley; - using Netcode; - using SObject = StardewValley.Object; + /********* + ** Fields + *********/ + /// Sample C# mod code, with a {{test-code}} placeholder for the code in the Entry method to test. + const string SampleProgram = @" + using System; + using StardewValley; + using Netcode; + using SObject = StardewValley.Object; - namespace SampleMod + namespace SampleMod + { + class ModEntry { - class ModEntry + public void Entry() { - public void Entry() - { - {{test-code}} - } + {{test-code}} } } - "; + } + "; - /// The line number where the unit tested code is injected into . - private const int SampleCodeLine = 13; + /// The line number where the unit tested code is injected into . + private const int SampleCodeLine = 13; - /// The column number where the unit tested code is injected into . - private const int SampleCodeColumn = 25; + /// The column number where the unit tested code is injected into . + private const int SampleCodeColumn = 25; - /********* - ** Unit tests - *********/ - /// Test that no diagnostics are raised for an empty code block. - [TestCase] - public void EmptyCode_HasNoDiagnostics() - { - // arrange - string test = @""; + /********* + ** Unit tests + *********/ + /// Test that no diagnostics are raised for an empty code block. + [TestCase] + public void EmptyCode_HasNoDiagnostics() + { + // arrange + string test = @""; - // assert - this.VerifyCSharpDiagnostic(test); - } + // assert + this.VerifyCSharpDiagnostic(test); + } - /// Test that the expected diagnostic message is raised for an obsolete field reference. - /// The code line to test. - /// The column within the code line where the diagnostic message should be reported. - /// The old field name which should be reported. - /// The new field name which should be reported. - [TestCase("var x = new Farmer().friendships;", 8, "StardewValley.Farmer.friendships", "friendshipData")] - [TestCase("var x = new Farmer()?.friendships;", 8, "StardewValley.Farmer.friendships", "friendshipData")] - public void AvoidObsoleteField_RaisesDiagnostic(string codeText, int column, string oldName, string newName) + /// Test that the expected diagnostic message is raised for an obsolete field reference. + /// The code line to test. + /// The column within the code line where the diagnostic message should be reported. + /// The old field name which should be reported. + /// The new field name which should be reported. + [TestCase("var x = new Farmer().friendships;", 8, "StardewValley.Farmer.friendships", "friendshipData")] + [TestCase("var x = new Farmer()?.friendships;", 8, "StardewValley.Farmer.friendships", "friendshipData")] + public void AvoidObsoleteField_RaisesDiagnostic(string codeText, int column, string oldName, string newName) + { + // arrange + string code = ObsoleteFieldAnalyzerTests.SampleProgram.Replace("{{test-code}}", codeText); + DiagnosticResult expected = new() { - // arrange - string code = ObsoleteFieldAnalyzerTests.SampleProgram.Replace("{{test-code}}", codeText); - DiagnosticResult expected = new() - { - Id = "AvoidObsoleteField", - Message = $"The '{oldName}' field is obsolete and should be replaced with '{newName}'. See https://smapi.io/package/avoid-obsolete-field for details.", - Severity = DiagnosticSeverity.Warning, - Locations = new[] { new DiagnosticResultLocation("Test0.cs", ObsoleteFieldAnalyzerTests.SampleCodeLine, ObsoleteFieldAnalyzerTests.SampleCodeColumn + column) } - }; + Id = "AvoidObsoleteField", + Message = $"The '{oldName}' field is obsolete and should be replaced with '{newName}'. See https://smapi.io/package/avoid-obsolete-field for details.", + Severity = DiagnosticSeverity.Warning, + Locations = new[] { new DiagnosticResultLocation("Test0.cs", ObsoleteFieldAnalyzerTests.SampleCodeLine, ObsoleteFieldAnalyzerTests.SampleCodeColumn + column) } + }; - // assert - this.VerifyCSharpDiagnostic(code, expected); - } + // assert + this.VerifyCSharpDiagnostic(code, expected); + } - /********* - ** Helpers - *********/ - /// Get the analyzer being tested. - protected override DiagnosticAnalyzer GetCSharpDiagnosticAnalyzer() - { - return new ObsoleteFieldAnalyzer(); - } + /********* + ** Helpers + *********/ + /// Get the analyzer being tested. + protected override DiagnosticAnalyzer GetCSharpDiagnosticAnalyzer() + { + return new ObsoleteFieldAnalyzer(); } } diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/SMAPI.ModBuildConfig.Analyzer.Tests.csproj b/src/SMAPI.ModBuildConfig.Analyzer.Tests/SMAPI.ModBuildConfig.Analyzer.Tests.csproj index c63935c25..f1c9af760 100644 --- a/src/SMAPI.ModBuildConfig.Analyzer.Tests/SMAPI.ModBuildConfig.Analyzer.Tests.csproj +++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/SMAPI.ModBuildConfig.Analyzer.Tests.csproj @@ -5,10 +5,10 @@ - - - - + + + + diff --git a/src/SMAPI.ModBuildConfig.Analyzer/AnalyzerReleases.Shipped.md b/src/SMAPI.ModBuildConfig.Analyzer/AnalyzerReleases.Shipped.md index 9a46676df..a0dfaf897 100644 --- a/src/SMAPI.ModBuildConfig.Analyzer/AnalyzerReleases.Shipped.md +++ b/src/SMAPI.ModBuildConfig.Analyzer/AnalyzerReleases.Shipped.md @@ -2,6 +2,5 @@ ### New Rules Rule ID | Category | Severity | Notes ------------------------- | ------------------ | -------- | ------------------------------------------------------------ -AvoidImplicitNetFieldCast | SMAPI.CommonErrors | Warning | See [documentation](https://smapi.io/package/code-warnings). AvoidNetField | SMAPI.CommonErrors | Warning | See [documentation](https://smapi.io/package/code-warnings). AvoidObsoleteField | SMAPI.CommonErrors | Warning | See [documentation](https://smapi.io/package/code-warnings). diff --git a/src/SMAPI.ModBuildConfig.Analyzer/AnalyzerUtilities.cs b/src/SMAPI.ModBuildConfig.Analyzer/AnalyzerUtilities.cs index 2e34cf719..57f8234fa 100644 --- a/src/SMAPI.ModBuildConfig.Analyzer/AnalyzerUtilities.cs +++ b/src/SMAPI.ModBuildConfig.Analyzer/AnalyzerUtilities.cs @@ -3,91 +3,90 @@ using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; -namespace StardewModdingAPI.ModBuildConfig.Analyzer +namespace StardewModdingAPI.ModBuildConfig.Analyzer; + +/// Provides generic utilities for SMAPI's Roslyn analyzers. +internal static class AnalyzerUtilities { - /// Provides generic utilities for SMAPI's Roslyn analyzers. - internal static class AnalyzerUtilities + /********* + ** Public methods + *********/ + /// Get the metadata for an explicit cast or 'x as y' expression. + /// The member access expression. + /// provides methods for asking semantic questions about syntax nodes. + /// The expression whose value is being converted. + /// The type being converted from. + /// The type being converted to. + /// Returns true if the node is a matched expression, else false. + public static bool TryGetCastOrAsInfo(SyntaxNode node, SemanticModel semanticModel, out ExpressionSyntax fromExpression, out TypeInfo fromType, out TypeInfo toType) { - /********* - ** Public methods - *********/ - /// Get the metadata for an explicit cast or 'x as y' expression. - /// The member access expression. - /// provides methods for asking semantic questions about syntax nodes. - /// The expression whose value is being converted. - /// The type being converted from. - /// The type being converted to. - /// Returns true if the node is a matched expression, else false. - public static bool TryGetCastOrAsInfo(SyntaxNode node, SemanticModel semanticModel, out ExpressionSyntax fromExpression, out TypeInfo fromType, out TypeInfo toType) + // (type)x + if (node is CastExpressionSyntax cast) { - // (type)x - if (node is CastExpressionSyntax cast) - { - fromExpression = cast.Expression; - fromType = semanticModel.GetTypeInfo(fromExpression); - toType = semanticModel.GetTypeInfo(cast.Type); - return true; - } - - // x as y - if (node is BinaryExpressionSyntax binary && binary.Kind() == SyntaxKind.AsExpression) - { - fromExpression = binary.Left; - fromType = semanticModel.GetTypeInfo(fromExpression); - toType = semanticModel.GetTypeInfo(binary.Right); - return true; - } - - // invalid - fromExpression = null; - fromType = default; - toType = default; - return false; + fromExpression = cast.Expression; + fromType = semanticModel.GetTypeInfo(fromExpression); + toType = semanticModel.GetTypeInfo(cast.Type); + return true; } - /// Get the metadata for a member access expression. - /// The member access expression. - /// provides methods for asking semantic questions about syntax nodes. - /// The object type which has the member. - /// The type of the accessed member. - /// The name of the accessed member. - /// Returns true if the node is a member access expression, else false. - public static bool TryGetMemberInfo(SyntaxNode node, SemanticModel semanticModel, out ITypeSymbol declaringType, out TypeInfo memberType, out string memberName) + // x as y + if (node is BinaryExpressionSyntax binary && binary.Kind() == SyntaxKind.AsExpression) { - // simple access - if (node is MemberAccessExpressionSyntax memberAccess) - { - declaringType = semanticModel.GetTypeInfo(memberAccess.Expression).Type; - memberType = semanticModel.GetTypeInfo(node); - memberName = memberAccess.Name.Identifier.Text; - return true; - } + fromExpression = binary.Left; + fromType = semanticModel.GetTypeInfo(fromExpression); + toType = semanticModel.GetTypeInfo(binary.Right); + return true; + } - // conditional access - if (node is ConditionalAccessExpressionSyntax { WhenNotNull: MemberBindingExpressionSyntax conditionalBinding } conditionalAccess) - { - declaringType = semanticModel.GetTypeInfo(conditionalAccess.Expression).Type; - memberType = semanticModel.GetTypeInfo(node); - memberName = conditionalBinding.Name.Identifier.Text; - return true; - } + // invalid + fromExpression = null; + fromType = default; + toType = default; + return false; + } - // invalid - declaringType = null; - memberType = default; - memberName = null; - return false; + /// Get the metadata for a member access expression. + /// The member access expression. + /// provides methods for asking semantic questions about syntax nodes. + /// The object type which has the member. + /// The type of the accessed member. + /// The name of the accessed member. + /// Returns true if the node is a member access expression, else false. + public static bool TryGetMemberInfo(SyntaxNode node, SemanticModel semanticModel, out ITypeSymbol declaringType, out TypeInfo memberType, out string memberName) + { + // simple access + if (node is MemberAccessExpressionSyntax memberAccess) + { + declaringType = semanticModel.GetTypeInfo(memberAccess.Expression).Type; + memberType = semanticModel.GetTypeInfo(node); + memberName = memberAccess.Name.Identifier.Text; + return true; } - /// Get the class types in a type's inheritance chain, including itself. - /// The initial type. - public static IEnumerable GetConcreteTypes(ITypeSymbol type) + // conditional access + if (node is ConditionalAccessExpressionSyntax { WhenNotNull: MemberBindingExpressionSyntax conditionalBinding } conditionalAccess) + { + declaringType = semanticModel.GetTypeInfo(conditionalAccess.Expression).Type; + memberType = semanticModel.GetTypeInfo(node); + memberName = conditionalBinding.Name.Identifier.Text; + return true; + } + + // invalid + declaringType = null; + memberType = default; + memberName = null; + return false; + } + + /// Get the class types in a type's inheritance chain, including itself. + /// The initial type. + public static IEnumerable GetConcreteTypes(ITypeSymbol type) + { + while (type != null) { - while (type != null) - { - yield return type; - type = type.BaseType; - } + yield return type; + type = type.BaseType; } } } diff --git a/src/SMAPI.ModBuildConfig.Analyzer/NetFieldAnalyzer.cs b/src/SMAPI.ModBuildConfig.Analyzer/NetFieldAnalyzer.cs index 553aae99a..9b6e840a4 100644 --- a/src/SMAPI.ModBuildConfig.Analyzer/NetFieldAnalyzer.cs +++ b/src/SMAPI.ModBuildConfig.Analyzer/NetFieldAnalyzer.cs @@ -1,329 +1,224 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; -using System.Linq; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Diagnostics; -namespace StardewModdingAPI.ModBuildConfig.Analyzer +namespace StardewModdingAPI.ModBuildConfig.Analyzer; + +/// Detects implicit conversion from Stardew Valley's Netcode types. These have very unintuitive implicit conversion rules, so mod authors should always explicitly convert the type with appropriate null checks. +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class NetFieldAnalyzer : DiagnosticAnalyzer { - /// Detects implicit conversion from Stardew Valley's Netcode types. These have very unintuitive implicit conversion rules, so mod authors should always explicitly convert the type with appropriate null checks. - [DiagnosticAnalyzer(LanguageNames.CSharp)] - public class NetFieldAnalyzer : DiagnosticAnalyzer + /********* + ** Fields + *********/ + /// The namespace for Stardew Valley's Netcode types. + private const string NetcodeNamespace = "Netcode"; + + /// Maps net fields to their equivalent non-net properties where available. + private readonly IDictionary NetFieldWrapperProperties = new Dictionary { - /********* - ** Fields - *********/ - /// The namespace for Stardew Valley's Netcode types. - private const string NetcodeNamespace = "Netcode"; - - /// Maps net fields to their equivalent non-net properties where available. - private readonly IDictionary NetFieldWrapperProperties = new Dictionary - { - // AnimatedSprite - ["StardewValley.AnimatedSprite::currentAnimation"] = "CurrentAnimation", - ["StardewValley.AnimatedSprite::currentFrame"] = "CurrentFrame", - ["StardewValley.AnimatedSprite::sourceRect"] = "SourceRect", - ["StardewValley.AnimatedSprite::spriteHeight"] = "SpriteHeight", - ["StardewValley.AnimatedSprite::spriteWidth"] = "SpriteWidth", - - // Character - ["StardewValley.Character::currentLocationRef"] = "currentLocation", - ["StardewValley.Character::facingDirection"] = "FacingDirection", - ["StardewValley.Character::name"] = "Name", - ["StardewValley.Character::position"] = "Position", - ["StardewValley.Character::scale"] = "Scale", - ["StardewValley.Character::speed"] = "Speed", - ["StardewValley.Character::sprite"] = "Sprite", - - // Chest - ["StardewValley.Objects.Chest::tint"] = "Tint", - - // Farmer - ["StardewValley.Farmer::houseUpgradeLevel"] = "HouseUpgradeLevel", - ["StardewValley.Farmer::isMale"] = "IsMale", - ["StardewValley.Farmer::items"] = "Items", - ["StardewValley.Farmer::magneticRadius"] = "MagneticRadius", - ["StardewValley.Farmer::stamina"] = "Stamina", - ["StardewValley.Farmer::uniqueMultiplayerID"] = "UniqueMultiplayerID", - ["StardewValley.Farmer::usingTool"] = "UsingTool", - - // Forest - ["StardewValley.Locations.Forest::netTravelingMerchantDay"] = "travelingMerchantDay", - ["StardewValley.Locations.Forest::netLog"] = "log", - - // FruitTree - ["StardewValley.TerrainFeatures.FruitTree::greenHouseTileTree"] = "GreenHouseTileTree", - ["StardewValley.TerrainFeatures.FruitTree::greenHouseTree"] = "GreenHouseTree", - - // GameLocation - ["StardewValley.GameLocation::isFarm"] = "IsFarm", - ["StardewValley.GameLocation::isOutdoors"] = "IsOutdoors", - ["StardewValley.GameLocation::lightLevel"] = "LightLevel", - ["StardewValley.GameLocation::name"] = "Name", - - // Item - ["StardewValley.Item::category"] = "Category", - ["StardewValley.Item::netName"] = "Name", - ["StardewValley.Item::parentSheetIndex"] = "ParentSheetIndex", - ["StardewValley.Item::specialVariable"] = "SpecialVariable", - - // Junimo - ["StardewValley.Characters.Junimo::eventActor"] = "EventActor", - - // LightSource - ["StardewValley.LightSource::identifier"] = "Identifier", - - // Monster - ["StardewValley.Monsters.Monster::damageToFarmer"] = "DamageToFarmer", - ["StardewValley.Monsters.Monster::experienceGained"] = "ExperienceGained", - ["StardewValley.Monsters.Monster::health"] = "Health", - ["StardewValley.Monsters.Monster::maxHealth"] = "MaxHealth", - ["StardewValley.Monsters.Monster::netFocusedOnFarmers"] = "focusedOnFarmers", - ["StardewValley.Monsters.Monster::netWildernessFarmMonster"] = "wildernessFarmMonster", - ["StardewValley.Monsters.Monster::slipperiness"] = "Slipperiness", - - // NPC - ["StardewValley.NPC::age"] = "Age", - ["StardewValley.NPC::birthday_Day"] = "Birthday_Day", - ["StardewValley.NPC::birthday_Season"] = "Birthday_Season", - ["StardewValley.NPC::breather"] = "Breather", - ["StardewValley.NPC::defaultMap"] = "DefaultMap", - ["StardewValley.NPC::gender"] = "Gender", - ["StardewValley.NPC::hideShadow"] = "HideShadow", - ["StardewValley.NPC::isInvisible"] = "IsInvisible", - ["StardewValley.NPC::isWalkingTowardPlayer"] = "IsWalkingTowardPlayer", - ["StardewValley.NPC::manners"] = "Manners", - ["StardewValley.NPC::optimism"] = "Optimism", - ["StardewValley.NPC::socialAnxiety"] = "SocialAnxiety", - - // Object - ["StardewValley.Object::canBeGrabbed"] = "CanBeGrabbed", - ["StardewValley.Object::canBeSetDown"] = "CanBeSetDown", - ["StardewValley.Object::edibility"] = "Edibility", - ["StardewValley.Object::flipped"] = "Flipped", - ["StardewValley.Object::fragility"] = "Fragility", - ["StardewValley.Object::hasBeenPickedUpByFarmer"] = "HasBeenPickedUpByFarmer", - ["StardewValley.Object::isHoedirt"] = "IsHoeDirt", - ["StardewValley.Object::isOn"] = "IsOn", - ["StardewValley.Object::isRecipe"] = "IsRecipe", - ["StardewValley.Object::isSpawnedObject"] = "IsSpawnedObject", - ["StardewValley.Object::minutesUntilReady"] = "MinutesUntilReady", - ["StardewValley.Object::netName"] = "name", - ["StardewValley.Object::price"] = "Price", - ["StardewValley.Object::quality"] = "Quality", - ["StardewValley.Object::stack"] = "Stack", - ["StardewValley.Object::tileLocation"] = "TileLocation", - ["StardewValley.Object::type"] = "Type", - - // Projectile - ["StardewValley.Projectiles.Projectile::ignoreLocationCollision"] = "IgnoreLocationCollision", - - // Tool - ["StardewValley.Tool::currentParentTileIndex"] = "CurrentParentTileIndex", - ["StardewValley.Tool::indexOfMenuItemView"] = "IndexOfMenuItemView", - ["StardewValley.Tool::initialParentTileIndex"] = "InitialParentTileIndex", - ["StardewValley.Tool::instantUse"] = "InstantUse", - ["StardewValley.Tool::netName"] = "BaseName", - ["StardewValley.Tool::stackable"] = "Stackable", - ["StardewValley.Tool::upgradeLevel"] = "UpgradeLevel" - }; + // AnimatedSprite + ["StardewValley.AnimatedSprite::currentAnimation"] = "CurrentAnimation", + ["StardewValley.AnimatedSprite::currentFrame"] = "CurrentFrame", + ["StardewValley.AnimatedSprite::sourceRect"] = "SourceRect", + ["StardewValley.AnimatedSprite::spriteHeight"] = "SpriteHeight", + ["StardewValley.AnimatedSprite::spriteWidth"] = "SpriteWidth", + + // Character + ["StardewValley.Character::currentLocationRef"] = "currentLocation", + ["StardewValley.Character::facingDirection"] = "FacingDirection", + ["StardewValley.Character::name"] = "Name", + ["StardewValley.Character::position"] = "Position", + ["StardewValley.Character::scale"] = "Scale", + ["StardewValley.Character::speed"] = "Speed", + ["StardewValley.Character::sprite"] = "Sprite", + + // Chest + ["StardewValley.Objects.Chest::tint"] = "Tint", + + // Farmer + ["StardewValley.Farmer::houseUpgradeLevel"] = "HouseUpgradeLevel", + ["StardewValley.Farmer::isMale"] = "IsMale", + ["StardewValley.Farmer::items"] = "Items", + ["StardewValley.Farmer::magneticRadius"] = "MagneticRadius", + ["StardewValley.Farmer::stamina"] = "Stamina", + ["StardewValley.Farmer::uniqueMultiplayerID"] = "UniqueMultiplayerID", + ["StardewValley.Farmer::usingTool"] = "UsingTool", + + // Forest + ["StardewValley.Locations.Forest::netTravelingMerchantDay"] = "travelingMerchantDay", + ["StardewValley.Locations.Forest::netLog"] = "log", + + // FruitTree + ["StardewValley.TerrainFeatures.FruitTree::greenHouseTileTree"] = "GreenHouseTileTree", + ["StardewValley.TerrainFeatures.FruitTree::greenHouseTree"] = "GreenHouseTree", + + // GameLocation + ["StardewValley.GameLocation::isFarm"] = "IsFarm", + ["StardewValley.GameLocation::isOutdoors"] = "IsOutdoors", + ["StardewValley.GameLocation::lightLevel"] = "LightLevel", + ["StardewValley.GameLocation::name"] = "Name", + + // Item + ["StardewValley.Item::category"] = "Category", + ["StardewValley.Item::netName"] = "Name", + ["StardewValley.Item::parentSheetIndex"] = "ParentSheetIndex", + ["StardewValley.Item::specialVariable"] = "SpecialVariable", + + // Junimo + ["StardewValley.Characters.Junimo::eventActor"] = "EventActor", + + // LightSource + ["StardewValley.LightSource::identifier"] = "Identifier", + + // Monster + ["StardewValley.Monsters.Monster::damageToFarmer"] = "DamageToFarmer", + ["StardewValley.Monsters.Monster::experienceGained"] = "ExperienceGained", + ["StardewValley.Monsters.Monster::health"] = "Health", + ["StardewValley.Monsters.Monster::maxHealth"] = "MaxHealth", + ["StardewValley.Monsters.Monster::netFocusedOnFarmers"] = "focusedOnFarmers", + ["StardewValley.Monsters.Monster::netWildernessFarmMonster"] = "wildernessFarmMonster", + ["StardewValley.Monsters.Monster::slipperiness"] = "Slipperiness", + + // NPC + ["StardewValley.NPC::age"] = "Age", + ["StardewValley.NPC::birthday_Day"] = "Birthday_Day", + ["StardewValley.NPC::birthday_Season"] = "Birthday_Season", + ["StardewValley.NPC::breather"] = "Breather", + ["StardewValley.NPC::defaultMap"] = "DefaultMap", + ["StardewValley.NPC::gender"] = "Gender", + ["StardewValley.NPC::hideShadow"] = "HideShadow", + ["StardewValley.NPC::isInvisible"] = "IsInvisible", + ["StardewValley.NPC::isWalkingTowardPlayer"] = "IsWalkingTowardPlayer", + ["StardewValley.NPC::manners"] = "Manners", + ["StardewValley.NPC::optimism"] = "Optimism", + ["StardewValley.NPC::socialAnxiety"] = "SocialAnxiety", + + // Object + ["StardewValley.Object::canBeGrabbed"] = "CanBeGrabbed", + ["StardewValley.Object::canBeSetDown"] = "CanBeSetDown", + ["StardewValley.Object::edibility"] = "Edibility", + ["StardewValley.Object::flipped"] = "Flipped", + ["StardewValley.Object::fragility"] = "Fragility", + ["StardewValley.Object::hasBeenPickedUpByFarmer"] = "HasBeenPickedUpByFarmer", + ["StardewValley.Object::isHoedirt"] = "IsHoeDirt", + ["StardewValley.Object::isOn"] = "IsOn", + ["StardewValley.Object::isRecipe"] = "IsRecipe", + ["StardewValley.Object::isSpawnedObject"] = "IsSpawnedObject", + ["StardewValley.Object::minutesUntilReady"] = "MinutesUntilReady", + ["StardewValley.Object::netName"] = "name", + ["StardewValley.Object::price"] = "Price", + ["StardewValley.Object::quality"] = "Quality", + ["StardewValley.Object::stack"] = "Stack", + ["StardewValley.Object::tileLocation"] = "TileLocation", + ["StardewValley.Object::type"] = "Type", + + // Projectile + ["StardewValley.Projectiles.Projectile::ignoreLocationCollision"] = "IgnoreLocationCollision", + + // Tool + ["StardewValley.Tool::currentParentTileIndex"] = "CurrentParentTileIndex", + ["StardewValley.Tool::indexOfMenuItemView"] = "IndexOfMenuItemView", + ["StardewValley.Tool::initialParentTileIndex"] = "InitialParentTileIndex", + ["StardewValley.Tool::instantUse"] = "InstantUse", + ["StardewValley.Tool::netName"] = "BaseName", + ["StardewValley.Tool::stackable"] = "Stackable", + ["StardewValley.Tool::upgradeLevel"] = "UpgradeLevel" + }; + + /// The diagnostic info for an avoidable net field access. + private readonly DiagnosticDescriptor AvoidNetFieldRule = new( + id: "AvoidNetField", + title: "Avoid Netcode types when possible", + messageFormat: "'{0}' is a {1} field; consider using the {2} property instead. See https://smapi.io/package/avoid-net-field for details.", + category: "SMAPI.CommonErrors", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + helpLinkUri: "https://smapi.io/package/avoid-net-field" + ); + + + /********* + ** Accessors + *********/ + /// The descriptors for the diagnostics that this analyzer is capable of producing. + public override ImmutableArray SupportedDiagnostics { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + public NetFieldAnalyzer() + { + this.SupportedDiagnostics = ImmutableArray.Create(this.AvoidNetFieldRule); + } - /// The diagnostic info for an implicit net field cast. - private readonly DiagnosticDescriptor AvoidImplicitNetFieldCastRule = new( - id: "AvoidImplicitNetFieldCast", - title: "Netcode types shouldn't be implicitly converted", - messageFormat: "This implicitly converts '{0}' from {1} to {2}, but {1} has unintuitive implicit conversion rules. Consider comparing against the actual value instead to avoid bugs. See https://smapi.io/package/avoid-implicit-net-field-cast for details.", - category: "SMAPI.CommonErrors", - defaultSeverity: DiagnosticSeverity.Warning, - isEnabledByDefault: true, - helpLinkUri: "https://smapi.io/package/avoid-implicit-net-field-cast" - ); + /// Called once at session start to register actions in the analysis context. + /// The analysis context. + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze | GeneratedCodeAnalysisFlags.ReportDiagnostics); + context.EnableConcurrentExecution(); - /// The diagnostic info for an avoidable net field access. - private readonly DiagnosticDescriptor AvoidNetFieldRule = new( - id: "AvoidNetField", - title: "Avoid Netcode types when possible", - messageFormat: "'{0}' is a {1} field; consider using the {2} property instead. See https://smapi.io/package/avoid-net-field for details.", - category: "SMAPI.CommonErrors", - defaultSeverity: DiagnosticSeverity.Warning, - isEnabledByDefault: true, - helpLinkUri: "https://smapi.io/package/avoid-net-field" + context.RegisterSyntaxNodeAction( + this.AnalyzeMemberAccess, + SyntaxKind.SimpleMemberAccessExpression, + SyntaxKind.ConditionalAccessExpression ); + } - /********* - ** Accessors - *********/ - /// The descriptors for the diagnostics that this analyzer is capable of producing. - public override ImmutableArray SupportedDiagnostics { get; } - - - /********* - ** Public methods - *********/ - /// Construct an instance. - public NetFieldAnalyzer() - { - this.SupportedDiagnostics = ImmutableArray.CreateRange(new[] { this.AvoidNetFieldRule, this.AvoidImplicitNetFieldCastRule }); - } - - /// Called once at session start to register actions in the analysis context. - /// The analysis context. - public override void Initialize(AnalysisContext context) - { - context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze | GeneratedCodeAnalysisFlags.ReportDiagnostics); - context.EnableConcurrentExecution(); - - context.RegisterSyntaxNodeAction( - this.AnalyzeMemberAccess, - SyntaxKind.SimpleMemberAccessExpression, - SyntaxKind.ConditionalAccessExpression - ); - context.RegisterSyntaxNodeAction( - this.AnalyzeCast, - SyntaxKind.CastExpression, - SyntaxKind.AsExpression - ); - context.RegisterSyntaxNodeAction( - this.AnalyzeBinaryComparison, - SyntaxKind.EqualsExpression, - SyntaxKind.NotEqualsExpression, - SyntaxKind.GreaterThanExpression, - SyntaxKind.GreaterThanOrEqualExpression, - SyntaxKind.LessThanExpression, - SyntaxKind.LessThanOrEqualExpression - ); - } - - - /********* - ** Private methods - *********/ - /// Analyze a member access syntax node and add a diagnostic message if applicable. - /// The analysis context. - /// Returns whether any warnings were added. - private void AnalyzeMemberAccess(SyntaxNodeAnalysisContext context) - { - this.HandleErrors(context.Node, () => - { - // get member access info - if (!AnalyzerUtilities.TryGetMemberInfo(context.Node, context.SemanticModel, out ITypeSymbol declaringType, out TypeInfo memberType, out string memberName)) - return; - if (!this.IsNetType(memberType.Type)) - return; - - // warn: use property wrapper if available - foreach (ITypeSymbol type in AnalyzerUtilities.GetConcreteTypes(declaringType)) - { - if (this.NetFieldWrapperProperties.TryGetValue($"{type}::{memberName}", out string suggestedPropertyName)) - { - context.ReportDiagnostic(Diagnostic.Create(this.AvoidNetFieldRule, context.Node.GetLocation(), context.Node, memberType.Type.Name, suggestedPropertyName)); - return; - } - } - - // warn: implicit conversion - if (this.IsInvalidConversion(memberType.Type, memberType.ConvertedType)) - context.ReportDiagnostic(Diagnostic.Create(this.AvoidImplicitNetFieldCastRule, context.Node.GetLocation(), context.Node, memberType.Type.Name, memberType.ConvertedType)); - }); - } - - /// Analyze an explicit cast or 'x as y' node and add a diagnostic message if applicable. - /// The analysis context. - /// Returns whether any warnings were added. - private void AnalyzeCast(SyntaxNodeAnalysisContext context) - { - // NOTE: implicit conversion within the expression is detected by the member access - // checks. This method is only concerned with the conversion of its final value. - this.HandleErrors(context.Node, () => - { - if (AnalyzerUtilities.TryGetCastOrAsInfo(context.Node, context.SemanticModel, out ExpressionSyntax fromExpression, out TypeInfo fromType, out TypeInfo toType)) - { - if (this.IsInvalidConversion(fromType.ConvertedType, toType.Type)) - context.ReportDiagnostic(Diagnostic.Create(this.AvoidImplicitNetFieldCastRule, context.Node.GetLocation(), fromExpression, fromType.ConvertedType.Name, toType.Type)); - } - }); - } - - /// Analyze a binary comparison syntax node and add a diagnostic message if applicable. - /// The analysis context. - /// Returns whether any warnings were added. - private void AnalyzeBinaryComparison(SyntaxNodeAnalysisContext context) + /********* + ** Private methods + *********/ + /// Analyze a member access syntax node and add a diagnostic message if applicable. + /// The analysis context. + /// Returns whether any warnings were added. + private void AnalyzeMemberAccess(SyntaxNodeAnalysisContext context) + { + this.HandleErrors(context.Node, () => { - // NOTE: implicit conversion within an operand is detected by the member access checks. - // This method is only concerned with the conversion of each side's final value. - this.HandleErrors(context.Node, () => + // get member access info + if (!AnalyzerUtilities.TryGetMemberInfo(context.Node, context.SemanticModel, out ITypeSymbol declaringType, out TypeInfo memberType, out string memberName)) + return; + if (!this.IsNetType(memberType.Type)) + return; + + // warn: use property wrapper if available + foreach (ITypeSymbol type in AnalyzerUtilities.GetConcreteTypes(declaringType)) { - BinaryExpressionSyntax expression = (BinaryExpressionSyntax)context.Node; - foreach (var pair in new[] { Tuple.Create(expression.Left, expression.Right), Tuple.Create(expression.Right, expression.Left) }) + if (this.NetFieldWrapperProperties.TryGetValue($"{type}::{memberName}", out string suggestedPropertyName)) { - // get node info - ExpressionSyntax curExpression = pair.Item1; // the side of the comparison being examined - ExpressionSyntax otherExpression = pair.Item2; // the other side - TypeInfo curType = context.SemanticModel.GetTypeInfo(curExpression); - TypeInfo otherType = context.SemanticModel.GetTypeInfo(otherExpression); - if (!this.IsNetType(curType.ConvertedType)) - continue; - - // warn for comparison to null - // An expression like `building.indoors != null` will sometimes convert `building.indoors` to NetFieldBase instead of object before comparison. Haven't reproduced this in unit tests yet. - Optional otherValue = context.SemanticModel.GetConstantValue(otherExpression); - if (otherValue.HasValue && otherValue.Value == null) - { - context.ReportDiagnostic(Diagnostic.Create(this.AvoidImplicitNetFieldCastRule, context.Node.GetLocation(), curExpression, curType.Type.Name, "null")); - break; - } - - // warn for implicit conversion - if (!this.IsNetType(otherType.ConvertedType)) - { - context.ReportDiagnostic(Diagnostic.Create(this.AvoidImplicitNetFieldCastRule, context.Node.GetLocation(), curExpression, curType.Type.Name, curType.ConvertedType)); - break; - } + context.ReportDiagnostic(Diagnostic.Create(this.AvoidNetFieldRule, context.Node.GetLocation(), context.Node, memberType.Type.Name, suggestedPropertyName)); + return; } - }); - } - - /// Handle exceptions raised while analyzing a node. - /// The node being analyzed. - /// The callback to invoke. - private void HandleErrors(SyntaxNode node, Action action) - { - try - { - action(); - } - catch (Exception ex) - { - throw new InvalidOperationException($"Failed processing expression: '{node}'. Exception details: {ex.ToString().Replace('\r', ' ').Replace('\n', ' ')}"); } - } + }); + } - /// Get whether a net field was converted in an error-prone way. - /// The source type. - /// The target type. - private bool IsInvalidConversion(ITypeSymbol fromType, ITypeSymbol toType) + /// Handle exceptions raised while analyzing a node. + /// The node being analyzed. + /// The callback to invoke. + private void HandleErrors(SyntaxNode node, Action action) + { + try { - // no conversion - if (!this.IsNetType(fromType) || this.IsNetType(toType)) - return false; - - // conversion to implemented interface is OK - if (fromType.AllInterfaces.Contains(toType, SymbolEqualityComparer.Default)) - return false; - - // avoid any other conversions - return true; + action(); } - - /// Get whether a type symbol references a Netcode type. - /// The type symbol. - private bool IsNetType(ITypeSymbol typeSymbol) + catch (Exception ex) { - return typeSymbol?.ContainingNamespace?.Name == NetFieldAnalyzer.NetcodeNamespace; + throw new InvalidOperationException($"Failed processing expression: '{node}'. Exception details: {ex.ToString().Replace('\r', ' ').Replace('\n', ' ')}"); } } + + /// Get whether a type symbol references a Netcode type. + /// The type symbol. + private bool IsNetType(ITypeSymbol typeSymbol) + { + return typeSymbol?.ContainingNamespace?.Name == NetFieldAnalyzer.NetcodeNamespace; + } } diff --git a/src/SMAPI.ModBuildConfig.Analyzer/ObsoleteFieldAnalyzer.cs b/src/SMAPI.ModBuildConfig.Analyzer/ObsoleteFieldAnalyzer.cs index ba0895135..fb95a3cc0 100644 --- a/src/SMAPI.ModBuildConfig.Analyzer/ObsoleteFieldAnalyzer.cs +++ b/src/SMAPI.ModBuildConfig.Analyzer/ObsoleteFieldAnalyzer.cs @@ -5,95 +5,94 @@ using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.Diagnostics; -namespace StardewModdingAPI.ModBuildConfig.Analyzer +namespace StardewModdingAPI.ModBuildConfig.Analyzer; + +/// Detects references to a field which has been replaced. +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class ObsoleteFieldAnalyzer : DiagnosticAnalyzer { - /// Detects references to a field which has been replaced. - [DiagnosticAnalyzer(LanguageNames.CSharp)] - public class ObsoleteFieldAnalyzer : DiagnosticAnalyzer + /********* + ** Fields + *********/ + /// Maps obsolete fields/properties to their non-obsolete equivalent. + private readonly IDictionary ReplacedFields = new Dictionary { - /********* - ** Fields - *********/ - /// Maps obsolete fields/properties to their non-obsolete equivalent. - private readonly IDictionary ReplacedFields = new Dictionary - { - // Farmer - ["StardewValley.Farmer::friendships"] = "friendshipData" - }; + // Farmer + ["StardewValley.Farmer::friendships"] = "friendshipData" + }; - /// Describes the diagnostic rule covered by the analyzer. - private readonly IDictionary Rules = new Dictionary - { - ["AvoidObsoleteField"] = new( - id: "AvoidObsoleteField", - title: "Reference to obsolete field", - messageFormat: "The '{0}' field is obsolete and should be replaced with '{1}'. See https://smapi.io/package/avoid-obsolete-field for details.", - category: "SMAPI.CommonErrors", - defaultSeverity: DiagnosticSeverity.Warning, - isEnabledByDefault: true, - helpLinkUri: "https://smapi.io/package/avoid-obsolete-field" - ) - }; + /// Describes the diagnostic rule covered by the analyzer. + private readonly IDictionary Rules = new Dictionary + { + ["AvoidObsoleteField"] = new( + id: "AvoidObsoleteField", + title: "Reference to obsolete field", + messageFormat: "The '{0}' field is obsolete and should be replaced with '{1}'. See https://smapi.io/package/avoid-obsolete-field for details.", + category: "SMAPI.CommonErrors", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + helpLinkUri: "https://smapi.io/package/avoid-obsolete-field" + ) + }; - /********* - ** Accessors - *********/ - /// The descriptors for the diagnostics that this analyzer is capable of producing. - public override ImmutableArray SupportedDiagnostics { get; } + /********* + ** Accessors + *********/ + /// The descriptors for the diagnostics that this analyzer is capable of producing. + public override ImmutableArray SupportedDiagnostics { get; } - /********* - ** Public methods - *********/ - /// Construct an instance. - public ObsoleteFieldAnalyzer() - { - this.SupportedDiagnostics = ImmutableArray.CreateRange(this.Rules.Values); - } + /********* + ** Public methods + *********/ + /// Construct an instance. + public ObsoleteFieldAnalyzer() + { + this.SupportedDiagnostics = ImmutableArray.CreateRange(this.Rules.Values); + } - /// Called once at session start to register actions in the analysis context. - /// The analysis context. - public override void Initialize(AnalysisContext context) - { - context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze | GeneratedCodeAnalysisFlags.ReportDiagnostics); - context.EnableConcurrentExecution(); + /// Called once at session start to register actions in the analysis context. + /// The analysis context. + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze | GeneratedCodeAnalysisFlags.ReportDiagnostics); + context.EnableConcurrentExecution(); - context.RegisterSyntaxNodeAction( - this.AnalyzeObsoleteFields, - SyntaxKind.SimpleMemberAccessExpression, - SyntaxKind.ConditionalAccessExpression - ); - } + context.RegisterSyntaxNodeAction( + this.AnalyzeObsoleteFields, + SyntaxKind.SimpleMemberAccessExpression, + SyntaxKind.ConditionalAccessExpression + ); + } - /********* - ** Private methods - *********/ - /// Analyze a syntax node and add a diagnostic message if it references an obsolete field. - /// The analysis context. - private void AnalyzeObsoleteFields(SyntaxNodeAnalysisContext context) + /********* + ** Private methods + *********/ + /// Analyze a syntax node and add a diagnostic message if it references an obsolete field. + /// The analysis context. + private void AnalyzeObsoleteFields(SyntaxNodeAnalysisContext context) + { + try { - try - { - // get reference info - if (!AnalyzerUtilities.TryGetMemberInfo(context.Node, context.SemanticModel, out ITypeSymbol declaringType, out _, out string memberName)) - return; + // get reference info + if (!AnalyzerUtilities.TryGetMemberInfo(context.Node, context.SemanticModel, out ITypeSymbol declaringType, out _, out string memberName)) + return; - // suggest replacement - foreach (ITypeSymbol type in AnalyzerUtilities.GetConcreteTypes(declaringType)) + // suggest replacement + foreach (ITypeSymbol type in AnalyzerUtilities.GetConcreteTypes(declaringType)) + { + if (this.ReplacedFields.TryGetValue($"{type}::{memberName}", out string replacement)) { - if (this.ReplacedFields.TryGetValue($"{type}::{memberName}", out string replacement)) - { - context.ReportDiagnostic(Diagnostic.Create(this.Rules["AvoidObsoleteField"], context.Node.GetLocation(), $"{type}.{memberName}", replacement)); - break; - } + context.ReportDiagnostic(Diagnostic.Create(this.Rules["AvoidObsoleteField"], context.Node.GetLocation(), $"{type}.{memberName}", replacement)); + break; } } - catch (Exception ex) - { - throw new InvalidOperationException($"Failed processing expression: '{context.Node}'. Exception details: {ex.ToString().Replace('\r', ' ').Replace('\n', ' ')}"); - } + } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed processing expression: '{context.Node}'. Exception details: {ex.ToString().Replace('\r', ' ').Replace('\n', ' ')}"); } } } diff --git a/src/SMAPI.ModBuildConfig.Analyzer/SMAPI.ModBuildConfig.Analyzer.csproj b/src/SMAPI.ModBuildConfig.Analyzer/SMAPI.ModBuildConfig.Analyzer.csproj index 7ac3277ec..f5a62a511 100644 --- a/src/SMAPI.ModBuildConfig.Analyzer/SMAPI.ModBuildConfig.Analyzer.csproj +++ b/src/SMAPI.ModBuildConfig.Analyzer/SMAPI.ModBuildConfig.Analyzer.csproj @@ -6,10 +6,12 @@ false bin latest + + true - + diff --git a/src/SMAPI.ModBuildConfig/DeployModTask.cs b/src/SMAPI.ModBuildConfig/DeployModTask.cs index 3508a6db8..b68ff26c2 100644 --- a/src/SMAPI.ModBuildConfig/DeployModTask.cs +++ b/src/SMAPI.ModBuildConfig/DeployModTask.cs @@ -14,298 +14,344 @@ using StardewModdingAPI.Toolkit.Serialization.Models; using StardewModdingAPI.Toolkit.Utilities; -namespace StardewModdingAPI.ModBuildConfig +namespace StardewModdingAPI.ModBuildConfig; + +/// A build task which deploys the mod files and prepares a release zip. +public class DeployModTask : Task { - /// A build task which deploys the mod files and prepares a release zip. - public class DeployModTask : Task - { - /********* - ** Accessors - *********/ - /// The name (without extension or path) of the current mod's DLL. - [Required] - public string ModDllName { get; set; } + /********* + ** Accessors + *********/ + /// The name (without extension or path) of the current mod's DLL. + [Required] + public string ModDllName { get; set; } + + /// The name of the mod folder. + [Required] + public string ModFolderName { get; set; } - /// The name of the mod folder. - [Required] - public string ModFolderName { get; set; } + /// The absolute or relative path to the folder which should contain the generated zip file. + [Required] + public string ModZipPath { get; set; } - /// The absolute or relative path to the folder which should contain the generated zip file. - [Required] - public string ModZipPath { get; set; } + /// The folder containing the project files. + [Required] + public string ProjectDir { get; set; } - /// The folder containing the project files. - [Required] - public string ProjectDir { get; set; } + /// The folder containing the build output. + [Required] + public string TargetDir { get; set; } - /// The folder containing the build output. - [Required] - public string TargetDir { get; set; } + /// The folder containing the game's mod folders. + [Required] + public string GameModsDir { get; set; } - /// The folder containing the game's mod folders. - [Required] - public string GameModsDir { get; set; } + /// Whether to enable copying the mod files into the game's Mods folder. + [Required] + public bool EnableModDeploy { get; set; } - /// Whether to enable copying the mod files into the game's Mods folder. - [Required] - public bool EnableModDeploy { get; set; } + /// Whether to enable the release zip. + [Required] + public bool EnableModZip { get; set; } - /// Whether to enable the release zip. - [Required] - public bool EnableModZip { get; set; } + /// A comma-separated list of regex patterns matching files to ignore when deploying or zipping the mod. + public string IgnoreModFilePatterns { get; set; } - /// A comma-separated list of regex patterns matching files to ignore when deploying or zipping the mod. - public string IgnoreModFilePatterns { get; set; } + /// A comma-separated list of relative file paths to ignore when deploying or zipping the mod. + public string IgnoreModFilePaths { get; set; } - /// A comma-separated list of relative file paths to ignore when deploying or zipping the mod. - public string IgnoreModFilePaths { get; set; } + /// A comma-separated list of values which indicate which extra DLLs to bundle. + public string BundleExtraAssemblies { get; set; } - /// A comma-separated list of values which indicate which extra DLLs to bundle. - public string BundleExtraAssemblies { get; set; } + /// A list of content pack folders to bundle with the mod. + public ITaskItem[] ContentPacks { get; set; } - /********* - ** Public methods - *********/ - /// When overridden in a derived class, executes the task. - /// true if the task successfully executed; otherwise, false. - public override bool Execute() + /********* + ** Public methods + *********/ + /// When overridden in a derived class, executes the task. + /// true if the task successfully executed; otherwise, false. + public override bool Execute() + { + // log build settings { - // log build settings - { - var properties = this - .GetPropertiesToLog() - .Select(p => $"{p.Key}: {p.Value}"); - this.Log.LogMessage(MessageImportance.High, $"[mod build package] Handling build with options {string.Join(", ", properties)}"); - } + var properties = this + .GetPropertiesToLog() + .Select(p => $"{p.Key}: {p.Value}"); + this.Log.LogMessage(MessageImportance.High, $"[mod build package] Handling build with options {string.Join(", ", properties)}"); + } - // skip if nothing to do - // (This must be checked before the manifest validation, to allow cases like unit test projects.) - if (!this.EnableModDeploy && !this.EnableModZip) - return true; + // skip if nothing to do + // (This must be checked before the manifest validation, to allow cases like unit test projects.) + if (!this.EnableModDeploy && !this.EnableModZip) + return true; - // validate the manifest file - IManifest manifest; + // validate the manifest file + IManifest manifest; + { + try { - try - { - string manifestPath = Path.Combine(this.ProjectDir, "manifest.json"); - if (!new JsonHelper().ReadJsonFileIfExists(manifestPath, out Manifest rawManifest)) - { - this.Log.LogError("[mod build package] The mod's manifest.json file doesn't exist."); - return false; - } - manifest = rawManifest; - } - catch (JsonReaderException ex) - { - // log the inner exception, otherwise the message will be generic - Exception exToShow = ex.InnerException ?? ex; - this.Log.LogError($"[mod build package] The mod's manifest.json file isn't valid JSON: {exToShow.Message}"); - return false; - } - - // validate manifest fields - if (!ManifestValidator.TryValidateFields(manifest, out string error)) + string manifestPath = Path.Combine(this.ProjectDir, "manifest.json"); + if (!new JsonHelper().ReadJsonFileIfExists(manifestPath, out Manifest rawManifest)) { - this.Log.LogError($"[mod build package] The mod's manifest.json file is invalid: {error}"); + this.Log.LogError("[mod build package] The mod's manifest.json file doesn't exist."); return false; } + manifest = rawManifest; + } + catch (JsonReaderException ex) + { + // log the inner exception, otherwise the message will be generic + Exception exToShow = ex.InnerException ?? ex; + this.Log.LogError($"[mod build package] The mod's manifest.json file isn't valid JSON: {exToShow.Message}"); + return false; } - // deploy files - try + // validate manifest fields + if (!ManifestValidator.TryValidateFields(manifest, out string error)) { - // parse extra DLLs to bundle - ExtraAssemblyTypes bundleAssemblyTypes = this.GetExtraAssembliesToBundleOption(); + this.Log.LogError($"[mod build package] The mod's manifest.json file is invalid: {error}"); + return false; + } + } - // parse ignore patterns - string[] ignoreFilePaths = this.GetCustomIgnoreFilePaths().ToArray(); - Regex[] ignoreFilePatterns = this.GetCustomIgnorePatterns().ToArray(); + // deploy files + try + { + // parse extra DLLs to bundle + ExtraAssemblyTypes bundleAssemblyTypes = this.GetExtraAssembliesToBundleOption(); - // get mod info - ModFileManager package = new(this.ProjectDir, this.TargetDir, ignoreFilePaths, ignoreFilePatterns, bundleAssemblyTypes, this.ModDllName, validateRequiredModFiles: this.EnableModDeploy || this.EnableModZip); + // parse ignore patterns + string[] ignoreFilePaths = this.GetCustomIgnoreFilePaths(this.IgnoreModFilePatterns).ToArray(); + Regex[] ignoreFilePatterns = this.GetCustomIgnorePatterns(this.IgnoreModFilePaths).ToArray(); - // deploy mod files - if (this.EnableModDeploy) - { - string outputPath = Path.Combine(this.GameModsDir, this.EscapeInvalidFilenameCharacters(this.ModFolderName)); - this.Log.LogMessage(MessageImportance.High, $"[mod build package] Copying the mod files to {outputPath}..."); - this.CreateModFolder(package.GetFiles(), outputPath); - } + var modPackages = new Dictionary + { + [this.ModFolderName] = new MainModFileManager(this.ProjectDir, this.TargetDir, ignoreFilePaths, ignoreFilePatterns, bundleAssemblyTypes, this.ModDllName, validateRequiredModFiles: this.EnableModDeploy || this.EnableModZip) + }; - // create release zip - if (this.EnableModZip) + if (this.ContentPacks != null) + { + foreach (ITaskItem item in this.ContentPacks) { - string zipName = this.EscapeInvalidFilenameCharacters($"{this.ModFolderName} {manifest.Version}.zip"); - string zipPath = Path.Combine(this.ModZipPath, zipName); - - this.Log.LogMessage(MessageImportance.High, $"[mod build package] Generating the release zip at {zipPath}..."); - this.CreateReleaseZip(package.GetFiles(), this.ModFolderName, zipPath); + // get content folder path + string contentPath = item.ItemSpec; + if (string.IsNullOrWhiteSpace(contentPath)) + throw new UserErrorException("Content pack doesn't have the required 'Include' attribute."); + + // get folder name + string folderName = item.GetMetadata("FolderName"); + if (string.IsNullOrWhiteSpace(folderName)) + folderName = Path.GetFileName(contentPath); + + // get version + string version = item.GetMetadata("Version"); + + // get options + ignoreFilePaths = this.GetCustomIgnoreFilePaths(item.GetMetadata("IgnoreModFilePatterns")).ToArray(); + ignoreFilePatterns = this.GetCustomIgnorePatterns(this.IgnoreModFilePaths).ToArray(); + string rawValidateManifest = item.GetMetadata("ValidateManifest"); + bool validateManifest = string.IsNullOrEmpty(rawValidateManifest) || bool.Parse(rawValidateManifest); + + // apply + this.Log.LogMessage(MessageImportance.High, $"[mod build package] Bundling content pack: {folderName} v{version} at {contentPath}."); + modPackages.Add( + folderName, + new ContentPackFileManager(this.ProjectDir, contentPath, version, ignoreFilePaths, ignoreFilePatterns, validateManifest) + ); } - - return true; } - catch (UserErrorException ex) + // deploy mod files + if (this.EnableModDeploy) { - this.Log.LogErrorFromException(ex); - return false; + string outputPath = Path.Combine(this.GameModsDir, this.EscapeInvalidFilenameCharacters(this.ModFolderName)); + this.Log.LogMessage(MessageImportance.High, $"[mod build package] Copying the mod files to {outputPath}..."); + this.CreateModFolder(modPackages, outputPath); } - catch (Exception ex) + + // create release zip + if (this.EnableModZip) { - this.Log.LogError($"[mod build package] Failed trying to deploy the mod.\n{ex}"); - return false; + string zipName = this.EscapeInvalidFilenameCharacters($"{this.ModFolderName} {manifest.Version}.zip"); + string zipPath = Path.Combine(this.ModZipPath, zipName); + + this.Log.LogMessage(MessageImportance.High, $"[mod build package] Generating the release zip at {zipPath}..."); + this.CreateReleaseZip(modPackages, zipPath); } + + return true; + } + catch (UserErrorException ex) + { + this.Log.LogErrorFromException(ex); + return false; + } + catch (Exception ex) + { + this.Log.LogError($"[mod build package] Failed trying to deploy the mod.\n{ex}"); + return false; } + } - /********* - ** Private methods - *********/ - /// Get the properties to write to the log. - private IEnumerable> GetPropertiesToLog() + /********* + ** Private methods + *********/ + /// Get the properties to write to the log. + private IEnumerable> GetPropertiesToLog() + { + var properties = this.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly); + foreach (PropertyInfo property in properties.OrderBy(p => p.Name)) { - var properties = this.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly); - foreach (PropertyInfo property in properties.OrderBy(p => p.Name)) - { - if (property.Name == nameof(this.IgnoreModFilePatterns) && string.IsNullOrWhiteSpace(this.IgnoreModFilePatterns)) - continue; + if (property.Name == nameof(this.IgnoreModFilePatterns) && string.IsNullOrWhiteSpace(this.IgnoreModFilePatterns)) + continue; - string name = property.Name; + if (property.Name == nameof(this.ContentPacks)) + continue; - string value = property.GetValue(this)?.ToString(); - if (value == null) - value = "null"; - else if (property.PropertyType == typeof(bool)) - value = value.ToLower(); - else - value = $"'{value}'"; + string name = property.Name; - yield return new KeyValuePair(name, value); - } + string value = property.GetValue(this)?.ToString(); + if (value == null) + value = "null"; + else if (property.PropertyType == typeof(bool)) + value = value.ToLower(); + else + value = $"'{value}'"; + + yield return new KeyValuePair(name, value); } + } - /// Parse the extra assembly types which should be bundled with the mod. - private ExtraAssemblyTypes GetExtraAssembliesToBundleOption() - { - ExtraAssemblyTypes flags = ExtraAssemblyTypes.None; + /// Parse the extra assembly types which should be bundled with the mod. + private ExtraAssemblyTypes GetExtraAssembliesToBundleOption() + { + ExtraAssemblyTypes flags = ExtraAssemblyTypes.None; - if (!string.IsNullOrWhiteSpace(this.BundleExtraAssemblies)) + if (!string.IsNullOrWhiteSpace(this.BundleExtraAssemblies)) + { + foreach (string raw in this.BundleExtraAssemblies.Split(',')) { - foreach (string raw in this.BundleExtraAssemblies.Split(',')) + if (!Enum.TryParse(raw, out ExtraAssemblyTypes type)) { - if (!Enum.TryParse(raw, out ExtraAssemblyTypes type)) - { - this.Log.LogWarning($"[mod build package] Ignored invalid <{nameof(this.BundleExtraAssemblies)}> value '{raw}', expected one of '{string.Join("', '", Enum.GetNames(typeof(ExtraAssemblyTypes)))}'."); - continue; - } - - flags |= type; + this.Log.LogWarning($"[mod build package] Ignored invalid <{nameof(this.BundleExtraAssemblies)}> value '{raw}', expected one of '{string.Join("', '", Enum.GetNames(typeof(ExtraAssemblyTypes)))}'."); + continue; } - } - return flags; + flags |= type; + } } - /// Get the custom ignore patterns provided by the user. - private IEnumerable GetCustomIgnorePatterns() - { - if (string.IsNullOrWhiteSpace(this.IgnoreModFilePatterns)) - yield break; + return flags; + } - foreach (string raw in this.IgnoreModFilePatterns.Split(',')) - { - Regex regex; - try - { - regex = new Regex(raw.Trim(), RegexOptions.IgnoreCase); - } - catch (Exception ex) - { - this.Log.LogWarning($"[mod build package] Ignored invalid <{nameof(this.IgnoreModFilePatterns)}> pattern {raw}:\n{ex}"); - continue; - } + /// Get the custom ignore patterns provided by the user. + /// The comma-separated regex patterns. + private IEnumerable GetCustomIgnorePatterns(string patterns) + { + if (string.IsNullOrWhiteSpace(patterns)) + yield break; - yield return regex; + foreach (string raw in patterns.Split(',')) + { + Regex regex; + try + { + regex = new Regex(raw.Trim(), RegexOptions.IgnoreCase); + } + catch (Exception ex) + { + this.Log.LogWarning($"[mod build package] Ignored invalid <{nameof(patterns)}> pattern {raw}:\n{ex}"); + continue; } + + yield return regex; } + } - /// Get the custom relative file paths provided by the user to ignore. - private IEnumerable GetCustomIgnoreFilePaths() - { - if (string.IsNullOrWhiteSpace(this.IgnoreModFilePaths)) - yield break; + /// Get the custom relative file paths provided by the user to ignore. + /// The comma-separated file paths. + private IEnumerable GetCustomIgnoreFilePaths(string pattern) + { + if (string.IsNullOrWhiteSpace(pattern)) + yield break; - foreach (string raw in this.IgnoreModFilePaths.Split(',')) + foreach (string raw in pattern.Split(',')) + { + string path; + try { - string path; - try - { - path = PathUtilities.NormalizePath(raw); - } - catch (Exception ex) - { - this.Log.LogWarning($"[mod build package] Ignored invalid <{nameof(this.IgnoreModFilePaths)}> path {raw}:\n{ex}"); - continue; - } - - yield return path; + path = PathUtilities.NormalizePath(raw); + } + catch (Exception ex) + { + this.Log.LogWarning($"[mod build package] Ignored invalid <{nameof(pattern)}> path {raw}:\n{ex}"); + continue; } + + yield return path; } + } - /// Copy the mod files into the game's mod folder. - /// The files to include. - /// The folder path to create with the mod files. - private void CreateModFolder(IDictionary files, string modFolderPath) + /// Copy the mod files into the game's mod folder. + /// The mods to include. + /// The folder path to create with the mod files. + private void CreateModFolder(IDictionary modPackages, string outputPath) + { + foreach (var mod in modPackages) { - foreach (var entry in files) + string relativePath = modPackages.Count == 1 + ? outputPath + : Path.Combine(outputPath, this.EscapeInvalidFilenameCharacters(mod.Key)); + + foreach (var file in mod.Value.GetFiles()) { - string fromPath = entry.Value.FullName; - string toPath = Path.Combine(modFolderPath, entry.Key); + string fromPath = file.Value.FullName; + string toPath = Path.Combine(relativePath, file.Key); Directory.CreateDirectory(Path.GetDirectoryName(toPath)!); File.Copy(fromPath, toPath, overwrite: true); } } + } - /// Create a release zip in the recommended format for uploading to mod sites. - /// The files to include. - /// The name of the mod. - /// The absolute path to the zip file to create. - private void CreateReleaseZip(IDictionary files, string modName, string zipPath) - { - // get folder name within zip - string folderName = this.EscapeInvalidFilenameCharacters(modName); - - // create zip file - Directory.CreateDirectory(Path.GetDirectoryName(zipPath)!); - using Stream zipStream = new FileStream(zipPath, FileMode.Create, FileAccess.Write); - using ZipArchive archive = new(zipStream, ZipArchiveMode.Create); + /// Create a release zip in the recommended format for uploading to mod sites. + /// The mods to include. + /// The absolute path to the zip file to create. + private void CreateReleaseZip(IDictionary modPackages, string zipPath) + { + // create zip file + Directory.CreateDirectory(Path.GetDirectoryName(zipPath)!); + using Stream zipStream = new FileStream(zipPath, FileMode.Create, FileAccess.Write); + using ZipArchive archive = new(zipStream, ZipArchiveMode.Create); - foreach (var fileEntry in files) + foreach (var mod in modPackages) + { + string modFolder = this.EscapeInvalidFilenameCharacters(mod.Key); + foreach (var file in mod.Value.GetFiles()) { - string relativePath = fileEntry.Key; - FileInfo file = fileEntry.Value; + string relativePath = file.Key; + FileInfo fileInfo = file.Value; - // get file info - string filePath = file.FullName; - string entryName = folderName + '/' + relativePath.Replace(Path.DirectorySeparatorChar, '/'); + string archivePath = modPackages.Count == 1 + ? $"{modFolder}/{relativePath}" + : $"{this.ModFolderName}/{modFolder}/{relativePath}"; - // add to zip - using Stream fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read); - using Stream fileStreamInZip = archive.CreateEntry(entryName).Open(); + using Stream fileStream = new FileStream(fileInfo.FullName, FileMode.Open, FileAccess.Read); + using Stream fileStreamInZip = archive.CreateEntry(archivePath).Open(); fileStream.CopyTo(fileStreamInZip); } } + } - /// Get a copy of a filename with all invalid filename characters substituted. - /// The filename. - private string EscapeInvalidFilenameCharacters(string name) - { - foreach (char invalidChar in Path.GetInvalidFileNameChars()) - name = name.Replace(invalidChar, '.'); - return name; - } + /// Get a copy of a filename with all invalid filename characters substituted. + /// The filename. + private string EscapeInvalidFilenameCharacters(string name) + { + foreach (char invalidChar in Path.GetInvalidFileNameChars()) + name = name.Replace(invalidChar, '.'); + return name; } } diff --git a/src/SMAPI.ModBuildConfig/Framework/ContentPackFileManager.cs b/src/SMAPI.ModBuildConfig/Framework/ContentPackFileManager.cs new file mode 100644 index 000000000..bb2c4f4b3 --- /dev/null +++ b/src/SMAPI.ModBuildConfig/Framework/ContentPackFileManager.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using Newtonsoft.Json; +using StardewModdingAPI.Toolkit; +using StardewModdingAPI.Toolkit.Framework; +using StardewModdingAPI.Toolkit.Serialization; +using StardewModdingAPI.Toolkit.Serialization.Models; +using StardewModdingAPI.Toolkit.Utilities; + +namespace StardewModdingAPI.ModBuildConfig.Framework; + +/// Manages the files that are part of a bundled content pack. +internal class ContentPackFileManager : IModFileManager +{ + /********* + ** Fields + *********/ + /// The name of the manifest file. + private readonly string ManifestFileName = "manifest.json"; + + /// The files that are part of the package. + private readonly Dictionary Files = new(StringComparer.OrdinalIgnoreCase); + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The folder containing the project files. + /// The absolute or relative path to the content pack folder. + /// The mod version. + /// The custom relative file paths provided by the user to ignore. + /// Custom regex patterns matching files to ignore when deploying or zipping the mod. + /// Whether to validate that the content pack's manifest is valid. + /// The mod package isn't valid. + public ContentPackFileManager(string projectDir, string contentPackDir, string version, string[] ignoreFilePaths, Regex[] ignoreFilePatterns, bool validateManifest) + { + // get folders + DirectoryInfo projectDirInfo = new DirectoryInfo(Path.Combine(projectDir, contentPackDir)); + if (!projectDirInfo.Exists) + throw GetError($"that folder doesn't exist at {projectDirInfo.FullName}"); + + // collect files + foreach (FileInfo entry in projectDirInfo.GetFiles("*", SearchOption.AllDirectories)) + { + string relativePath = PathUtilities.GetRelativePath(projectDirInfo.FullName, entry.FullName); + FileInfo file = entry; + + if (!this.ShouldIgnore(file, relativePath, ignoreFilePaths, ignoreFilePatterns)) + this.Files[relativePath] = file; + } + + // validate manifest + if (validateManifest) + { + // get manifest file + if (!this.Files.TryGetValue(this.ManifestFileName, out FileInfo manifestFile)) + throw GetError($"it has no {this.ManifestFileName} file"); + + // parse file + Manifest manifest; + try + { + new JsonHelper().ReadJsonFileIfExists(manifestFile.FullName, out Manifest rawManifest); + manifest = rawManifest; + } + catch (JsonReaderException ex) + { + throw GetError($"its {this.ManifestFileName} file isn't valid JSON: {ex.InnerException?.Message ?? ex.Message}"); + } + + // validate manifest fields + if (!ManifestValidator.TryValidateFields(manifest, out string error)) + throw GetError($"its {this.ManifestFileName} file is invalid: {error}"); + + // validate version + if (version == null) + throw GetError("no Version value was provided"); + if (!SemanticVersion.TryParse(version, out ISemanticVersion requiredVersion)) + throw GetError($"the provided Version value '{version}' isn't a valid semantic version"); + if (manifest.Version.CompareTo(requiredVersion) != 0) + throw GetError($"its {this.ManifestFileName} has version '{manifest.Version}' instead of the required '{requiredVersion}'"); + } + + UserErrorException GetError(string reasonPhrase) + { + return new UserErrorException($"The content pack at '{contentPackDir}' can't be loaded because {reasonPhrase}."); + } + } + + /// + public IDictionary GetFiles() + { + return new Dictionary(this.Files, StringComparer.OrdinalIgnoreCase); + } + + /// Get whether a content file should be ignored. + /// The file to check. + /// The file's relative path in the package. + /// The custom relative file paths provided by the user to ignore. + /// Custom regex patterns matching files to ignore when deploying or zipping the mod. + private bool ShouldIgnore(FileInfo file, string relativePath, string[] ignoreFilePaths, Regex[] ignoreFilePatterns) + { + // apply custom patterns + if (ignoreFilePaths.Any(p => p == relativePath) || ignoreFilePatterns.Any(p => p.IsMatch(relativePath))) + return true; + + // ignore special files + return + // release zips + this.EqualsInvariant(file.Extension, ".zip") + + // OS metadata files + || this.EqualsInvariant(file.Name, ".DS_Store") + || this.EqualsInvariant(file.Name, "Thumbs.db"); + } + + // Get whether a string is equal to another case-insensitively. + /// The string value. + /// The string to compare with. + private bool EqualsInvariant(string str, string other) + { + if (str == null) + return other == null; + return str.Equals(other, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/SMAPI.ModBuildConfig/Framework/ExtraAssemblyType.cs b/src/SMAPI.ModBuildConfig/Framework/ExtraAssemblyType.cs index 571bf7c7c..d7522ee99 100644 --- a/src/SMAPI.ModBuildConfig/Framework/ExtraAssemblyType.cs +++ b/src/SMAPI.ModBuildConfig/Framework/ExtraAssemblyType.cs @@ -1,21 +1,20 @@ using System; -namespace StardewModdingAPI.ModBuildConfig.Framework +namespace StardewModdingAPI.ModBuildConfig.Framework; + +/// An extra assembly type for the field. +[Flags] +internal enum ExtraAssemblyTypes { - /// An extra assembly type for the field. - [Flags] - internal enum ExtraAssemblyTypes - { - /// Don't include extra assemblies. - None = 0, + /// Don't include extra assemblies. + None = 0, - /// Assembly files which are part of MonoGame, SMAPI, or Stardew Valley. - Game = 1, + /// Assembly files which are part of MonoGame, SMAPI, or Stardew Valley. + Game = 1, - /// Assembly files whose names start with Microsoft.* or System.*. - System = 2, + /// Assembly files whose names start with Microsoft.* or System.*. + System = 2, - /// Assembly files which don't match any other category. - ThirdParty = 4 - } + /// Assembly files which don't match any other category. + ThirdParty = 4 } diff --git a/src/SMAPI.ModBuildConfig/Framework/IModFileManager.cs b/src/SMAPI.ModBuildConfig/Framework/IModFileManager.cs new file mode 100644 index 000000000..1ce6cdc51 --- /dev/null +++ b/src/SMAPI.ModBuildConfig/Framework/IModFileManager.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using System.IO; + +namespace StardewModdingAPI.ModBuildConfig.Framework; + +/// Manages the files that are part of a mod in the release package. +public interface IModFileManager +{ + /// Get the files in the mod package. + public IDictionary GetFiles(); +} diff --git a/src/SMAPI.ModBuildConfig/Framework/MainModFileManager.cs b/src/SMAPI.ModBuildConfig/Framework/MainModFileManager.cs new file mode 100644 index 000000000..5bd150858 --- /dev/null +++ b/src/SMAPI.ModBuildConfig/Framework/MainModFileManager.cs @@ -0,0 +1,271 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using StardewModdingAPI.Toolkit.Utilities; + +namespace StardewModdingAPI.ModBuildConfig.Framework; + +/// Manages the files that are part of the main C# mod. +internal class MainModFileManager : IModFileManager +{ + /********* + ** Fields + *********/ + /// The name of the manifest file. + private readonly string ManifestFileName = "manifest.json"; + + /// The files that are part of the package. + private readonly Dictionary Files = new(StringComparer.OrdinalIgnoreCase); + + /// The file extensions used by assembly files. + private readonly ISet AssemblyFileExtensions = new HashSet(StringComparer.OrdinalIgnoreCase) + { + ".dll", + ".exe", + ".pdb", + ".xml" + }; + + /// The DLLs which match the type. + private readonly ISet GameDllNames = new HashSet + { + // SMAPI + "0Harmony", + "Mono.Cecil", + "Mono.Cecil.Mdb", + "Mono.Cecil.Pdb", + "MonoMod.Common", + "Newtonsoft.Json", + "StardewModdingAPI", + "SMAPI.Toolkit", + "SMAPI.Toolkit.CoreInterfaces", + "TMXTile", + + // game + framework + "BmFont", + "FAudio-CS", + "GalaxyCSharp", + "GalaxyCSharpGlue", + "Lidgren.Network", + "MonoGame.Framework", + "SkiaSharp", + "Stardew Valley", + "StardewValley.GameData", + "Steamworks.NET", + "TextCopy", + "xTile" + }; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The folder containing the project files. + /// The folder containing the build output. + /// The custom relative file paths provided by the user to ignore. + /// Custom regex patterns matching files to ignore when deploying or zipping the mod. + /// The extra assembly types which should be bundled with the mod. + /// The name (without extension or path) for the current mod's DLL. + /// Whether to validate that required mod files like the manifest are present. + /// The mod package isn't valid. + public MainModFileManager(string projectDir, string targetDir, string[] ignoreFilePaths, Regex[] ignoreFilePatterns, ExtraAssemblyTypes bundleAssemblyTypes, string modDllName, bool validateRequiredModFiles) + { + // validate paths + if (!Directory.Exists(projectDir)) + throw new UserErrorException("Could not create mod package because the project folder wasn't found."); + if (!Directory.Exists(targetDir)) + throw new UserErrorException("Could not create mod package because no build output was found."); + + // collect files + foreach (Tuple entry in this.GetPossibleFiles(projectDir, targetDir)) + { + string relativePath = entry.Item1; + FileInfo file = entry.Item2; + + if (!this.ShouldIgnore(file, relativePath, ignoreFilePaths, ignoreFilePatterns, bundleAssemblyTypes, modDllName)) + this.Files[relativePath] = file; + } + + // check for required files + if (validateRequiredModFiles) + { + // manifest + if (!this.Files.ContainsKey(this.ManifestFileName)) + throw new UserErrorException($"Could not create mod package because no {this.ManifestFileName} was found in the project or build output."); + + // DLL + // ReSharper disable once SimplifyLinqExpression + if (!this.Files.Any(p => !p.Key.EndsWith(".dll"))) + throw new UserErrorException("Could not create mod package because no .dll file was found in the project or build output."); + } + } + + /// + public IDictionary GetFiles() + { + return new Dictionary(this.Files, StringComparer.OrdinalIgnoreCase); + } + + + /********* + ** Private methods + *********/ + /// Get all files to include in the mod folder, not accounting for ignore patterns. + /// The folder containing the project files. + /// The folder containing the build output. + /// Returns tuples containing the relative path within the mod folder, and the file to copy to it. + private IEnumerable> GetPossibleFiles(string projectDir, string targetDir) + { + // project manifest + bool hasProjectManifest = false; + { + FileInfo manifest = new(Path.Combine(projectDir, this.ManifestFileName)); + if (manifest.Exists) + { + yield return Tuple.Create(this.ManifestFileName, manifest); + hasProjectManifest = true; + } + } + + // project i18n files + bool hasProjectTranslations = false; + DirectoryInfo translationsFolder = new(Path.Combine(projectDir, "i18n")); + if (translationsFolder.Exists) + { + foreach (FileInfo file in translationsFolder.EnumerateFiles("*", SearchOption.AllDirectories)) + { + string relativePath = PathUtilities.GetRelativePath(projectDir, file.FullName); + yield return Tuple.Create(relativePath, file); + } + hasProjectTranslations = true; + } + + // project assets folder + bool hasAssetsFolder = false; + DirectoryInfo assetsFolder = new(Path.Combine(projectDir, "assets")); + if (assetsFolder.Exists) + { + foreach (FileInfo file in assetsFolder.EnumerateFiles("*", SearchOption.AllDirectories)) + { + string relativePath = PathUtilities.GetRelativePath(projectDir, file.FullName); + yield return Tuple.Create(relativePath, file); + } + hasAssetsFolder = true; + } + + // build output + DirectoryInfo buildFolder = new(targetDir); + foreach (FileInfo file in buildFolder.EnumerateFiles("*", SearchOption.AllDirectories)) + { + // get path info + string relativePath = PathUtilities.GetRelativePath(buildFolder.FullName, file.FullName); + string[] segments = PathUtilities.GetSegments(relativePath); + + // prefer project manifest/i18n/assets files + if (hasProjectManifest && this.EqualsInvariant(relativePath, this.ManifestFileName)) + continue; + if (hasProjectTranslations && this.EqualsInvariant(segments[0], "i18n")) + continue; + if (hasAssetsFolder && this.EqualsInvariant(segments[0], "assets")) + continue; + + // add file + yield return Tuple.Create(relativePath, file); + } + } + + /// Get whether a build output file should be ignored. + /// The file to check. + /// The file's relative path in the package. + /// The custom relative file paths provided by the user to ignore. + /// Custom regex patterns matching files to ignore when deploying or zipping the mod. + /// The extra assembly types which should be bundled with the mod. + /// The name (without extension or path) for the current mod's DLL. + private bool ShouldIgnore(FileInfo file, string relativePath, string[] ignoreFilePaths, Regex[] ignoreFilePatterns, ExtraAssemblyTypes bundleAssemblyTypes, string modDllName) + { + // apply custom patterns + if (ignoreFilePaths.Any(p => p == relativePath) || ignoreFilePatterns.Any(p => p.IsMatch(relativePath))) + return true; + + // ignore unneeded files + { + bool shouldIgnore = + // release zips + this.EqualsInvariant(file.Extension, ".zip") + + // *.deps.json (only SMAPI's top-level one is used) + || file.Name.EndsWith(".deps.json") + + // code analysis files + || file.Name.EndsWith(".CodeAnalysisLog.xml", StringComparison.OrdinalIgnoreCase) + || file.Name.EndsWith(".lastcodeanalysissucceeded", StringComparison.OrdinalIgnoreCase) + + // translation class builder (not used at runtime) + || ( + file.Name.StartsWith("Pathoschild.Stardew.ModTranslationClassBuilder") + && this.AssemblyFileExtensions.Contains(file.Extension) + ) + + // OS metadata files + || this.EqualsInvariant(file.Name, ".DS_Store") + || this.EqualsInvariant(file.Name, "Thumbs.db"); + if (shouldIgnore) + return true; + } + + // ignore by assembly type + ExtraAssemblyTypes type = this.GetExtraAssemblyType(file, modDllName); + switch (bundleAssemblyTypes) + { + // Only explicitly-referenced assemblies are in the build output. These should be added to the zip, + // since it's possible the game won't load them (except game assemblies which will always be loaded + // separately). If they're already loaded, SMAPI will just ignore them. + case ExtraAssemblyTypes.None: + if (type is ExtraAssemblyTypes.Game) + return true; + break; + + // All assemblies are in the build output (due to how .NET builds references), but only those which + // match the bundled type should be in the zip. + default: + if (type != ExtraAssemblyTypes.None && !bundleAssemblyTypes.HasFlag(type)) + return true; + break; + } + + return false; + } + + /// Get the extra assembly type for a file, assuming that the user specified one or more extra types to bundle. + /// The file to check. + /// The name (without extension or path) for the current mod's DLL. + private ExtraAssemblyTypes GetExtraAssemblyType(FileInfo file, string modDllName) + { + string baseName = Path.GetFileNameWithoutExtension(file.Name); + string extension = file.Extension; + + if (baseName == modDllName || !this.AssemblyFileExtensions.Contains(extension)) + return ExtraAssemblyTypes.None; + + if (this.GameDllNames.Contains(baseName)) + return ExtraAssemblyTypes.Game; + + if (baseName.StartsWith("System.", StringComparison.OrdinalIgnoreCase) || baseName.StartsWith("Microsoft.", StringComparison.OrdinalIgnoreCase)) + return ExtraAssemblyTypes.System; + + return ExtraAssemblyTypes.ThirdParty; + } + + /// Get whether a string is equal to another case-insensitively. + /// The string value. + /// The string to compare with. + private bool EqualsInvariant(string str, string other) + { + if (str == null) + return other == null; + return str.Equals(other, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs b/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs deleted file mode 100644 index d47e492ac..000000000 --- a/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs +++ /dev/null @@ -1,271 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text.RegularExpressions; -using StardewModdingAPI.Toolkit.Utilities; - -namespace StardewModdingAPI.ModBuildConfig.Framework -{ - /// Manages the files that are part of a mod package. - internal class ModFileManager - { - /********* - ** Fields - *********/ - /// The name of the manifest file. - private readonly string ManifestFileName = "manifest.json"; - - /// The files that are part of the package. - private readonly IDictionary Files; - - /// The file extensions used by assembly files. - private readonly ISet AssemblyFileExtensions = new HashSet(StringComparer.OrdinalIgnoreCase) - { - ".dll", - ".exe", - ".pdb", - ".xml" - }; - - /// The DLLs which match the type. - private readonly ISet GameDllNames = new HashSet - { - // SMAPI - "0Harmony", - "Mono.Cecil", - "Mono.Cecil.Mdb", - "Mono.Cecil.Pdb", - "MonoMod.Common", - "Newtonsoft.Json", - "StardewModdingAPI", - "SMAPI.Toolkit", - "SMAPI.Toolkit.CoreInterfaces", - "TMXTile", - - // game + framework - "BmFont", - "FAudio-CS", - "GalaxyCSharp", - "GalaxyCSharpGlue", - "Lidgren.Network", - "MonoGame.Framework", - "SkiaSharp", - "Stardew Valley", - "StardewValley.GameData", - "Steamworks.NET", - "TextCopy", - "xTile" - }; - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The folder containing the project files. - /// The folder containing the build output. - /// The custom relative file paths provided by the user to ignore. - /// Custom regex patterns matching files to ignore when deploying or zipping the mod. - /// The extra assembly types which should be bundled with the mod. - /// The name (without extension or path) for the current mod's DLL. - /// Whether to validate that required mod files like the manifest are present. - /// The mod package isn't valid. - public ModFileManager(string projectDir, string targetDir, string[] ignoreFilePaths, Regex[] ignoreFilePatterns, ExtraAssemblyTypes bundleAssemblyTypes, string modDllName, bool validateRequiredModFiles) - { - this.Files = new Dictionary(StringComparer.OrdinalIgnoreCase); - - // validate paths - if (!Directory.Exists(projectDir)) - throw new UserErrorException("Could not create mod package because the project folder wasn't found."); - if (!Directory.Exists(targetDir)) - throw new UserErrorException("Could not create mod package because no build output was found."); - - // collect files - foreach (Tuple entry in this.GetPossibleFiles(projectDir, targetDir)) - { - string relativePath = entry.Item1; - FileInfo file = entry.Item2; - - if (!this.ShouldIgnore(file, relativePath, ignoreFilePaths, ignoreFilePatterns, bundleAssemblyTypes, modDllName)) - this.Files[relativePath] = file; - } - - // check for required files - if (validateRequiredModFiles) - { - // manifest - if (!this.Files.ContainsKey(this.ManifestFileName)) - throw new UserErrorException($"Could not create mod package because no {this.ManifestFileName} was found in the project or build output."); - - // DLL - // ReSharper disable once SimplifyLinqExpression - if (!this.Files.Any(p => !p.Key.EndsWith(".dll"))) - throw new UserErrorException("Could not create mod package because no .dll file was found in the project or build output."); - } - } - - /// Get the files in the mod package. - public IDictionary GetFiles() - { - return new Dictionary(this.Files, StringComparer.OrdinalIgnoreCase); - } - - - /********* - ** Private methods - *********/ - /// Get all files to include in the mod folder, not accounting for ignore patterns. - /// The folder containing the project files. - /// The folder containing the build output. - /// Returns tuples containing the relative path within the mod folder, and the file to copy to it. - private IEnumerable> GetPossibleFiles(string projectDir, string targetDir) - { - // project manifest - bool hasProjectManifest = false; - { - FileInfo manifest = new(Path.Combine(projectDir, this.ManifestFileName)); - if (manifest.Exists) - { - yield return Tuple.Create(this.ManifestFileName, manifest); - hasProjectManifest = true; - } - } - - // project i18n files - bool hasProjectTranslations = false; - DirectoryInfo translationsFolder = new(Path.Combine(projectDir, "i18n")); - if (translationsFolder.Exists) - { - foreach (FileInfo file in translationsFolder.EnumerateFiles()) - yield return Tuple.Create(Path.Combine("i18n", file.Name), file); - hasProjectTranslations = true; - } - - // project assets folder - bool hasAssetsFolder = false; - DirectoryInfo assetsFolder = new(Path.Combine(projectDir, "assets")); - if (assetsFolder.Exists) - { - foreach (FileInfo file in assetsFolder.EnumerateFiles("*", SearchOption.AllDirectories)) - { - string relativePath = PathUtilities.GetRelativePath(projectDir, file.FullName); - yield return Tuple.Create(relativePath, file); - } - hasAssetsFolder = true; - } - - // build output - DirectoryInfo buildFolder = new(targetDir); - foreach (FileInfo file in buildFolder.EnumerateFiles("*", SearchOption.AllDirectories)) - { - // get path info - string relativePath = PathUtilities.GetRelativePath(buildFolder.FullName, file.FullName); - string[] segments = PathUtilities.GetSegments(relativePath); - - // prefer project manifest/i18n/assets files - if (hasProjectManifest && this.EqualsInvariant(relativePath, this.ManifestFileName)) - continue; - if (hasProjectTranslations && this.EqualsInvariant(segments[0], "i18n")) - continue; - if (hasAssetsFolder && this.EqualsInvariant(segments[0], "assets")) - continue; - - // add file - yield return Tuple.Create(relativePath, file); - } - } - - /// Get whether a build output file should be ignored. - /// The file to check. - /// The file's relative path in the package. - /// The custom relative file paths provided by the user to ignore. - /// Custom regex patterns matching files to ignore when deploying or zipping the mod. - /// The extra assembly types which should be bundled with the mod. - /// The name (without extension or path) for the current mod's DLL. - private bool ShouldIgnore(FileInfo file, string relativePath, string[] ignoreFilePaths, Regex[] ignoreFilePatterns, ExtraAssemblyTypes bundleAssemblyTypes, string modDllName) - { - // apply custom patterns - if (ignoreFilePaths.Any(p => p == relativePath) || ignoreFilePatterns.Any(p => p.IsMatch(relativePath))) - return true; - - // ignore unneeded files - { - bool shouldIgnore = - // release zips - this.EqualsInvariant(file.Extension, ".zip") - - // *.deps.json (only SMAPI's top-level one is used) - || file.Name.EndsWith(".deps.json") - - // code analysis files - || file.Name.EndsWith(".CodeAnalysisLog.xml", StringComparison.OrdinalIgnoreCase) - || file.Name.EndsWith(".lastcodeanalysissucceeded", StringComparison.OrdinalIgnoreCase) - - // translation class builder (not used at runtime) - || ( - file.Name.StartsWith("Pathoschild.Stardew.ModTranslationClassBuilder") - && this.AssemblyFileExtensions.Contains(file.Extension) - ) - - // OS metadata files - || this.EqualsInvariant(file.Name, ".DS_Store") - || this.EqualsInvariant(file.Name, "Thumbs.db"); - if (shouldIgnore) - return true; - } - - // ignore by assembly type - ExtraAssemblyTypes type = this.GetExtraAssemblyType(file, modDllName); - switch (bundleAssemblyTypes) - { - // Only explicitly-referenced assemblies are in the build output. These should be added to the zip, - // since it's possible the game won't load them (except game assemblies which will always be loaded - // separately). If they're already loaded, SMAPI will just ignore them. - case ExtraAssemblyTypes.None: - if (type is ExtraAssemblyTypes.Game) - return true; - break; - - // All assemblies are in the build output (due to how .NET builds references), but only those which - // match the bundled type should be in the zip. - default: - if (type != ExtraAssemblyTypes.None && !bundleAssemblyTypes.HasFlag(type)) - return true; - break; - } - - return false; - } - - /// Get the extra assembly type for a file, assuming that the user specified one or more extra types to bundle. - /// The file to check. - /// The name (without extension or path) for the current mod's DLL. - private ExtraAssemblyTypes GetExtraAssemblyType(FileInfo file, string modDllName) - { - string baseName = Path.GetFileNameWithoutExtension(file.Name); - string extension = file.Extension; - - if (baseName == modDllName || !this.AssemblyFileExtensions.Contains(extension)) - return ExtraAssemblyTypes.None; - - if (this.GameDllNames.Contains(baseName)) - return ExtraAssemblyTypes.Game; - - if (baseName.StartsWith("System.", StringComparison.OrdinalIgnoreCase) || baseName.StartsWith("Microsoft.", StringComparison.OrdinalIgnoreCase)) - return ExtraAssemblyTypes.System; - - return ExtraAssemblyTypes.ThirdParty; - } - - /// Get whether a string is equal to another case-insensitively. - /// The string value. - /// The string to compare with. - private bool EqualsInvariant(string str, string other) - { - if (str == null) - return other == null; - return str.Equals(other, StringComparison.OrdinalIgnoreCase); - } - } -} diff --git a/src/SMAPI.ModBuildConfig/Framework/UserErrorException.cs b/src/SMAPI.ModBuildConfig/Framework/UserErrorException.cs index 64e31c290..15527ea31 100644 --- a/src/SMAPI.ModBuildConfig/Framework/UserErrorException.cs +++ b/src/SMAPI.ModBuildConfig/Framework/UserErrorException.cs @@ -1,16 +1,15 @@ using System; -namespace StardewModdingAPI.ModBuildConfig.Framework +namespace StardewModdingAPI.ModBuildConfig.Framework; + +/// A user error whose message can be displayed to the user. +internal class UserErrorException : Exception { - /// A user error whose message can be displayed to the user. - internal class UserErrorException : Exception - { - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The error message. - public UserErrorException(string message) - : base(message) { } - } + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The error message. + public UserErrorException(string message) + : base(message) { } } diff --git a/src/SMAPI.ModBuildConfig/SMAPI.ModBuildConfig.csproj b/src/SMAPI.ModBuildConfig/SMAPI.ModBuildConfig.csproj index 2e97d53f5..65b1bbc36 100644 --- a/src/SMAPI.ModBuildConfig/SMAPI.ModBuildConfig.csproj +++ b/src/SMAPI.ModBuildConfig/SMAPI.ModBuildConfig.csproj @@ -10,12 +10,15 @@ Pathoschild.Stardew.ModBuildConfig Build package for SMAPI mods - 4.1.1 + 4.3.0 Pathoschild Automates the build configuration for crossplatform Stardew Valley SMAPI mods. For SMAPI 3.13.0 or later. + mod-package.md MIT images/icon.png https://smapi.io/package/readme + git + https://github.com/Pathoschild/SMAPI.git false @@ -23,14 +26,9 @@ - - - - - + + + @@ -39,7 +37,8 @@ - + + diff --git a/src/SMAPI.ModBuildConfig/build/smapi.targets b/src/SMAPI.ModBuildConfig/build/smapi.targets index 41b5bc869..708dc3ade 100644 --- a/src/SMAPI.ModBuildConfig/build/smapi.targets +++ b/src/SMAPI.ModBuildConfig/build/smapi.targets @@ -99,6 +99,7 @@ IgnoreModFilePaths="$(IgnoreModFilePaths)" BundleExtraAssemblies="$(BundleExtraAssemblies)" + ContentPacks="@(ContentPacks)" /> diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/ArgumentParser.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/ArgumentParser.cs index e42aea212..7a9f2f8e2 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/ArgumentParser.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/ArgumentParser.cs @@ -4,149 +4,148 @@ using System.Diagnostics.CodeAnalysis; using System.Linq; -namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands; + +/// Provides methods for parsing command-line arguments. +internal class ArgumentParser : IReadOnlyList { - /// Provides methods for parsing command-line arguments. - internal class ArgumentParser : IReadOnlyList + /********* + ** Fields + *********/ + /// The command name for errors. + private readonly string CommandName; + + /// Writes messages to the console and log file. + private readonly IMonitor Monitor; + + + /********* + ** Accessors + *********/ + /// The arguments to parse. + public string[] Values { get; } + + /// Get the number of arguments. + public int Count => this.Values.Length; + + /// Get the argument at the specified index in the list. + /// The zero-based index of the element to get. + public string this[int index] => this.Values[index]; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The command name for errors. + /// The arguments to parse. + /// Writes messages to the console and log file. + public ArgumentParser(string commandName, string[] values, IMonitor monitor) { - /********* - ** Fields - *********/ - /// The command name for errors. - private readonly string CommandName; - - /// Writes messages to the console and log file. - private readonly IMonitor Monitor; - - - /********* - ** Accessors - *********/ - /// The arguments to parse. - public string[] Values { get; } - - /// Get the number of arguments. - public int Count => this.Values.Length; - - /// Get the argument at the specified index in the list. - /// The zero-based index of the element to get. - public string this[int index] => this.Values[index]; - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The command name for errors. - /// The arguments to parse. - /// Writes messages to the console and log file. - public ArgumentParser(string commandName, string[] values, IMonitor monitor) - { - this.CommandName = commandName; - this.Values = values; - this.Monitor = monitor; - } + this.CommandName = commandName; + this.Values = values; + this.Monitor = monitor; + } - /// Try to read a string argument. - /// The argument index. - /// The argument name for error messages. - /// The parsed value. - /// Whether to show an error if the argument is missing. - /// Require that the argument match one of the given values (case-insensitive). - public bool TryGet(int index, string name, [NotNullWhen(true)] out string? value, bool required = true, string[]? oneOf = null) - { - value = null; - - // validate - if (this.Values.Length < index + 1) - { - if (required) - this.LogError($"Argument {index} ({name}) is required."); - return false; - } - if (oneOf?.Any() == true && !oneOf.Contains(this.Values[index], StringComparer.OrdinalIgnoreCase)) - { - this.LogError($"Argument {index} ({name}) must be one of {string.Join(", ", oneOf)}."); - return false; - } - - // get value - value = this.Values[index]; - return true; - } + /// Try to read a string argument. + /// The argument index. + /// The argument name for error messages. + /// The parsed value. + /// Whether to show an error if the argument is missing. + /// Require that the argument match one of the given values (case-insensitive). + public bool TryGet(int index, string name, [NotNullWhen(true)] out string? value, bool required = true, string[]? oneOf = null) + { + value = null; - /// Try to read an integer argument. - /// The argument index. - /// The argument name for error messages. - /// The parsed value. - /// Whether to show an error if the argument is missing. - /// The minimum value allowed. - /// The maximum value allowed. - public bool TryGetInt(int index, string name, out int value, bool required = true, int? min = null, int? max = null) + // validate + if (this.Values.Length < index + 1) { - value = 0; - - // get argument - if (!this.TryGet(index, name, out string? raw, required)) - return false; - - // parse - if (!int.TryParse(raw, out value)) - { - this.LogIntFormatError(index, name, min, max); - return false; - } - - // validate - if ((min.HasValue && value < min) || (max.HasValue && value > max)) - { - this.LogIntFormatError(index, name, min, max); - return false; - } - - return true; + if (required) + this.LogError($"Argument {index} ({name}) is required."); + return false; } - - /// Returns an enumerator that iterates through the collection. - /// An enumerator that can be used to iterate through the collection. - public IEnumerator GetEnumerator() + if (oneOf?.Any() == true && !oneOf.Contains(this.Values[index], StringComparer.OrdinalIgnoreCase)) { - return ((IEnumerable)this.Values).GetEnumerator(); + this.LogError($"Argument {index} ({name}) must be one of {string.Join(", ", oneOf)}."); + return false; } - /// Returns an enumerator that iterates through a collection. - /// An object that can be used to iterate through the collection. - IEnumerator IEnumerable.GetEnumerator() - { - return this.GetEnumerator(); - } + // get value + value = this.Values[index]; + return true; + } + /// Try to read an integer argument. + /// The argument index. + /// The argument name for error messages. + /// The parsed value. + /// Whether to show an error if the argument is missing. + /// The minimum value allowed. + /// The maximum value allowed. + public bool TryGetInt(int index, string name, out int value, bool required = true, int? min = null, int? max = null) + { + value = 0; + + // get argument + if (!this.TryGet(index, name, out string? raw, required)) + return false; - /********* - ** Private methods - *********/ - /// Log a usage error. - /// The message describing the error. - private void LogError(string message) + // parse + if (!int.TryParse(raw, out value)) { - this.Monitor.Log($"{message} Type 'help {this.CommandName}' for usage.", LogLevel.Error); + this.LogIntFormatError(index, name, min, max); + return false; } - /// Print an error for an invalid int argument. - /// The argument index. - /// The argument name for error messages. - /// The minimum value allowed. - /// The maximum value allowed. - private void LogIntFormatError(int index, string name, int? min, int? max) + // validate + if ((min.HasValue && value < min) || (max.HasValue && value > max)) { - if (min.HasValue && max.HasValue) - this.LogError($"Argument {index} ({name}) must be an integer between {min} and {max}."); - else if (min.HasValue) - this.LogError($"Argument {index} ({name}) must be an integer and at least {min}."); - else if (max.HasValue) - this.LogError($"Argument {index} ({name}) must be an integer and at most {max}."); - else - this.LogError($"Argument {index} ({name}) must be an integer."); + this.LogIntFormatError(index, name, min, max); + return false; } + + return true; + } + + /// Returns an enumerator that iterates through the collection. + /// An enumerator that can be used to iterate through the collection. + public IEnumerator GetEnumerator() + { + return ((IEnumerable)this.Values).GetEnumerator(); + } + + /// Returns an enumerator that iterates through a collection. + /// An object that can be used to iterate through the collection. + IEnumerator IEnumerable.GetEnumerator() + { + return this.GetEnumerator(); + } + + + /********* + ** Private methods + *********/ + /// Log a usage error. + /// The message describing the error. + private void LogError(string message) + { + this.Monitor.Log($"{message} Type 'help {this.CommandName}' for usage.", LogLevel.Error); + } + + /// Print an error for an invalid int argument. + /// The argument index. + /// The argument name for error messages. + /// The minimum value allowed. + /// The maximum value allowed. + private void LogIntFormatError(int index, string name, int? min, int? max) + { + if (min.HasValue && max.HasValue) + this.LogError($"Argument {index} ({name}) must be an integer between {min} and {max}."); + else if (min.HasValue) + this.LogError($"Argument {index} ({name}) must be an integer and at least {min}."); + else if (max.HasValue) + this.LogError($"Argument {index} ({name}) must be an integer and at most {max}."); + else + this.LogError($"Argument {index} ({name}) must be an integer."); } } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/ConsoleCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/ConsoleCommand.cs index 2133817c4..9c47d4f98 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/ConsoleCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/ConsoleCommand.cs @@ -2,109 +2,108 @@ using System.Collections.Generic; using System.Linq; -namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands; + +/// The base implementation for a console command. +internal abstract class ConsoleCommand : IConsoleCommand { - /// The base implementation for a console command. - internal abstract class ConsoleCommand : IConsoleCommand + /********* + ** Accessors + *********/ + /// The command name the user must type. + public string Name { get; } + + /// The command description. + public string Description { get; } + + /// Whether the command may need to perform logic when the game updates. This value shouldn't change. + public bool MayNeedUpdate { get; } + + + /********* + ** Public methods + *********/ + /// Handle the command. + /// Writes messages to the console and log file. + /// The command name. + /// The command arguments. + public abstract void Handle(IMonitor monitor, string command, ArgumentParser args); + + /// Perform any logic needed on update tick. + /// Writes messages to the console and log file. + public virtual void OnUpdated(IMonitor monitor) { } + + /// Perform any logic when input is received. + /// Writes messages to the console and log file. + /// The button that was pressed. + public virtual void OnButtonPressed(IMonitor monitor, SButton button) { } + + + /********* + ** Protected methods + *********/ + /// Construct an instance. + /// The command name the user must type. + /// The command description. + /// Whether the command may need to perform logic when the game updates. + protected ConsoleCommand(string name, string description, bool mayNeedUpdate = false) { - /********* - ** Accessors - *********/ - /// The command name the user must type. - public string Name { get; } - - /// The command description. - public string Description { get; } - - /// Whether the command may need to perform logic when the game updates. This value shouldn't change. - public bool MayNeedUpdate { get; } - - - /********* - ** Public methods - *********/ - /// Handle the command. - /// Writes messages to the console and log file. - /// The command name. - /// The command arguments. - public abstract void Handle(IMonitor monitor, string command, ArgumentParser args); - - /// Perform any logic needed on update tick. - /// Writes messages to the console and log file. - public virtual void OnUpdated(IMonitor monitor) { } - - /// Perform any logic when input is received. - /// Writes messages to the console and log file. - /// The button that was pressed. - public virtual void OnButtonPressed(IMonitor monitor, SButton button) { } - - - /********* - ** Protected methods - *********/ - /// Construct an instance. - /// The command name the user must type. - /// The command description. - /// Whether the command may need to perform logic when the game updates. - protected ConsoleCommand(string name, string description, bool mayNeedUpdate = false) - { - this.Name = name; - this.Description = description; - this.MayNeedUpdate = mayNeedUpdate; - } - - /// Log an error indicating incorrect usage. - /// Writes messages to the console and log file. - /// A sentence explaining the problem. - protected void LogUsageError(IMonitor monitor, string error) - { - monitor.Log($"{error} Type 'help {this.Name}' for usage.", LogLevel.Error); - } + this.Name = name; + this.Description = description; + this.MayNeedUpdate = mayNeedUpdate; + } - /// Log an error indicating a value must be an integer. - /// Writes messages to the console and log file. - protected void LogArgumentNotInt(IMonitor monitor) - { - this.LogUsageError(monitor, "The value must be a whole number."); - } - - /// Get an ASCII table to show tabular data in the console. - /// The data type. - /// The data to display. - /// The table header. - /// Returns a set of fields for a data value. - protected string GetTableString(IEnumerable data, string[] header, Func getRow) - { - // get table data - int[] widths = header.Select(p => p.Length).ToArray(); - string[][] rows = data - .Select(item => - { - string[] fields = getRow(item); - if (fields.Length != widths.Length) - throw new InvalidOperationException($"Expected {widths.Length} columns, but found {fields.Length}: {string.Join(", ", fields)}"); - - for (int i = 0; i < fields.Length; i++) - widths[i] = Math.Max(widths[i], fields[i].Length); - - return fields; - }) - .ToArray(); - - // render fields - List lines = new List(rows.Length + 2) + /// Log an error indicating incorrect usage. + /// Writes messages to the console and log file. + /// A sentence explaining the problem. + protected void LogUsageError(IMonitor monitor, string error) + { + monitor.Log($"{error} Type 'help {this.Name}' for usage.", LogLevel.Error); + } + + /// Log an error indicating a value must be an integer. + /// Writes messages to the console and log file. + protected void LogArgumentNotInt(IMonitor monitor) + { + this.LogUsageError(monitor, "The value must be a whole number."); + } + + /// Get an ASCII table to show tabular data in the console. + /// The data type. + /// The data to display. + /// The table header. + /// Returns a set of fields for a data value. + protected string GetTableString(IEnumerable data, string[] header, Func getRow) + { + // get table data + int[] widths = header.Select(p => p.Length).ToArray(); + string[][] rows = data + .Select(item => { - header, - header.Select((_, i) => "".PadRight(widths[i], '-')).ToArray() - }; - lines.AddRange(rows); - - return string.Join( - Environment.NewLine, - lines.Select(line => string.Join(" | ", - line.Select((field, i) => field.PadLeft(widths[i], ' ')) - )) - ); - } + string[] fields = getRow(item); + if (fields.Length != widths.Length) + throw new InvalidOperationException($"Expected {widths.Length} columns, but found {fields.Length}: {string.Join(", ", fields)}"); + + for (int i = 0; i < fields.Length; i++) + widths[i] = Math.Max(widths[i], fields[i].Length); + + return fields; + }) + .ToArray(); + + // render fields + List lines = new List(rows.Length + 2) + { + header, + header.Select((_, i) => "".PadRight(widths[i], '-')).ToArray() + }; + lines.AddRange(rows); + + return string.Join( + Environment.NewLine, + lines.Select(line => string.Join(" | ", + line.Select((field, i) => field.PadLeft(widths[i], ' ')) + )) + ); } } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/IConsoleCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/IConsoleCommand.cs index 05dfcf321..9ce26b789 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/IConsoleCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/IConsoleCommand.cs @@ -1,32 +1,31 @@ -namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands; + +/// A console command to register. +internal interface IConsoleCommand { - /// A console command to register. - internal interface IConsoleCommand - { - /********* - ** Accessors - *********/ - /// The command name the user must type. - string Name { get; } + /********* + ** Accessors + *********/ + /// The command name the user must type. + string Name { get; } - /// The command description. - string Description { get; } + /// The command description. + string Description { get; } - /// Whether the command may need to perform logic when the game updates. This value shouldn't change. - bool MayNeedUpdate { get; } + /// Whether the command may need to perform logic when the game updates. This value shouldn't change. + bool MayNeedUpdate { get; } - /********* - ** Public methods - *********/ - /// Handle the command. - /// Writes messages to the console and log file. - /// The command name. - /// The command arguments. - void Handle(IMonitor monitor, string command, ArgumentParser args); + /********* + ** Public methods + *********/ + /// Handle the command. + /// Writes messages to the console and log file. + /// The command name. + /// The command arguments. + void Handle(IMonitor monitor, string command, ArgumentParser args); - /// Perform any logic needed on update tick. - /// Writes messages to the console and log file. - void OnUpdated(IMonitor monitor); - } + /// Perform any logic needed on update tick. + /// Writes messages to the console and log file. + void OnUpdated(IMonitor monitor); } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ApplySaveFixCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ApplySaveFixCommand.cs index 01303194f..64d2f4a25 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ApplySaveFixCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ApplySaveFixCommand.cs @@ -4,89 +4,88 @@ using StardewValley; using StardewValley.SaveMigrations; -namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Other +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Other; + +/// A command which runs one of the game's save migrations. +[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] +internal class ApplySaveFixCommand : ConsoleCommand { - /// A command which runs one of the game's save migrations. - [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] - internal class ApplySaveFixCommand : ConsoleCommand - { - /********* - ** Public methods - *********/ - /// Construct an instance. - public ApplySaveFixCommand() - : base("apply_save_fix", "Apply one of the game's save migrations to the currently loaded save. WARNING: This may corrupt or make permanent changes to your save. DO NOT USE THIS unless you're absolutely sure.\n\nUsage: apply_save_fix list\nList all valid save IDs.\n\nUsage: apply_save_fix \nApply the named save fix.") { } + /********* + ** Public methods + *********/ + /// Construct an instance. + public ApplySaveFixCommand() + : base("apply_save_fix", "Apply one of the game's save migrations to the currently loaded save. WARNING: This may corrupt or make permanent changes to your save. DO NOT USE THIS unless you're absolutely sure.\n\nUsage: apply_save_fix list\nList all valid save IDs.\n\nUsage: apply_save_fix \nApply the named save fix.") { } - /// Handle the command. - /// Writes messages to the console and log file. - /// The command name. - /// The command arguments. - public override void Handle(IMonitor monitor, string command, ArgumentParser args) + /// Handle the command. + /// Writes messages to the console and log file. + /// The command name. + /// The command arguments. + public override void Handle(IMonitor monitor, string command, ArgumentParser args) + { + // get fix ID + if (!args.TryGet(0, "fix_id", out string? rawFixId, required: false)) { - // get fix ID - if (!args.TryGet(0, "fix_id", out string? rawFixId, required: false)) - { - monitor.Log("Invalid usage. Type 'help apply_save_fix' for details.", LogLevel.Error); - return; - } - rawFixId = rawFixId.Trim(); + monitor.Log("Invalid usage. Type 'help apply_save_fix' for details.", LogLevel.Error); + return; + } + rawFixId = rawFixId.Trim(); - // list mode - if (rawFixId == "list") - { - monitor.Log("Valid save fix IDs:\n - " + string.Join("\n - ", this.GetSaveIds()), LogLevel.Info); - return; - } + // list mode + if (rawFixId == "list") + { + monitor.Log("Valid save fix IDs:\n - " + string.Join("\n - ", this.GetSaveIds()), LogLevel.Info); + return; + } - // validate fix ID - if (!Enum.TryParse(rawFixId, ignoreCase: true, out SaveFixes fixId)) - { - monitor.Log($"Invalid save ID '{rawFixId}'. Type 'help apply_save_fix' for details.", LogLevel.Error); - return; - } + // validate fix ID + if (!Enum.TryParse(rawFixId, ignoreCase: true, out SaveFixes fixId)) + { + monitor.Log($"Invalid save ID '{rawFixId}'. Type 'help apply_save_fix' for details.", LogLevel.Error); + return; + } - // apply - monitor.Log("THIS MAY CAUSE PERMANENT CHANGES TO YOUR SAVE FILE. If you're not sure, exit your game without saving to avoid issues.", LogLevel.Warn); - monitor.Log($"Trying to apply save fix ID: '{fixId}'.", LogLevel.Warn); - try - { - SaveMigrator.ApplySingleSaveFix(fixId, this.GetLoadedItems()); - monitor.Log("Save fix applied.", LogLevel.Info); - } - catch (Exception ex) - { - monitor.Log("Applying save fix failed. The save may be in an invalid state; you should exit your game now without saving to avoid issues.", LogLevel.Error); - monitor.Log($"Technical details: {ex}", LogLevel.Debug); - } + // apply + monitor.Log("THIS MAY CAUSE PERMANENT CHANGES TO YOUR SAVE FILE. If you're not sure, exit your game without saving to avoid issues.", LogLevel.Warn); + monitor.Log($"Trying to apply save fix ID: '{fixId}'.", LogLevel.Warn); + try + { + SaveMigrator.ApplySingleSaveFix(fixId, this.GetLoadedItems()); + monitor.Log("Save fix applied.", LogLevel.Info); + } + catch (Exception ex) + { + monitor.Log("Applying save fix failed. The save may be in an invalid state; you should exit your game now without saving to avoid issues.", LogLevel.Error); + monitor.Log($"Technical details: {ex}", LogLevel.Debug); } + } - /********* - ** Private methods - *********/ - /// Get all item instances in the world. - private List GetLoadedItems() + /********* + ** Private methods + *********/ + /// Get all item instances in the world. + private List GetLoadedItems() + { + List loadedItems = new(); + Utility.ForEachItem(item => { - List loadedItems = new(); - Utility.ForEachItem(item => - { - loadedItems.Add(item); - return true; - }); - return loadedItems; - } + loadedItems.Add(item); + return true; + }); + return loadedItems; + } - /// Get the valid save fix IDs. - private IEnumerable GetSaveIds() + /// Get the valid save fix IDs. + private IEnumerable GetSaveIds() + { + foreach (SaveFixes id in Enum.GetValues(typeof(SaveFixes))) { - foreach (SaveFixes id in Enum.GetValues(typeof(SaveFixes))) - { - if (id == SaveFixes.MAX) - continue; + if (id == SaveFixes.MAX) + continue; - yield return id.ToString(); - } + yield return id.ToString(); } } } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/DebugCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/DebugCommand.cs index 2099b0284..d0507198b 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/DebugCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/DebugCommand.cs @@ -1,32 +1,31 @@ using System.Diagnostics.CodeAnalysis; using StardewValley; -namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Other +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Other; + +/// A command which sends a debug command to the game. +[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] +internal class DebugCommand : ConsoleCommand { - /// A command which sends a debug command to the game. - [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] - internal class DebugCommand : ConsoleCommand - { - /********* - ** Public methods - *********/ - /// Construct an instance. - public DebugCommand() - : base("debug", "Run one of the game's debug commands; for example, 'debug warp FarmHouse 1 1' warps the player to the farmhouse.") { } + /********* + ** Public methods + *********/ + /// Construct an instance. + public DebugCommand() + : base("debug", "Run one of the game's debug commands; for example, 'debug warp FarmHouse 1 1' warps the player to the farmhouse.") { } - /// Handle the command. - /// Writes messages to the console and log file. - /// The command name. - /// The command arguments. - public override void Handle(IMonitor monitor, string command, ArgumentParser args) + /// Handle the command. + /// Writes messages to the console and log file. + /// The command name. + /// The command arguments. + public override void Handle(IMonitor monitor, string command, ArgumentParser args) + { + string oldOutput = Game1.debugOutput; + if (DebugCommands.TryHandle(args.Values)) // if it returns false, the game will log an error itself { - string oldOutput = Game1.debugOutput; - if (DebugCommands.TryHandle(args.Values)) // if it returns false, the game will log an error itself - { - monitor.Log(Game1.debugOutput != oldOutput - ? $"> {Game1.debugOutput}" - : "Sent debug command to the game, but there was no output.", LogLevel.Info); - } + monitor.Log(Game1.debugOutput != oldOutput + ? $"> {Game1.debugOutput}" + : "Sent debug command to the game, but there was no output.", LogLevel.Info); } } } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/LogContextCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/LogContextCommand.cs index 270faa9d1..7186fe75a 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/LogContextCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/LogContextCommand.cs @@ -1,31 +1,30 @@ using System.Diagnostics.CodeAnalysis; using StardewModdingAPI.Framework; -namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Other +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Other; + +/// A command which logs contextual info like keys pressed or menus changed until it's disabled. +[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] +internal class LogContextCommand : ConsoleCommand { - /// A command which logs contextual info like keys pressed or menus changed until it's disabled. - [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] - internal class LogContextCommand : ConsoleCommand - { - /********* - ** Public methods - *********/ - /// Construct an instance. - public LogContextCommand() - : base("log_context", "Prints contextual info like keys pressed or menus changed until it's disabled.", mayNeedUpdate: true) { } + /********* + ** Public methods + *********/ + /// Construct an instance. + public LogContextCommand() + : base("log_context", "Prints contextual info like keys pressed or menus changed until it's disabled.", mayNeedUpdate: true) { } - /// Handle the command. - /// Writes messages to the console and log file. - /// The command name. - /// The command arguments. - public override void Handle(IMonitor monitor, string command, ArgumentParser args) - { - Monitor.ForceLogContext = true; + /// Handle the command. + /// Writes messages to the console and log file. + /// The command name. + /// The command arguments. + public override void Handle(IMonitor monitor, string command, ArgumentParser args) + { + Monitor.ForceLogContext = true; - monitor.Log( - Monitor.ForceLogContext ? "OK, logging contextual info until you run this command again." : "OK, no longer logging contextual info.", - LogLevel.Info - ); - } + monitor.Log( + Monitor.ForceLogContext ? "OK, logging contextual info until you run this command again." : "OK, no longer logging contextual info.", + LogLevel.Info + ); } } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/RegenerateBundles.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/RegenerateBundles.cs index e239ed3cf..5b8c93f3e 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/RegenerateBundles.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/RegenerateBundles.cs @@ -7,91 +7,90 @@ using StardewValley; using StardewValley.Network; -namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Other +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Other; + +/// A command which regenerates the game's bundles. +[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] +internal class RegenerateBundlesCommand : ConsoleCommand { - /// A command which regenerates the game's bundles. - [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] - internal class RegenerateBundlesCommand : ConsoleCommand - { - /********* - ** Public methods - *********/ - /// Construct an instance. - public RegenerateBundlesCommand() - : base("regenerate_bundles", $"Regenerate the game's community center bundle data. WARNING: this will reset all bundle progress, and may have unintended effects if you've already completed bundles. DO NOT USE THIS unless you're absolutely sure.\n\nUsage: regenerate_bundles confirm [] [ignore_seed]\nRegenerate all bundles for this save. If the is set to '{string.Join("' or '", Enum.GetNames(typeof(Game1.BundleType)))}', change the bundle type for the save. If an 'ignore_seed' option is included, remixed bundles are re-randomized without using the predetermined save seed.\n\nExample: regenerate_bundles remixed confirm") { } + /********* + ** Public methods + *********/ + /// Construct an instance. + public RegenerateBundlesCommand() + : base("regenerate_bundles", $"Regenerate the game's community center bundle data. WARNING: this will reset all bundle progress, and may have unintended effects if you've already completed bundles. DO NOT USE THIS unless you're absolutely sure.\n\nUsage: regenerate_bundles confirm [] [ignore_seed]\nRegenerate all bundles for this save. If the is set to '{string.Join("' or '", Enum.GetNames(typeof(Game1.BundleType)))}', change the bundle type for the save. If an 'ignore_seed' option is included, remixed bundles are re-randomized without using the predetermined save seed.\n\nExample: regenerate_bundles remixed confirm") { } - /// Handle the command. - /// Writes messages to the console and log file. - /// The command name. - /// The command arguments. - public override void Handle(IMonitor monitor, string command, ArgumentParser args) + /// Handle the command. + /// Writes messages to the console and log file. + /// The command name. + /// The command arguments. + public override void Handle(IMonitor monitor, string command, ArgumentParser args) + { + // get flags + var bundleType = Game1.bundleType; + bool confirmed = false; + bool useSeed = true; + foreach (string arg in args) { - // get flags - var bundleType = Game1.bundleType; - bool confirmed = false; - bool useSeed = true; - foreach (string arg in args) - { - if (arg.Equals("confirm", StringComparison.OrdinalIgnoreCase)) - confirmed = true; - else if (arg.Equals("ignore_seed", StringComparison.OrdinalIgnoreCase)) - useSeed = false; - else if (Enum.TryParse(arg, ignoreCase: true, out Game1.BundleType type)) - bundleType = type; - else - { - monitor.Log($"Invalid option '{arg}'. Type 'help {command}' for usage.", LogLevel.Error); - return; - } - } - - // require confirmation - if (!confirmed) + if (arg.Equals("confirm", StringComparison.OrdinalIgnoreCase)) + confirmed = true; + else if (arg.Equals("ignore_seed", StringComparison.OrdinalIgnoreCase)) + useSeed = false; + else if (Enum.TryParse(arg, ignoreCase: true, out Game1.BundleType type)) + bundleType = type; + else { - monitor.Log($"WARNING: this may have unintended consequences (type 'help {command}' for details). Are you sure?", LogLevel.Warn); - - string[] newArgs = args.Concat(new[] { "confirm" }).ToArray(); - monitor.Log($"To confirm, enter this command: '{command} {string.Join(" ", newArgs)}'.", LogLevel.Info); + monitor.Log($"Invalid option '{arg}'. Type 'help {command}' for usage.", LogLevel.Error); return; } + } - // need a loaded save - if (!Context.IsWorldReady) - { - monitor.Log("You need to load a save to use this command.", LogLevel.Error); - return; - } + // require confirmation + if (!confirmed) + { + monitor.Log($"WARNING: this may have unintended consequences (type 'help {command}' for details). Are you sure?", LogLevel.Warn); - // get private fields - NetWorldState state = Game1.netWorldState.Value; - var bundleData = state.GetType().GetField("_bundleData", BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance)?.GetValue(state) as IDictionary - ?? throw new InvalidOperationException("Can't access '_bundleData' field on world state."); - var netBundleData = state.GetType().GetField("netBundleData", BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance)?.GetValue(state) as NetStringDictionary - ?? throw new InvalidOperationException("Can't access 'netBundleData' field on world state."); + string[] newArgs = args.Concat(new[] { "confirm" }).ToArray(); + monitor.Log($"To confirm, enter this command: '{command} {string.Join(" ", newArgs)}'.", LogLevel.Info); + return; + } - // clear bundle data - state.BundleData.Clear(); - state.Bundles.Clear(); - state.BundleRewards.Clear(); - bundleData.Clear(); - netBundleData.Clear(); + // need a loaded save + if (!Context.IsWorldReady) + { + monitor.Log("You need to load a save to use this command.", LogLevel.Error); + return; + } - // regenerate bundles - var locale = LocalizedContentManager.CurrentLanguageCode; - try - { - LocalizedContentManager.CurrentLanguageCode = LocalizedContentManager.LanguageCode.en; // the base bundle data needs to be unlocalized (the game will add localized names later) + // get private fields + NetWorldState state = Game1.netWorldState.Value; + var bundleData = state.GetType().GetField("_bundleData", BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance)?.GetValue(state) as IDictionary + ?? throw new InvalidOperationException("Can't access '_bundleData' field on world state."); + var netBundleData = state.GetType().GetField("netBundleData", BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance)?.GetValue(state) as NetStringDictionary + ?? throw new InvalidOperationException("Can't access 'netBundleData' field on world state."); - Game1.bundleType = bundleType; - Game1.GenerateBundles(bundleType, use_seed: useSeed); - } - finally - { - LocalizedContentManager.CurrentLanguageCode = locale; - } + // clear bundle data + state.BundleData.Clear(); + state.Bundles.Clear(); + state.BundleRewards.Clear(); + bundleData.Clear(); + netBundleData.Clear(); + + // regenerate bundles + var locale = LocalizedContentManager.CurrentLanguageCode; + try + { + LocalizedContentManager.CurrentLanguageCode = LocalizedContentManager.LanguageCode.en; // the base bundle data needs to be unlocalized (the game will add localized names later) - monitor.Log("Regenerated bundles and reset bundle progress.", LogLevel.Info); - monitor.Log("This may have unintended effects if you've already completed any bundles. If you're not sure, exit your game without saving to cancel.", LogLevel.Warn); + Game1.bundleType = bundleType; + Game1.GenerateBundles(bundleType, use_seed: useSeed); } + finally + { + LocalizedContentManager.CurrentLanguageCode = locale; + } + + monitor.Log("Regenerated bundles and reset bundle progress.", LogLevel.Info); + monitor.Log("This may have unintended effects if you've already completed any bundles. If you're not sure, exit your game without saving to cancel.", LogLevel.Warn); } } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ShowDataFilesCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ShowDataFilesCommand.cs index a233d5881..93ac2d603 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ShowDataFilesCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ShowDataFilesCommand.cs @@ -1,27 +1,31 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; -namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Other +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Other; + +/// A command which shows the data files. +[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] +internal class ShowDataFilesCommand : ConsoleCommand { - /// A command which shows the data files. - [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] - internal class ShowDataFilesCommand : ConsoleCommand - { - /********* - ** Public methods - *********/ - /// Construct an instance. - public ShowDataFilesCommand() - : base("show_data_files", "Opens the folder containing the save and log files.") { } + /********* + ** Public methods + *********/ + /// Construct an instance. + public ShowDataFilesCommand() + : base("show_data_files", "Opens the folder containing the save and log files.") { } - /// Handle the command. - /// Writes messages to the console and log file. - /// The command name. - /// The command arguments. - public override void Handle(IMonitor monitor, string command, ArgumentParser args) + /// Handle the command. + /// Writes messages to the console and log file. + /// The command name. + /// The command arguments. + public override void Handle(IMonitor monitor, string command, ArgumentParser args) + { + Process.Start(new ProcessStartInfo { - Process.Start(Constants.DataPath); - monitor.Log($"OK, opening {Constants.DataPath}.", LogLevel.Info); - } + FileName = Constants.DataPath, + UseShellExecute = true + }); + + monitor.Log($"OK, opening {Constants.DataPath}.", LogLevel.Info); } } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ShowGameFilesCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ShowGameFilesCommand.cs index 745b821ba..eadaed5f7 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ShowGameFilesCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ShowGameFilesCommand.cs @@ -1,27 +1,31 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; -namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Other +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Other; + +/// A command which shows the game files. +[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] +internal class ShowGameFilesCommand : ConsoleCommand { - /// A command which shows the game files. - [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] - internal class ShowGameFilesCommand : ConsoleCommand - { - /********* - ** Public methods - *********/ - /// Construct an instance. - public ShowGameFilesCommand() - : base("show_game_files", "Opens the game folder.") { } + /********* + ** Public methods + *********/ + /// Construct an instance. + public ShowGameFilesCommand() + : base("show_game_files", "Opens the game folder.") { } - /// Handle the command. - /// Writes messages to the console and log file. - /// The command name. - /// The command arguments. - public override void Handle(IMonitor monitor, string command, ArgumentParser args) + /// Handle the command. + /// Writes messages to the console and log file. + /// The command name. + /// The command arguments. + public override void Handle(IMonitor monitor, string command, ArgumentParser args) + { + Process.Start(new ProcessStartInfo { - Process.Start(Constants.GamePath); - monitor.Log($"OK, opening {Constants.GamePath}.", LogLevel.Info); - } + FileName = Constants.GamePath, + UseShellExecute = true + }); + + monitor.Log($"OK, opening {Constants.GamePath}.", LogLevel.Info); } } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/AddCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/AddCommand.cs index ea5ef75d4..014e72e89 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/AddCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/AddCommand.cs @@ -3,184 +3,186 @@ using StardewValley; using Object = StardewValley.Object; -namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player; + +/// A command which adds an item to the player inventory. +internal class AddCommand : ConsoleCommand { - /// A command which adds an item to the player inventory. - internal class AddCommand : ConsoleCommand + /********* + ** Fields + *********/ + /// Provides methods for searching and constructing items. + private readonly ItemRepository Items = new(); + + + /********* + ** Public methods + *********/ + /// Construct an instance. + public AddCommand() + : base("player_add", AddCommand.GetDescription()) { } + + /// Handle the command. + /// Writes messages to the console and log file. + /// The command name. + /// The command arguments. + public override void Handle(IMonitor monitor, string command, ArgumentParser args) { - /********* - ** Fields - *********/ - /// Provides methods for searching and constructing items. - private readonly ItemRepository Items = new(); - - - /********* - ** Public methods - *********/ - /// Construct an instance. - public AddCommand() - : base("player_add", AddCommand.GetDescription()) { } - - /// Handle the command. - /// Writes messages to the console and log file. - /// The command name. - /// The command arguments. - public override void Handle(IMonitor monitor, string command, ArgumentParser args) + // validate + if (!Context.IsWorldReady) { - // validate - if (!Context.IsWorldReady) - { - monitor.Log("You need to load a save to use this command.", LogLevel.Error); - return; - } - - // read arguments - if (!this.TryReadArguments(args, out string? id, out string? name, out int? count, out int? quality)) - return; + monitor.Log("You need to load a save to use this command.", LogLevel.Error); + return; + } - // find matching item - SearchableItem? match = id != null - ? this.FindItemByID(monitor, id) - : this.FindItemByName(monitor, name); - if (match == null) - return; + // read arguments + if (!this.TryReadArguments(args, out string? id, out string? name, out int? count, out int? quality)) + return; - // apply count - match.Item.Stack = count ?? 1; + // find matching item + SearchableItem? match = id != null + ? this.FindItemByID(monitor, id) + : this.FindItemByName(monitor, name); + if (match == null) + return; - // apply quality - if (quality != null) - { - if (match.Item is Object obj) - obj.Quality = quality.Value; - else if (match.Item is Tool tool && args.Count >= 3) - tool.UpgradeLevel = quality.Value; - } + // apply count + match.Item.Stack = count ?? 1; - // add to inventory - Game1.player.addItemByMenuIfNecessary(match.Item); - monitor.Log($"OK, added {match.Name} (ID: {match.QualifiedItemId}) to your inventory.", LogLevel.Info); + // apply quality + if (quality != null) + { + if (match.Item is Object obj) + obj.Quality = quality.Value; + else if (match.Item is Tool tool && args.Count >= 3) + tool.UpgradeLevel = quality.Value; } + // add to inventory + Game1.player.addItemByMenuIfNecessary(match.Item); + monitor.Log($"OK, added {match.Name} (ID: {match.QualifiedItemId}) to your inventory.", LogLevel.Info); + } + - /********* - ** Private methods - *********/ - /// Parse the arguments from the user if they're valid. - /// The arguments to parse. - /// The ID of the item to add, or null if searching by . - /// The name of the item to add, or null if searching by . - /// The number of the item to add. - /// The item quality to set. - /// Returns whether the arguments are valid. - private bool TryReadArguments(ArgumentParser args, out string? id, out string? name, out int? count, out int? quality) + /********* + ** Private methods + *********/ + /// Parse the arguments from the user if they're valid. + /// The arguments to parse. + /// The ID of the item to add, or null if searching by . + /// The name of the item to add, or null if searching by . + /// The number of the item to add. + /// The item quality to set. + /// Returns whether the arguments are valid. + private bool TryReadArguments(ArgumentParser args, out string? id, out string? name, out int? count, out int? quality) + { + // get id or 'name' flag + if (!args.TryGet(0, "id or 'name'", out id, required: true)) + { + name = null; + count = null; + quality = null; + return false; + } + + // get name + int argOffset = 0; + if (string.Equals(id, "name", StringComparison.OrdinalIgnoreCase)) { - // get id or 'name' flag - if (!args.TryGet(0, "id or 'name'", out id, required: true)) + id = null; + if (!args.TryGet(1, "item name", out name)) { - name = null; count = null; quality = null; return false; } - // get name - int argOffset = 0; - if (string.Equals(id, "name", StringComparison.OrdinalIgnoreCase)) - { - id = null; - if (!args.TryGet(1, "item name", out name)) - { - count = null; - quality = null; - return false; - } - - argOffset = 1; - } - else - name = null; - - // get count - count = null; - if (args.TryGetInt(1 + argOffset, "count", out int rawCount, min: 1, required: false)) - count = rawCount; - - // get quality - quality = null; - if (args.TryGetInt(2 + argOffset, "quality", out int rawQuality, min: Object.lowQuality, max: Object.bestQuality, required: false)) - quality = rawQuality; - - return true; + argOffset = 1; } + else + name = null; + // get count + count = null; + if (args.TryGetInt(1 + argOffset, "count", out int rawCount, min: 1, required: false)) + count = rawCount; - /// Get a matching item by its ID. - /// Writes messages to the console and log file. - /// The qualified item ID. - private SearchableItem? FindItemByID(IMonitor monitor, string id) - { - SearchableItem? item = this.Items - .GetAll() - .Where(p => string.Equals(p.QualifiedItemId, id, StringComparison.OrdinalIgnoreCase)) - .OrderByDescending(p => p.QualifiedItemId == id) // prefer case-sensitive match - .FirstOrDefault(); + // get quality + quality = null; + if (args.TryGetInt(2 + argOffset, "quality", out int rawQuality, min: Object.lowQuality, max: Object.bestQuality, required: false)) + quality = rawQuality; - if (item == null) - monitor.Log($"There's no item with the qualified ID {id}.", LogLevel.Error); + return true; + } - return item; - } - /// Get a matching item by its name. - /// Writes messages to the console and log file. - /// The partial item name to match. - private SearchableItem? FindItemByName(IMonitor monitor, string? name) - { - if (string.IsNullOrWhiteSpace(name)) - return null; + /// Get a matching item by its ID. + /// Writes messages to the console and log file. + /// The qualified item ID. + private SearchableItem? FindItemByID(IMonitor monitor, string id) + { + SearchableItem? item = this.Items + .GetAll() + .Where(p => string.Equals(p.QualifiedItemId, id, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(p => p.QualifiedItemId == id) // prefer case-sensitive match + .FirstOrDefault(); - SearchableItem[] matches = this.Items.GetAll().Where(p => p.NameContains(name)).ToArray(); - if (!matches.Any()) - { - monitor.Log($"There's no item whose name contains '{name}'. You can use 'list_items' command to list all items, or search like 'list_items {name}'.", LogLevel.Error); - return null; - } + if (item == null) + monitor.Log($"There's no item with the qualified ID {id}.", LogLevel.Error); - // handle single exact match - SearchableItem[] exactMatches = matches.Where(p => p.NameEquivalentTo(name)).ToArray(); - if (exactMatches.Length == 1) - return exactMatches[0]; - - // handle ambiguous results - string options = this.GetTableString( - data: matches, - header: new[] { "type", "name", "command" }, - getRow: item => new[] { item.Type.ToString(), item.DisplayName, $"player_add {item.QualifiedItemId}" } - ); - monitor.Log($"Multiple items have a name containing '{name}'. Do you mean one of these?\n\n{options}", LogLevel.Info); + return item; + } + + /// Get a matching item by its name. + /// Writes messages to the console and log file. + /// The partial item name to match. + private SearchableItem? FindItemByName(IMonitor monitor, string? name) + { + if (string.IsNullOrWhiteSpace(name)) return null; - } - /// Get the command description. - private static string GetDescription() + SearchableItem[] matches = this.Items.GetAll().Where(p => p.NameContains(name)).ToArray(); + if (!matches.Any()) { - return "Gives the player an item.\n" - + "\n" - + "Usage: player_add [count] [quality]\n" - + "- item id: the item ID (use the 'list_items' command to see a list).\n" - + "- count (optional): how many of the item to give.\n" - + $"- quality (optional): one of {Object.lowQuality} (normal), {Object.medQuality} (silver), {Object.highQuality} (gold), or {Object.bestQuality} (iridium).\n" - + "\n" - + "Usage: player_add name \"\" [count] [quality]\n" - + "- item name: the item name to search (use the 'list_items' command to see a list). This will add the item immediately if it's an exact match, else show a table of matching items.\n" - + "- count (optional): how many of the item to give.\n" - + $"- quality (optional): one of {Object.lowQuality} (normal), {Object.medQuality} (silver), {Object.highQuality} (gold), or {Object.bestQuality} (iridium).\n" - + "\n" - + "These examples both add the galaxy sword to your inventory:\n" - + " player_add weapon 4\n" - + " player_add name \"Galaxy Sword\""; + monitor.Log($"There's no item whose name contains '{name}'. You can use 'list_items' command to list all items, or search like 'list_items {name}'.", LogLevel.Error); + return null; } + + // handle single exact match + SearchableItem[] exactMatches = matches.Where(p => p.NameEquivalentTo(name)).ToArray(); + if (exactMatches.Length == 1) + return exactMatches[0]; + + // handle ambiguous results + string options = this.GetTableString( + data: matches, + header: new[] { "type", "name", "command" }, + getRow: item => new[] { item.Type.ToString(), item.DisplayName, $"player_add {item.QualifiedItemId}" } + ); + monitor.Log($"Multiple items have a name containing '{name}'. Do you mean one of these?\n\n{options}", LogLevel.Info); + return null; + } + + /// Get the command description. + private static string GetDescription() + { + return + $""" + Gives the player an item. + + Usage: player_add [count] [quality] + - item id: the item ID (use the 'list_items' command to see a list). + - count (optional): how many of the item to give. + - quality (optional): one of {Object.lowQuality} (normal), {Object.medQuality} (silver), {Object.highQuality} (gold), or {Object.bestQuality} (iridium). + + Usage: player_add name "" [count] [quality] + - item name: the item name to search (use the 'list_items' command to see a list). This will add the item immediately if it's an exact match, else show a table of matching items. + - count (optional): how many of the item to give. + - quality (optional): one of {Object.lowQuality} (normal), {Object.medQuality} (silver), {Object.highQuality} (gold), or {Object.bestQuality} (iridium). + + These examples both add the galaxy sword to your inventory: + player_add weapon 4 + player_add name "Galaxy Sword" + """; } } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/ListItemsCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/ListItemsCommand.cs index 1f949f303..5d1ad58d3 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/ListItemsCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/ListItemsCommand.cs @@ -3,73 +3,72 @@ using System.Diagnostics.CodeAnalysis; using System.Linq; -namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player; + +/// A command which list items available to spawn. +[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] +internal class ListItemsCommand : ConsoleCommand { - /// A command which list items available to spawn. - [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] - internal class ListItemsCommand : ConsoleCommand - { - /********* - ** Fields - *********/ - /// Provides methods for searching and constructing items. - private readonly ItemRepository Items = new(); + /********* + ** Fields + *********/ + /// Provides methods for searching and constructing items. + private readonly ItemRepository Items = new(); - /********* - ** Public methods - *********/ - /// Construct an instance. - public ListItemsCommand() - : base("list_items", "Lists and searches items in the game data.\n\nUsage: list_items [search]\n- search (optional): an arbitrary search string to filter by.") { } + /********* + ** Public methods + *********/ + /// Construct an instance. + public ListItemsCommand() + : base("list_items", "Lists and searches items in the game data.\n\nUsage: list_items [search]\n- search (optional): an arbitrary search string to filter by.") { } - /// Handle the command. - /// Writes messages to the console and log file. - /// The command name. - /// The command arguments. - public override void Handle(IMonitor monitor, string command, ArgumentParser args) + /// Handle the command. + /// Writes messages to the console and log file. + /// The command name. + /// The command arguments. + public override void Handle(IMonitor monitor, string command, ArgumentParser args) + { + // validate + if (!Context.IsWorldReady) { - // validate - if (!Context.IsWorldReady) - { - monitor.Log("You need to load a save to use this command.", LogLevel.Error); - return; - } - - // handle - SearchableItem[] matches = - ( - from item in this.GetItems(args.ToArray()) - orderby item.Type.ToString(), item.Name - select item - ) - .ToArray(); - string summary = "Searching...\n"; - if (matches.Any()) - monitor.Log(summary + this.GetTableString(matches, new[] { "name", "id" }, val => new[] { val.Name, val.QualifiedItemId }), LogLevel.Info); - else - monitor.Log(summary + "No items found", LogLevel.Info); + monitor.Log("You need to load a save to use this command.", LogLevel.Error); + return; } + // handle + SearchableItem[] matches = + ( + from item in this.GetItems(args.ToArray()) + orderby item.Type.ToString(), item.Name + select item + ) + .ToArray(); + string summary = "Searching...\n"; + if (matches.Any()) + monitor.Log(summary + this.GetTableString(matches, new[] { "name", "id" }, val => new[] { val.Name, val.QualifiedItemId }), LogLevel.Info); + else + monitor.Log(summary + "No items found", LogLevel.Info); + } - /********* - ** Private methods - *********/ - /// Get all items which can be searched and added to the player's inventory through the console. - /// The search string to find. - private IEnumerable GetItems(string[] searchWords) - { - // normalize search term - searchWords = searchWords.Where(word => !string.IsNullOrWhiteSpace(word)).ToArray(); - bool getAll = !searchWords.Any(); - // find matches - return ( - from item in this.Items.GetAll() - let term = $"{item.QualifiedItemId}|{item.Type}|{item.Name}|{item.DisplayName}" - where getAll || searchWords.All(word => term.IndexOf(word, StringComparison.CurrentCultureIgnoreCase) != -1) - select item - ); - } + /********* + ** Private methods + *********/ + /// Get all items which can be searched and added to the player's inventory through the console. + /// The search string to find. + private IEnumerable GetItems(string[] searchWords) + { + // normalize search term + searchWords = searchWords.Where(word => !string.IsNullOrWhiteSpace(word)).ToArray(); + bool getAll = !searchWords.Any(); + + // find matches + return ( + from item in this.Items.GetAll() + let term = $"{item.QualifiedItemId}|{item.Type}|{item.Name}|{item.DisplayName}" + where getAll || searchWords.All(word => term.IndexOf(word, StringComparison.CurrentCultureIgnoreCase) != -1) + select item + ); } } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetColorCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetColorCommand.cs index d267ae21b..89a713a2a 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetColorCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetColorCommand.cs @@ -2,77 +2,76 @@ using Microsoft.Xna.Framework; using StardewValley; -namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player; + +/// A command which edits the color of a player feature. +[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] +internal class SetColorCommand : ConsoleCommand { - /// A command which edits the color of a player feature. - [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] - internal class SetColorCommand : ConsoleCommand + /********* + ** Public methods + *********/ + /// Construct an instance. + public SetColorCommand() + : base("player_changecolor", "Sets the color of a player feature.\n\nUsage: player_changecolor \n- target: what to change (one of 'hair', 'eyes', or 'pants').\n- color: a color value in RGB format, like (255,255,255).") { } + + /// Handle the command. + /// Writes messages to the console and log file. + /// The command name. + /// The command arguments. + public override void Handle(IMonitor monitor, string command, ArgumentParser args) { - /********* - ** Public methods - *********/ - /// Construct an instance. - public SetColorCommand() - : base("player_changecolor", "Sets the color of a player feature.\n\nUsage: player_changecolor \n- target: what to change (one of 'hair', 'eyes', or 'pants').\n- color: a color value in RGB format, like (255,255,255).") { } + // parse arguments + if (!args.TryGet(0, "target", out string? target, oneOf: new[] { "hair", "eyes", "pants" })) + return; + if (!args.TryGet(1, "color", out string? rawColor)) + return; - /// Handle the command. - /// Writes messages to the console and log file. - /// The command name. - /// The command arguments. - public override void Handle(IMonitor monitor, string command, ArgumentParser args) + // parse color + if (!this.TryParseColor(rawColor, out Color color)) { - // parse arguments - if (!args.TryGet(0, "target", out string? target, oneOf: new[] { "hair", "eyes", "pants" })) - return; - if (!args.TryGet(1, "color", out string? rawColor)) - return; - - // parse color - if (!this.TryParseColor(rawColor, out Color color)) - { - this.LogUsageError(monitor, "Argument 1 (color) must be an RBG value like '255,150,0'."); - return; - } + this.LogUsageError(monitor, "Argument 1 (color) must be an RBG value like '255,150,0'."); + return; + } - // handle - switch (target) - { - case "hair": - Game1.player.hairstyleColor.Value = color; - monitor.Log("OK, your hair color is updated.", LogLevel.Info); - break; + // handle + switch (target) + { + case "hair": + Game1.player.hairstyleColor.Value = color; + monitor.Log("OK, your hair color is updated.", LogLevel.Info); + break; - case "eyes": - Game1.player.changeEyeColor(color); - monitor.Log("OK, your eye color is updated.", LogLevel.Info); - break; + case "eyes": + Game1.player.changeEyeColor(color); + monitor.Log("OK, your eye color is updated.", LogLevel.Info); + break; - case "pants": - Game1.player.changePantsColor(color); - Game1.player.UpdateClothing(); - monitor.Log("OK, your pants color is updated.", LogLevel.Info); - break; - } + case "pants": + Game1.player.changePantsColor(color); + Game1.player.UpdateClothing(); + monitor.Log("OK, your pants color is updated.", LogLevel.Info); + break; } + } - /********* - ** Private methods - *********/ - /// Try to parse a color from a string. - /// The input string. - /// The color to set. - private bool TryParseColor(string input, out Color color) + /********* + ** Private methods + *********/ + /// Try to parse a color from a string. + /// The input string. + /// The color to set. + private bool TryParseColor(string input, out Color color) + { + string[] colorHexes = input.Split(',', 3); + if (int.TryParse(colorHexes[0], out int r) && int.TryParse(colorHexes[1], out int g) && int.TryParse(colorHexes[2], out int b)) { - string[] colorHexes = input.Split(',', 3); - if (int.TryParse(colorHexes[0], out int r) && int.TryParse(colorHexes[1], out int g) && int.TryParse(colorHexes[2], out int b)) - { - color = new Color(r, g, b); - return true; - } - - color = Color.Transparent; - return false; + color = new Color(r, g, b); + return true; } + + color = Color.Transparent; + return false; } } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetFarmTypeCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetFarmTypeCommand.cs index e270c2ddf..b40aeff2e 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetFarmTypeCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetFarmTypeCommand.cs @@ -8,217 +8,216 @@ using StardewValley; using StardewValley.GameData; -namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player; + +/// A command which changes the player's farm type. +[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] +internal class SetFarmTypeCommand : ConsoleCommand { - /// A command which changes the player's farm type. - [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] - internal class SetFarmTypeCommand : ConsoleCommand + /********* + ** Public methods + *********/ + /// Construct an instance. + public SetFarmTypeCommand() + : base("set_farm_type", "Sets the current player's farm type.\n\nUsage: set_farm_type \n- farm type: the farm type to set. Enter `set_farm_type list` for a list of available farm types.") { } + + /// Handle the command. + /// Writes messages to the console and log file. + /// The command name. + /// The command arguments. + public override void Handle(IMonitor monitor, string command, ArgumentParser args) { - /********* - ** Public methods - *********/ - /// Construct an instance. - public SetFarmTypeCommand() - : base("set_farm_type", "Sets the current player's farm type.\n\nUsage: set_farm_type \n- farm type: the farm type to set. Enter `set_farm_type list` for a list of available farm types.") { } - - /// Handle the command. - /// Writes messages to the console and log file. - /// The command name. - /// The command arguments. - public override void Handle(IMonitor monitor, string command, ArgumentParser args) + // validate + if (!Context.IsWorldReady) { - // validate - if (!Context.IsWorldReady) - { - monitor.Log("You must load a save to use this command.", LogLevel.Error); - return; - } - - // parse arguments - if (!args.TryGet(0, "farm type", out string? farmType)) - return; - bool isVanillaId = int.TryParse(farmType, out int vanillaId) && vanillaId is (>= 0 and < Farm.layout_max); - - // handle argument - if (farmType == "list") - this.HandleList(monitor); - else if (isVanillaId) - this.HandleVanillaFarmType(vanillaId, monitor); - else - this.HandleCustomFarmType(farmType, monitor); + monitor.Log("You must load a save to use this command.", LogLevel.Error); + return; } + // parse arguments + if (!args.TryGet(0, "farm type", out string? farmType)) + return; + bool isVanillaId = int.TryParse(farmType, out int vanillaId) && vanillaId is (>= 0 and < Farm.layout_max); + + // handle argument + if (farmType == "list") + this.HandleList(monitor); + else if (isVanillaId) + this.HandleVanillaFarmType(vanillaId, monitor); + else + this.HandleCustomFarmType(farmType, monitor); + } - /********* - ** Private methods - *********/ - /**** - ** Handlers - ****/ - /// Print a list of available farm types. - /// Writes messages to the console and log file. - private void HandleList(IMonitor monitor) - { - StringBuilder result = new(); - - // list vanilla types - result.AppendLine("The farm type can be one of these vanilla types:"); - foreach (var type in this.GetVanillaFarmTypes()) - result.AppendLine($" - {type.Key} ({type.Value})"); - result.AppendLine(); - // list custom types - { - var customTypes = this.GetCustomFarmTypes(); - if (customTypes.Any()) - { - result.AppendLine("Or one of these custom farm types:"); - foreach (var type in customTypes.Values.OrderBy(p => p.Id)) - result.AppendLine($" - {type.Id} ({this.GetCustomName(type)})"); - } - else - result.AppendLine("Or a custom farm type (though none is loaded currently)."); - } + /********* + ** Private methods + *********/ + /**** + ** Handlers + ****/ + /// Print a list of available farm types. + /// Writes messages to the console and log file. + private void HandleList(IMonitor monitor) + { + StringBuilder result = new(); - // print - monitor.Log(result.ToString(), LogLevel.Info); - } + // list vanilla types + result.AppendLine("The farm type can be one of these vanilla types:"); + foreach (var type in this.GetVanillaFarmTypes()) + result.AppendLine($" - {type.Key} ({type.Value})"); + result.AppendLine(); - /// Set a vanilla farm type. - /// The farm type. - /// Writes messages to the console and log file. - private void HandleVanillaFarmType(int type, IMonitor monitor) + // list custom types { - if (Game1.whichFarm == type) + var customTypes = this.GetCustomFarmTypes(); + if (customTypes.Any()) { - monitor.Log($"Your current farm is already set to {type} ({this.GetVanillaName(type)}).", LogLevel.Info); - return; + result.AppendLine("Or one of these custom farm types:"); + foreach (var type in customTypes.Values.OrderBy(p => p.Id)) + result.AppendLine($" - {type.Id} ({this.GetCustomName(type)})"); } - - this.SetFarmType(type, null); - this.PrintSuccess(monitor, $"{type} ({this.GetVanillaName(type)}"); + else + result.AppendLine("Or a custom farm type (though none is loaded currently)."); } - /// Set a custom farm type. - /// The farm type ID. - /// Writes messages to the console and log file. - private void HandleCustomFarmType(string id, IMonitor monitor) - { - if (Game1.whichModFarm?.Id == id) - { - monitor.Log($"Your current farm is already set to {id} ({this.GetCustomName(Game1.whichModFarm)}).", LogLevel.Info); - return; - } - - if (!this.GetCustomFarmTypes().TryGetValue(id, out ModFarmType? customFarmType)) - { - monitor.Log($"Invalid farm type '{id}'. Enter `help set_farm_type` for more info.", LogLevel.Error); - return; - } - - this.SetFarmType(Farm.mod_layout, customFarmType); - this.PrintSuccess(monitor, $"{id} ({this.GetCustomName(customFarmType)})"); - } + // print + monitor.Log(result.ToString(), LogLevel.Info); + } - /// Change the farm type. - /// The farm type ID. - /// The custom farm type data, if applicable. - private void SetFarmType(int type, ModFarmType? customFarmData) + /// Set a vanilla farm type. + /// The farm type. + /// Writes messages to the console and log file. + private void HandleVanillaFarmType(int type, IMonitor monitor) + { + if (Game1.whichFarm == type) { - // set flags - Game1.whichFarm = type; - Game1.whichModFarm = customFarmData; - - // update farm map - Farm farm = Game1.getFarm(); - farm.mapPath.Value = $@"Maps\{Farm.getMapNameFromTypeInt(Game1.whichFarm)}"; - farm.reloadMap(); - farm.updateWarps(); - - // clear spouse area cache to avoid errors - FieldInfo? cacheField = farm.GetType().GetField("_baseSpouseAreaTiles", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); - if (cacheField == null) - throw new InvalidOperationException("Failed to access '_baseSpouseAreaTiles' field to clear spouse area cache."); - if (cacheField.GetValue(farm) is not IDictionary cache) - throw new InvalidOperationException($"The farm's '_baseSpouseAreaTiles' field didn't match the expected {nameof(IDictionary)} type."); - cache.Clear(); + monitor.Log($"Your current farm is already set to {type} ({this.GetVanillaName(type)}).", LogLevel.Info); + return; } - private void PrintSuccess(IMonitor monitor, string label) + this.SetFarmType(type, null); + this.PrintSuccess(monitor, $"{type} ({this.GetVanillaName(type)}"); + } + + /// Set a custom farm type. + /// The farm type ID. + /// Writes messages to the console and log file. + private void HandleCustomFarmType(string id, IMonitor monitor) + { + if (Game1.whichModFarm?.Id == id) { - StringBuilder result = new(); - result.AppendLine($"Your current farm has been converted to {label}. Saving and reloading is recommended to make sure everything is updated for the change."); - result.AppendLine(); - result.AppendLine("This doesn't move items that are out of bounds on the new map. If you need to clean up, you can..."); - result.AppendLine(" - temporarily switch back to the previous farm type;"); - result.AppendLine(" - or use a mod like Noclip Mode: https://www.nexusmods.com/stardewvalley/mods/3900 ;"); - result.AppendLine(" - or use the world_clear console command (enter `help world_clear` for details)."); - - monitor.Log(result.ToString(), LogLevel.Warn); + monitor.Log($"Your current farm is already set to {id} ({this.GetCustomName(Game1.whichModFarm)}).", LogLevel.Info); + return; } - /**** - ** Vanilla farm types - ****/ - /// Get the display name for a vanilla farm type. - /// The farm type. - private string GetVanillaName(int type) + if (!this.GetCustomFarmTypes().TryGetValue(id, out ModFarmType? customFarmType)) { - string? translationKey = type switch - { - Farm.default_layout => "Character_FarmStandard", - Farm.riverlands_layout => "Character_FarmFishing", - Farm.forest_layout => "Character_FarmForaging", - Farm.mountains_layout => "Character_FarmMining", - Farm.combat_layout => "Character_FarmCombat", - Farm.fourCorners_layout => "Character_FarmFourCorners", - Farm.beach_layout => "Character_FarmBeach", - _ => null - }; - - return translationKey != null - ? Game1.content.LoadString(@$"Strings\UI:{translationKey}").Split('_')[0] - : type.ToString(); + monitor.Log($"Invalid farm type '{id}'. Enter `help set_farm_type` for more info.", LogLevel.Error); + return; } - /// Get the available vanilla farm types by ID. - private IDictionary GetVanillaFarmTypes() - { - IDictionary farmTypes = new Dictionary(); + this.SetFarmType(Farm.mod_layout, customFarmType); + this.PrintSuccess(monitor, $"{id} ({this.GetCustomName(customFarmType)})"); + } - foreach (int id in Enumerable.Range(0, Farm.layout_max)) - farmTypes[id] = this.GetVanillaName(id); + /// Change the farm type. + /// The farm type ID. + /// The custom farm type data, if applicable. + private void SetFarmType(int type, ModFarmType? customFarmData) + { + // set flags + Game1.whichFarm = type; + Game1.whichModFarm = customFarmData; + + // update farm map + Farm farm = Game1.getFarm(); + farm.mapPath.Value = $@"Maps\{Farm.getMapNameFromTypeInt(Game1.whichFarm)}"; + farm.reloadMap(); + farm.updateWarps(); + + // clear spouse area cache to avoid errors + FieldInfo? cacheField = farm.GetType().GetField("_baseSpouseAreaTiles", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + if (cacheField == null) + throw new InvalidOperationException("Failed to access '_baseSpouseAreaTiles' field to clear spouse area cache."); + if (cacheField.GetValue(farm) is not IDictionary cache) + throw new InvalidOperationException($"The farm's '_baseSpouseAreaTiles' field didn't match the expected {nameof(IDictionary)} type."); + cache.Clear(); + } - return farmTypes; - } + private void PrintSuccess(IMonitor monitor, string label) + { + StringBuilder result = new(); + result.AppendLine($"Your current farm has been converted to {label}. Saving and reloading is recommended to make sure everything is updated for the change."); + result.AppendLine(); + result.AppendLine("This doesn't move items that are out of bounds on the new map. If you need to clean up, you can..."); + result.AppendLine(" - temporarily switch back to the previous farm type;"); + result.AppendLine(" - or use a mod like Noclip Mode: https://www.nexusmods.com/stardewvalley/mods/3900 ;"); + result.AppendLine(" - or use the world_clear console command (enter `help world_clear` for details)."); + + monitor.Log(result.ToString(), LogLevel.Warn); + } - /**** - ** Custom farm types - ****/ - /// Get the display name for a custom farm type. - /// The custom farm type. - private string? GetCustomName(ModFarmType? farmType) + /**** + ** Vanilla farm types + ****/ + /// Get the display name for a vanilla farm type. + /// The farm type. + private string GetVanillaName(int type) + { + string? translationKey = type switch { - if (string.IsNullOrWhiteSpace(farmType?.TooltipStringPath)) - return farmType?.Id; + Farm.default_layout => "Character_FarmStandard", + Farm.riverlands_layout => "Character_FarmFishing", + Farm.forest_layout => "Character_FarmForaging", + Farm.mountains_layout => "Character_FarmMining", + Farm.combat_layout => "Character_FarmCombat", + Farm.fourCorners_layout => "Character_FarmFourCorners", + Farm.beach_layout => "Character_FarmBeach", + _ => null + }; + + return translationKey != null + ? Game1.content.LoadString(@$"Strings\UI:{translationKey}").Split('_')[0] + : type.ToString(); + } - return Game1.content.LoadString(farmType.TooltipStringPath)?.Split('_')[0] ?? farmType.Id; - } + /// Get the available vanilla farm types by ID. + private IDictionary GetVanillaFarmTypes() + { + IDictionary farmTypes = new Dictionary(); - /// Get the available custom farm types by ID. - private IDictionary GetCustomFarmTypes() - { - IDictionary farmTypes = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (int id in Enumerable.Range(0, Farm.layout_max)) + farmTypes[id] = this.GetVanillaName(id); - foreach (ModFarmType farmType in DataLoader.AdditionalFarms(Game1.content)) - { - if (string.IsNullOrWhiteSpace(farmType.Id)) - continue; + return farmTypes; + } - farmTypes[farmType.Id] = farmType; - } + /**** + ** Custom farm types + ****/ + /// Get the display name for a custom farm type. + /// The custom farm type. + private string? GetCustomName(ModFarmType? farmType) + { + if (string.IsNullOrWhiteSpace(farmType?.TooltipStringPath)) + return farmType?.Id; - return farmTypes; + return Game1.content.LoadString(farmType.TooltipStringPath)?.Split('_')[0] ?? farmType.Id; + } + + /// Get the available custom farm types by ID. + private IDictionary GetCustomFarmTypes() + { + IDictionary farmTypes = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (ModFarmType farmType in DataLoader.AdditionalFarms(Game1.content)) + { + if (string.IsNullOrWhiteSpace(farmType.Id)) + continue; + + farmTypes[farmType.Id] = farmType; } + + return farmTypes; } } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetHealthCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetHealthCommand.cs index f169159f4..9d951229c 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetHealthCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetHealthCommand.cs @@ -2,41 +2,40 @@ using System.Linq; using StardewValley; -namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player; + +/// A command which edits the player's current health. +[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] +internal class SetHealthCommand : ConsoleCommand { - /// A command which edits the player's current health. - [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] - internal class SetHealthCommand : ConsoleCommand - { - /********* - ** Public methods - *********/ - /// Construct an instance. - public SetHealthCommand() - : base("player_sethealth", "Sets the player's health.\n\nUsage: player_sethealth [value]\n- value: an integer amount.") { } + /********* + ** Public methods + *********/ + /// Construct an instance. + public SetHealthCommand() + : base("player_sethealth", "Sets the player's health.\n\nUsage: player_sethealth [value]\n- value: an integer amount.") { } - /// Handle the command. - /// Writes messages to the console and log file. - /// The command name. - /// The command arguments. - public override void Handle(IMonitor monitor, string command, ArgumentParser args) + /// Handle the command. + /// Writes messages to the console and log file. + /// The command name. + /// The command arguments. + public override void Handle(IMonitor monitor, string command, ArgumentParser args) + { + // no-argument mode + if (!args.Any()) { - // no-argument mode - if (!args.Any()) - { - monitor.Log($"You currently have {Game1.player.health} health. Specify a value to change it.", LogLevel.Info); - return; - } + monitor.Log($"You currently have {Game1.player.health} health. Specify a value to change it.", LogLevel.Info); + return; + } - // handle - string amountStr = args[0]; - if (int.TryParse(amountStr, out int amount)) - { - Game1.player.health = amount; - monitor.Log($"OK, you now have {Game1.player.health} health.", LogLevel.Info); - } - else - this.LogArgumentNotInt(monitor); + // handle + string amountStr = args[0]; + if (int.TryParse(amountStr, out int amount)) + { + Game1.player.health = amount; + monitor.Log($"OK, you now have {Game1.player.health} health.", LogLevel.Info); } + else + this.LogArgumentNotInt(monitor); } } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMaxHealthCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMaxHealthCommand.cs index c2c4931dd..c35255aef 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMaxHealthCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMaxHealthCommand.cs @@ -2,38 +2,37 @@ using System.Linq; using StardewValley; -namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player; + +/// A command which edits the player's maximum health. +[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] +internal class SetMaxHealthCommand : ConsoleCommand { - /// A command which edits the player's maximum health. - [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] - internal class SetMaxHealthCommand : ConsoleCommand - { - /********* - ** Public methods - *********/ - /// Construct an instance. - public SetMaxHealthCommand() - : base("player_setmaxhealth", "Sets the player's max health.\n\nUsage: player_setmaxhealth [value]\n- value: an integer amount.") { } + /********* + ** Public methods + *********/ + /// Construct an instance. + public SetMaxHealthCommand() + : base("player_setmaxhealth", "Sets the player's max health.\n\nUsage: player_setmaxhealth [value]\n- value: an integer amount.") { } - /// Handle the command. - /// Writes messages to the console and log file. - /// The command name. - /// The command arguments. - public override void Handle(IMonitor monitor, string command, ArgumentParser args) + /// Handle the command. + /// Writes messages to the console and log file. + /// The command name. + /// The command arguments. + public override void Handle(IMonitor monitor, string command, ArgumentParser args) + { + // validate + if (!args.Any()) { - // validate - if (!args.Any()) - { - monitor.Log($"You currently have {Game1.player.maxHealth} max health. Specify a value to change it.", LogLevel.Info); - return; - } + monitor.Log($"You currently have {Game1.player.maxHealth} max health. Specify a value to change it.", LogLevel.Info); + return; + } - // handle - if (args.TryGetInt(0, "amount", out int amount, min: 1)) - { - Game1.player.maxHealth = amount; - monitor.Log($"OK, you now have {Game1.player.maxHealth} max health.", LogLevel.Info); - } + // handle + if (args.TryGetInt(0, "amount", out int amount, min: 1)) + { + Game1.player.maxHealth = amount; + monitor.Log($"OK, you now have {Game1.player.maxHealth} max health.", LogLevel.Info); } } } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMaxStaminaCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMaxStaminaCommand.cs index a17d039fd..9fb73283e 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMaxStaminaCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMaxStaminaCommand.cs @@ -2,38 +2,37 @@ using System.Linq; using StardewValley; -namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player; + +/// A command which edits the player's maximum stamina. +[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] +internal class SetMaxStaminaCommand : ConsoleCommand { - /// A command which edits the player's maximum stamina. - [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] - internal class SetMaxStaminaCommand : ConsoleCommand - { - /********* - ** Public methods - *********/ - /// Construct an instance. - public SetMaxStaminaCommand() - : base("player_setmaxstamina", "Sets the player's max stamina.\n\nUsage: player_setmaxstamina [value]\n- value: an integer amount.") { } + /********* + ** Public methods + *********/ + /// Construct an instance. + public SetMaxStaminaCommand() + : base("player_setmaxstamina", "Sets the player's max stamina.\n\nUsage: player_setmaxstamina [value]\n- value: an integer amount.") { } - /// Handle the command. - /// Writes messages to the console and log file. - /// The command name. - /// The command arguments. - public override void Handle(IMonitor monitor, string command, ArgumentParser args) + /// Handle the command. + /// Writes messages to the console and log file. + /// The command name. + /// The command arguments. + public override void Handle(IMonitor monitor, string command, ArgumentParser args) + { + // validate + if (!args.Any()) { - // validate - if (!args.Any()) - { - monitor.Log($"You currently have {Game1.player.maxStamina} base max stamina. Specify a value to change it.", LogLevel.Info); - return; - } + monitor.Log($"You currently have {Game1.player.maxStamina} base max stamina. Specify a value to change it.", LogLevel.Info); + return; + } - // handle - if (args.TryGetInt(0, "amount", out int amount, min: 1)) - { - Game1.player.maxStamina.Value = amount; - monitor.Log($"OK, you now have {Game1.player.maxStamina.Value} base max stamina.", LogLevel.Info); - } + // handle + if (args.TryGetInt(0, "amount", out int amount, min: 1)) + { + Game1.player.maxStamina.Value = amount; + monitor.Log($"OK, you now have {Game1.player.maxStamina.Value} base max stamina.", LogLevel.Info); } } } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMoneyCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMoneyCommand.cs index 3afcc62bf..fae4edbfb 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMoneyCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMoneyCommand.cs @@ -2,41 +2,40 @@ using System.Linq; using StardewValley; -namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player; + +/// A command which edits the player's current money. +[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] +internal class SetMoneyCommand : ConsoleCommand { - /// A command which edits the player's current money. - [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] - internal class SetMoneyCommand : ConsoleCommand - { - /********* - ** Public methods - *********/ - /// Construct an instance. - public SetMoneyCommand() - : base("player_setmoney", "Sets the player's money.\n\nUsage: player_setmoney \n- value: an integer amount.") { } + /********* + ** Public methods + *********/ + /// Construct an instance. + public SetMoneyCommand() + : base("player_setmoney", "Sets the player's money.\n\nUsage: player_setmoney \n- value: an integer amount.") { } - /// Handle the command. - /// Writes messages to the console and log file. - /// The command name. - /// The command arguments. - public override void Handle(IMonitor monitor, string command, ArgumentParser args) + /// Handle the command. + /// Writes messages to the console and log file. + /// The command name. + /// The command arguments. + public override void Handle(IMonitor monitor, string command, ArgumentParser args) + { + // validate + if (!args.Any()) { - // validate - if (!args.Any()) - { - monitor.Log($"You currently have {Game1.player.Money} gold. Specify a value to change it.", LogLevel.Info); - return; - } + monitor.Log($"You currently have {Game1.player.Money} gold. Specify a value to change it.", LogLevel.Info); + return; + } - // handle - string amountStr = args[0]; - if (int.TryParse(amountStr, out int amount)) - { - Game1.player.Money = amount; - monitor.Log($"OK, you now have {Game1.player.Money} gold.", LogLevel.Info); - } - else - this.LogArgumentNotInt(monitor); + // handle + string amountStr = args[0]; + if (int.TryParse(amountStr, out int amount)) + { + Game1.player.Money = amount; + monitor.Log($"OK, you now have {Game1.player.Money} gold.", LogLevel.Info); } + else + this.LogArgumentNotInt(monitor); } } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetNameCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetNameCommand.cs index 37c02ed0e..8ec715b14 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetNameCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetNameCommand.cs @@ -1,53 +1,52 @@ using System.Diagnostics.CodeAnalysis; using StardewValley; -namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player; + +/// A command which edits the player's name. +[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] +internal class SetNameCommand : ConsoleCommand { - /// A command which edits the player's name. - [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] - internal class SetNameCommand : ConsoleCommand + /********* + ** Public methods + *********/ + /// Construct an instance. + public SetNameCommand() + : base("player_setname", "Sets the player's name.\n\nUsage: player_setname \n- target: what to rename (one of 'player' or 'farm').\n- name: the new name to set.") { } + + /// Handle the command. + /// Writes messages to the console and log file. + /// The command name. + /// The command arguments. + public override void Handle(IMonitor monitor, string command, ArgumentParser args) { - /********* - ** Public methods - *********/ - /// Construct an instance. - public SetNameCommand() - : base("player_setname", "Sets the player's name.\n\nUsage: player_setname \n- target: what to rename (one of 'player' or 'farm').\n- name: the new name to set.") { } + // parse arguments + if (!args.TryGet(0, "target", out string? target, oneOf: new[] { "player", "farm" })) + return; + args.TryGet(1, "name", out string? name, required: false); - /// Handle the command. - /// Writes messages to the console and log file. - /// The command name. - /// The command arguments. - public override void Handle(IMonitor monitor, string command, ArgumentParser args) + // handle + switch (target) { - // parse arguments - if (!args.TryGet(0, "target", out string? target, oneOf: new[] { "player", "farm" })) - return; - args.TryGet(1, "name", out string? name, required: false); - - // handle - switch (target) - { - case "player": - if (!string.IsNullOrWhiteSpace(name)) - { - Game1.player.Name = args[1]; - monitor.Log($"OK, your name is now {Game1.player.Name}.", LogLevel.Info); - } - else - monitor.Log($"Your name is currently '{Game1.player.Name}'. Type 'help player_setname' for usage.", LogLevel.Info); - break; + case "player": + if (!string.IsNullOrWhiteSpace(name)) + { + Game1.player.Name = args[1]; + monitor.Log($"OK, your name is now {Game1.player.Name}.", LogLevel.Info); + } + else + monitor.Log($"Your name is currently '{Game1.player.Name}'. Type 'help player_setname' for usage.", LogLevel.Info); + break; - case "farm": - if (!string.IsNullOrWhiteSpace(name)) - { - Game1.player.farmName.Value = args[1]; - monitor.Log($"OK, your farm's name is now {Game1.player.farmName}.", LogLevel.Info); - } - else - monitor.Log($"Your farm's name is currently '{Game1.player.farmName}'. Type 'help player_setname' for usage.", LogLevel.Info); - break; - } + case "farm": + if (!string.IsNullOrWhiteSpace(name)) + { + Game1.player.farmName.Value = args[1]; + monitor.Log($"OK, your farm's name is now {Game1.player.farmName}.", LogLevel.Info); + } + else + monitor.Log($"Your farm's name is currently '{Game1.player.farmName}'. Type 'help player_setname' for usage.", LogLevel.Info); + break; } } } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStaminaCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStaminaCommand.cs index 24718acea..84d950976 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStaminaCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStaminaCommand.cs @@ -2,41 +2,40 @@ using System.Linq; using StardewValley; -namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player; + +/// A command which edits the player's current stamina. +[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] +internal class SetStaminaCommand : ConsoleCommand { - /// A command which edits the player's current stamina. - [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] - internal class SetStaminaCommand : ConsoleCommand - { - /********* - ** Public methods - *********/ - /// Construct an instance. - public SetStaminaCommand() - : base("player_setstamina", "Sets the player's stamina.\n\nUsage: player_setstamina [value]\n- value: an integer amount.") { } + /********* + ** Public methods + *********/ + /// Construct an instance. + public SetStaminaCommand() + : base("player_setstamina", "Sets the player's stamina.\n\nUsage: player_setstamina [value]\n- value: an integer amount.") { } - /// Handle the command. - /// Writes messages to the console and log file. - /// The command name. - /// The command arguments. - public override void Handle(IMonitor monitor, string command, ArgumentParser args) + /// Handle the command. + /// Writes messages to the console and log file. + /// The command name. + /// The command arguments. + public override void Handle(IMonitor monitor, string command, ArgumentParser args) + { + // validate + if (!args.Any()) { - // validate - if (!args.Any()) - { - monitor.Log($"You currently have {Game1.player.Stamina} stamina. Specify a value to change it.", LogLevel.Info); - return; - } + monitor.Log($"You currently have {Game1.player.Stamina} stamina. Specify a value to change it.", LogLevel.Info); + return; + } - // handle - string amountStr = args[0]; - if (int.TryParse(amountStr, out int amount)) - { - Game1.player.Stamina = amount; - monitor.Log($"OK, you now have {Game1.player.Stamina} stamina.", LogLevel.Info); - } - else - this.LogArgumentNotInt(monitor); + // handle + string amountStr = args[0]; + if (int.TryParse(amountStr, out int amount)) + { + Game1.player.Stamina = amount; + monitor.Log($"OK, you now have {Game1.player.Stamina} stamina.", LogLevel.Info); } + else + this.LogArgumentNotInt(monitor); } } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStyleCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStyleCommand.cs index 473faad86..705a2079d 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStyleCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStyleCommand.cs @@ -1,121 +1,120 @@ using System.Diagnostics.CodeAnalysis; using StardewValley; -namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player; + +/// A command which edits a player style. +[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] +internal class SetStyleCommand : ConsoleCommand { - /// A command which edits a player style. - [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] - internal class SetStyleCommand : ConsoleCommand + /********* + ** Public methods + *********/ + /// Construct an instance. + public SetStyleCommand() + : base("player_changestyle", "Sets the style of a player feature.\n\nUsage: player_changestyle .\n- target: what to change (one of 'hair', 'shirt', 'skin', 'acc', 'shoe', 'swim', or 'gender').\n- value: the style ID.") { } + + /// Handle the command. + /// Writes messages to the console and log file. + /// The command name. + /// The command arguments. + public override void Handle(IMonitor monitor, string command, ArgumentParser args) { - /********* - ** Public methods - *********/ - /// Construct an instance. - public SetStyleCommand() - : base("player_changestyle", "Sets the style of a player feature.\n\nUsage: player_changestyle .\n- target: what to change (one of 'hair', 'shirt', 'skin', 'acc', 'shoe', 'swim', or 'gender').\n- value: the style ID.") { } - - /// Handle the command. - /// Writes messages to the console and log file. - /// The command name. - /// The command arguments. - public override void Handle(IMonitor monitor, string command, ArgumentParser args) + // parse arguments + if (!args.TryGet(0, "target", out string? target, oneOf: new[] { "hair", "shirt", "acc", "skin", "shoe", "swim", "gender" })) + return; + if (!args.TryGet(1, "style ID", out string? styleID)) + return; + + bool AssertIntStyle(out int id) { - // parse arguments - if (!args.TryGet(0, "target", out string? target, oneOf: new[] { "hair", "shirt", "acc", "skin", "shoe", "swim", "gender" })) - return; - if (!args.TryGet(1, "style ID", out string? styleID)) - return; - - bool AssertIntStyle(out int id) - { - if (int.TryParse(styleID, out id)) - return true; - - monitor.Log($"The style ID must be a numeric integer for the '{target}' target.", LogLevel.Error); - return false; - } - - // handle - switch (target) - { - case "hair": - if (AssertIntStyle(out int hairId)) - { - Game1.player.changeHairStyle(hairId); - monitor.Log("OK, your hair style is updated.", LogLevel.Info); - } - break; + if (int.TryParse(styleID, out id)) + return true; - case "shirt": - Game1.player.changeShirt(styleID); - monitor.Log("OK, your shirt style is updated.", LogLevel.Info); - break; + monitor.Log($"The style ID must be a numeric integer for the '{target}' target.", LogLevel.Error); + return false; + } - case "acc": - if (AssertIntStyle(out int accId)) - { - Game1.player.changeAccessory(accId); - monitor.Log("OK, your accessory style is updated.", LogLevel.Info); - } - break; + // handle + switch (target) + { + case "hair": + if (AssertIntStyle(out int hairId)) + { + Game1.player.changeHairStyle(hairId); + monitor.Log("OK, your hair style is updated.", LogLevel.Info); + } + break; - case "skin": - if (AssertIntStyle(out int skinId)) - { - Game1.player.changeSkinColor(skinId); - monitor.Log("OK, your skin color is updated.", LogLevel.Info); - } - break; + case "shirt": + Game1.player.changeShirt(styleID); + monitor.Log("OK, your shirt style is updated.", LogLevel.Info); + break; + + case "acc": + if (AssertIntStyle(out int accId)) + { + Game1.player.changeAccessory(accId); + monitor.Log("OK, your accessory style is updated.", LogLevel.Info); + } + break; + + case "skin": + if (AssertIntStyle(out int skinId)) + { + Game1.player.changeSkinColor(skinId); + monitor.Log("OK, your skin color is updated.", LogLevel.Info); + } + break; - case "shoe": - Game1.player.changeShoeColor(styleID); - monitor.Log("OK, your shoe style is updated.", LogLevel.Info); - break; + case "shoe": + Game1.player.changeShoeColor(styleID); + monitor.Log("OK, your shoe style is updated.", LogLevel.Info); + break; - case "swim": - if (AssertIntStyle(out int swimId)) + case "swim": + if (AssertIntStyle(out int swimId)) + { + switch (swimId) { - switch (swimId) - { - case 0: - Game1.player.changeOutOfSwimSuit(); - monitor.Log("OK, you're no longer in your swimming suit.", LogLevel.Info); - break; - - case 1: - Game1.player.changeIntoSwimsuit(); - monitor.Log("OK, you're now in your swimming suit.", LogLevel.Info); - break; - - default: - this.LogUsageError(monitor, "The swim value should be 0 (no swimming suit) or 1 (swimming suit)."); - break; - } + case 0: + Game1.player.changeOutOfSwimSuit(); + monitor.Log("OK, you're no longer in your swimming suit.", LogLevel.Info); + break; + + case 1: + Game1.player.changeIntoSwimsuit(); + monitor.Log("OK, you're now in your swimming suit.", LogLevel.Info); + break; + + default: + this.LogUsageError(monitor, "The swim value should be 0 (no swimming suit) or 1 (swimming suit)."); + break; } - break; + } + break; - case "gender": - if (AssertIntStyle(out int genderId)) + case "gender": + if (AssertIntStyle(out int genderId)) + { + switch (genderId) { - switch (genderId) - { - case 0: - Game1.player.changeGender(true); - monitor.Log("OK, you're now male.", LogLevel.Info); - break; - - case 1: - Game1.player.changeGender(false); - monitor.Log("OK, you're now female.", LogLevel.Info); - break; - - default: - this.LogUsageError(monitor, "The gender value should be 0 (male) or 1 (female)."); - break; - } + case 0: + Game1.player.changeGender(true); + monitor.Log("OK, you're now male.", LogLevel.Info); + break; + + case 1: + Game1.player.changeGender(false); + monitor.Log("OK, you're now female.", LogLevel.Info); + break; + + default: + this.LogUsageError(monitor, "The gender value should be 0 (male) or 1 (female)."); + break; } - break; - } + } + break; } } } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/ClearCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/ClearCommand.cs index 9a1f114b0..3856828ab 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/ClearCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/ClearCommand.cs @@ -7,252 +7,255 @@ using StardewValley.TerrainFeatures; using SObject = StardewValley.Object; -namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World; + +/// A command which clears in-game objects. +[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] +internal class ClearCommand : ConsoleCommand { - /// A command which clears in-game objects. - [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] - internal class ClearCommand : ConsoleCommand + /********* + ** Fields + *********/ + /// The valid types that can be cleared. + private readonly string[] ValidTypes = { "crops", "debris", "fruit-trees", "furniture", "grass", "trees", "removable", "everything" }; + + /// The resource clump IDs to consider debris. + private readonly int[] DebrisClumps = { ResourceClump.stumpIndex, ResourceClump.hollowLogIndex, ResourceClump.meteoriteIndex, ResourceClump.boulderIndex }; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + public ClearCommand() + : base( + name: "world_clear", + description: + """ + Clears in-game entities in a given location. + + Usage: world_clear + - location: the location name for which to clear objects (like Farm), or 'current' for the current location. + - object type: the type of object clear. You can specify 'crops', 'debris' (stones/twigs/weeds and dead crops), 'furniture', 'grass', and 'trees' / 'fruit-trees'. You can also specify 'removable' (remove everything that can be removed or destroyed during normal gameplay) or 'everything' (remove everything including permanent bushes). + """ + ) + { } + + /// Handle the command. + /// Writes messages to the console and log file. + /// The command name. + /// The command arguments. + public override void Handle(IMonitor monitor, string command, ArgumentParser args) { - /********* - ** Fields - *********/ - /// The valid types that can be cleared. - private readonly string[] ValidTypes = { "crops", "debris", "fruit-trees", "furniture", "grass", "trees", "removable", "everything" }; - - /// The resource clump IDs to consider debris. - private readonly int[] DebrisClumps = { ResourceClump.stumpIndex, ResourceClump.hollowLogIndex, ResourceClump.meteoriteIndex, ResourceClump.boulderIndex }; - - - /********* - ** Public methods - *********/ - /// Construct an instance. - public ClearCommand() - : base( - name: "world_clear", - description: "Clears in-game entities in a given location.\n\n" - + "Usage: world_clear \n" - + " - location: the location name for which to clear objects (like Farm), or 'current' for the current location.\n" - + " - object type: the type of object clear. You can specify 'crops', 'debris' (stones/twigs/weeds and dead crops), 'furniture', 'grass', and 'trees' / 'fruit-trees'. You can also specify 'removable' (remove everything that can be removed or destroyed during normal gameplay) or 'everything' (remove everything including permanent bushes)." - ) - { } - - /// Handle the command. - /// Writes messages to the console and log file. - /// The command name. - /// The command arguments. - public override void Handle(IMonitor monitor, string command, ArgumentParser args) + // check context + if (!Context.IsWorldReady) { - // check context - if (!Context.IsWorldReady) - { - monitor.Log("You need to load a save to use this command.", LogLevel.Error); - return; - } + monitor.Log("You need to load a save to use this command.", LogLevel.Error); + return; + } - // parse arguments - if (!args.TryGet(0, "location", out string? locationName, required: true)) - return; - if (!args.TryGet(1, "object type", out string? type, required: true, oneOf: this.ValidTypes)) - return; - - // get target location - GameLocation? location = Game1.locations.FirstOrDefault(p => p.Name != null && p.Name.Equals(locationName, StringComparison.OrdinalIgnoreCase)); - if (location == null && locationName == "current") - location = Game1.currentLocation; - if (location == null) - { - string[] locationNames = (from loc in Game1.locations where !string.IsNullOrWhiteSpace(loc.Name) orderby loc.Name select loc.Name).ToArray(); - monitor.Log($"Could not find a location with that name. Must be one of [{string.Join(", ", locationNames)}].", LogLevel.Error); - return; - } + // parse arguments + if (!args.TryGet(0, "location", out string? locationName, required: true)) + return; + if (!args.TryGet(1, "object type", out string? type, required: true, oneOf: this.ValidTypes)) + return; + + // get target location + GameLocation? location = Game1.locations.FirstOrDefault(p => p.Name != null && p.Name.Equals(locationName, StringComparison.OrdinalIgnoreCase)); + if (location == null && locationName == "current") + location = Game1.currentLocation; + if (location == null) + { + string[] locationNames = (from loc in Game1.locations where !string.IsNullOrWhiteSpace(loc.Name) orderby loc.Name select loc.Name).ToArray(); + monitor.Log($"Could not find a location with that name. Must be one of [{string.Join(", ", locationNames)}].", LogLevel.Error); + return; + } - // apply - switch (type) - { - case "crops": - { - int removed = - this.RemoveTerrainFeatures(location, p => p is HoeDirt) - + this.RemoveResourceClumps(location, p => p is GiantCrop); - monitor.Log($"Done! Removed {removed} entities from {location.Name}.", LogLevel.Info); - break; - } + // apply + switch (type) + { + case "crops": + { + int removed = + this.RemoveTerrainFeatures(location, p => p is HoeDirt) + + this.RemoveResourceClumps(location, p => p is GiantCrop); + monitor.Log($"Done! Removed {removed} entities from {location.Name}.", LogLevel.Info); + break; + } - case "debris": + case "debris": + { + int removed = 0; + foreach (var pair in location.terrainFeatures.Pairs.ToArray()) { - int removed = 0; - foreach (var pair in location.terrainFeatures.Pairs.ToArray()) + TerrainFeature feature = pair.Value; + if (feature is HoeDirt dirt && dirt.crop?.dead.Value is true) { - TerrainFeature feature = pair.Value; - if (feature is HoeDirt dirt && dirt.crop?.dead == true) - { - dirt.crop = null; - removed++; - } + dirt.crop = null; + removed++; } - - removed += - this.RemoveObjects(location, obj => - obj is not Chest - && ( - obj.Name is "Weeds" or "Stone" - || obj.ParentSheetIndex is 294 or 295 - ) - ) - + this.RemoveResourceClumps(location, clump => this.DebrisClumps.Contains(clump.parentSheetIndex.Value)); - - monitor.Log($"Done! Removed {removed} entities from {location.Name}.", LogLevel.Info); - break; } - case "fruit-trees": - { - int removed = this.RemoveTerrainFeatures(location, feature => feature is FruitTree); - monitor.Log($"Done! Removed {removed} entities from {location.Name}.", LogLevel.Info); - break; - } - - case "furniture": - { - int removed = this.RemoveFurniture(location, _ => true); - monitor.Log($"Done! Removed {removed} entities from {location.Name}.", LogLevel.Info); - break; - } - - case "grass": - { - int removed = this.RemoveTerrainFeatures(location, feature => feature is Grass); - monitor.Log($"Done! Removed {removed} entities from {location.Name}.", LogLevel.Info); - break; - } + removed += + this.RemoveObjects(location, obj => + obj is not Chest + && ( + obj.Name is "Weeds" or "Stone" + || obj.ParentSheetIndex is 294 or 295 + ) + ) + + this.RemoveResourceClumps(location, clump => this.DebrisClumps.Contains(clump.parentSheetIndex.Value)); - case "trees": - { - int removed = this.RemoveTerrainFeatures(location, feature => feature is Tree); - monitor.Log($"Done! Removed {removed} entities from {location.Name}.", LogLevel.Info); - break; - } + monitor.Log($"Done! Removed {removed} entities from {location.Name}.", LogLevel.Info); + break; + } - case "removable": - case "everything": - { - bool everything = type == "everything"; - int removed = - this.RemoveFurniture(location, _ => true) - + this.RemoveObjects(location, _ => true) - + this.RemoveTerrainFeatures(location, _ => true) - + this.RemoveLargeTerrainFeatures(location, p => everything || p is not Bush bush || bush.isDestroyable()) - + this.RemoveResourceClumps(location, _ => true); - monitor.Log($"Done! Removed {removed} entities from {location.Name}.", LogLevel.Info); - break; - } + case "fruit-trees": + { + int removed = this.RemoveTerrainFeatures(location, feature => feature is FruitTree); + monitor.Log($"Done! Removed {removed} entities from {location.Name}.", LogLevel.Info); + break; + } - default: - monitor.Log($"Unknown type '{type}'. Must be one [{string.Join(", ", this.ValidTypes)}].", LogLevel.Error); + case "furniture": + { + int removed = this.RemoveFurniture(location, _ => true); + monitor.Log($"Done! Removed {removed} entities from {location.Name}.", LogLevel.Info); break; - } - } + } + case "grass": + { + int removed = this.RemoveTerrainFeatures(location, feature => feature is Grass); + monitor.Log($"Done! Removed {removed} entities from {location.Name}.", LogLevel.Info); + break; + } - /********* - ** Private methods - *********/ - /// Remove objects from a location matching a lambda. - /// The location to search. - /// Whether an entity should be removed. - /// Returns the number of removed entities. - private int RemoveObjects(GameLocation location, Func shouldRemove) - { - int removed = 0; + case "trees": + { + int removed = this.RemoveTerrainFeatures(location, feature => feature is Tree); + monitor.Log($"Done! Removed {removed} entities from {location.Name}.", LogLevel.Info); + break; + } - foreach ((Vector2 tile, SObject? obj) in location.Objects.Pairs.ToArray()) - { - if (shouldRemove(obj)) + case "removable": + case "everything": { - location.Objects.Remove(tile); - removed++; + bool everything = type == "everything"; + int removed = + this.RemoveFurniture(location, _ => true) + + this.RemoveObjects(location, _ => true) + + this.RemoveTerrainFeatures(location, _ => true) + + this.RemoveLargeTerrainFeatures(location, p => everything || p is not Bush bush || bush.isDestroyable()) + + this.RemoveResourceClumps(location, _ => true); + monitor.Log($"Done! Removed {removed} entities from {location.Name}.", LogLevel.Info); + break; } - } - return removed; + default: + monitor.Log($"Unknown type '{type}'. Must be one [{string.Join(", ", this.ValidTypes)}].", LogLevel.Error); + break; } + } - /// Remove terrain features from a location matching a lambda. - /// The location to search. - /// Whether an entity should be removed. - /// Returns the number of removed entities. - private int RemoveTerrainFeatures(GameLocation location, Func shouldRemove) - { - int removed = 0; - foreach ((Vector2 tile, TerrainFeature? feature) in location.terrainFeatures.Pairs.ToArray()) + /********* + ** Private methods + *********/ + /// Remove objects from a location matching a lambda. + /// The location to search. + /// Whether an entity should be removed. + /// Returns the number of removed entities. + private int RemoveObjects(GameLocation location, Func shouldRemove) + { + int removed = 0; + + foreach ((Vector2 tile, SObject? obj) in location.Objects.Pairs.ToArray()) + { + if (shouldRemove(obj)) { - if (shouldRemove(feature)) - { - location.terrainFeatures.Remove(tile); - removed++; - } + location.Objects.Remove(tile); + removed++; } - - return removed; } - /// Remove large terrain features from a location matching a lambda. - /// The location to search. - /// Whether an entity should be removed. - /// Returns the number of removed entities. - private int RemoveLargeTerrainFeatures(GameLocation location, Func shouldRemove) - { - int removed = 0; + return removed; + } - foreach (LargeTerrainFeature feature in location.largeTerrainFeatures.ToArray()) + /// Remove terrain features from a location matching a lambda. + /// The location to search. + /// Whether an entity should be removed. + /// Returns the number of removed entities. + private int RemoveTerrainFeatures(GameLocation location, Func shouldRemove) + { + int removed = 0; + + foreach ((Vector2 tile, TerrainFeature? feature) in location.terrainFeatures.Pairs.ToArray()) + { + if (shouldRemove(feature)) { - if (shouldRemove(feature)) - { - location.largeTerrainFeatures.Remove(feature); - removed++; - } + location.terrainFeatures.Remove(tile); + removed++; } - - return removed; } - /// Remove resource clumps from a location matching a lambda. - /// The location to search. - /// Whether an entity should be removed. - /// Returns the number of removed entities. - private int RemoveResourceClumps(GameLocation location, Func shouldRemove) - { - int removed = 0; + return removed; + } - foreach (ResourceClump clump in location.resourceClumps.Where(shouldRemove).ToArray()) + /// Remove large terrain features from a location matching a lambda. + /// The location to search. + /// Whether an entity should be removed. + /// Returns the number of removed entities. + private int RemoveLargeTerrainFeatures(GameLocation location, Func shouldRemove) + { + int removed = 0; + + foreach (LargeTerrainFeature feature in location.largeTerrainFeatures.ToArray()) + { + if (shouldRemove(feature)) { - location.resourceClumps.Remove(clump); + location.largeTerrainFeatures.Remove(feature); removed++; } - - return removed; } - /// Remove furniture from a location matching a lambda. - /// The location to search. - /// Whether an entity should be removed. - /// Returns the number of removed entities. - private int RemoveFurniture(GameLocation location, Func shouldRemove) + return removed; + } + + /// Remove resource clumps from a location matching a lambda. + /// The location to search. + /// Whether an entity should be removed. + /// Returns the number of removed entities. + private int RemoveResourceClumps(GameLocation location, Func shouldRemove) + { + int removed = 0; + + foreach (ResourceClump clump in location.resourceClumps.Where(shouldRemove).ToArray()) { - int removed = 0; + location.resourceClumps.Remove(clump); + removed++; + } + + return removed; + } - foreach (Furniture furniture in location.furniture.ToArray()) + /// Remove furniture from a location matching a lambda. + /// The location to search. + /// Whether an entity should be removed. + /// Returns the number of removed entities. + private int RemoveFurniture(GameLocation location, Func shouldRemove) + { + int removed = 0; + + foreach (Furniture furniture in location.furniture.ToArray()) + { + if (shouldRemove(furniture)) { - if (shouldRemove(furniture)) - { - location.furniture.Remove(furniture); - removed++; - } + location.furniture.Remove(furniture); + removed++; } - - return removed; } + + return removed; } } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/DownMineLevelCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/DownMineLevelCommand.cs index 5b1a4a13f..23988d101 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/DownMineLevelCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/DownMineLevelCommand.cs @@ -2,28 +2,27 @@ using StardewValley; using StardewValley.Locations; -namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World; + +/// A command which moves the player to the next mine level. +[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] +internal class DownMineLevelCommand : ConsoleCommand { - /// A command which moves the player to the next mine level. - [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] - internal class DownMineLevelCommand : ConsoleCommand - { - /********* - ** Public methods - *********/ - /// Construct an instance. - public DownMineLevelCommand() - : base("world_downminelevel", "Goes down one mine level.") { } + /********* + ** Public methods + *********/ + /// Construct an instance. + public DownMineLevelCommand() + : base("world_downminelevel", "Goes down one mine level.") { } - /// Handle the command. - /// Writes messages to the console and log file. - /// The command name. - /// The command arguments. - public override void Handle(IMonitor monitor, string command, ArgumentParser args) - { - int level = (Game1.currentLocation as MineShaft)?.mineLevel ?? 0; - monitor.Log($"OK, warping you to mine level {level + 1}.", LogLevel.Info); - Game1.enterMine(level + 1); - } + /// Handle the command. + /// Writes messages to the console and log file. + /// The command name. + /// The command arguments. + public override void Handle(IMonitor monitor, string command, ArgumentParser args) + { + int level = (Game1.currentLocation as MineShaft)?.mineLevel ?? 0; + monitor.Log($"OK, warping you to mine level {level + 1}.", LogLevel.Info); + Game1.enterMine(level + 1); } } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/FreezeTimeCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/FreezeTimeCommand.cs index 16faa2fe7..4fe2e3836 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/FreezeTimeCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/FreezeTimeCommand.cs @@ -1,59 +1,58 @@ using System.Linq; using StardewValley; -namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World; + +/// A command which freezes the current time. +internal class FreezeTimeCommand : ConsoleCommand { - /// A command which freezes the current time. - internal class FreezeTimeCommand : ConsoleCommand + /********* + ** Fields + *********/ + /// The time of day at which to freeze time. + internal static int FrozenTime; + + /// Whether to freeze time. + private bool FreezeTime; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + public FreezeTimeCommand() + : base("world_freezetime", "Freezes or resumes time.\n\nUsage: world_freezetime [value]\n- value: one of 0 (resume), 1 (freeze), or blank (toggle).", mayNeedUpdate: true) { } + + /// Handle the command. + /// Writes messages to the console and log file. + /// The command name. + /// The command arguments. + public override void Handle(IMonitor monitor, string command, ArgumentParser args) { - /********* - ** Fields - *********/ - /// The time of day at which to freeze time. - internal static int FrozenTime; - - /// Whether to freeze time. - private bool FreezeTime; - - - /********* - ** Public methods - *********/ - /// Construct an instance. - public FreezeTimeCommand() - : base("world_freezetime", "Freezes or resumes time.\n\nUsage: world_freezetime [value]\n- value: one of 0 (resume), 1 (freeze), or blank (toggle).", mayNeedUpdate: true) { } - - /// Handle the command. - /// Writes messages to the console and log file. - /// The command name. - /// The command arguments. - public override void Handle(IMonitor monitor, string command, ArgumentParser args) + if (args.Any()) { - if (args.Any()) - { - // parse arguments - if (!args.TryGetInt(0, "value", out int value, min: 0, max: 1)) - return; - - // handle - this.FreezeTime = value == 1; - FreezeTimeCommand.FrozenTime = Game1.timeOfDay; - monitor.Log($"OK, time is now {(this.FreezeTime ? "frozen" : "resumed")}.", LogLevel.Info); - } - else - { - this.FreezeTime = !this.FreezeTime; - FreezeTimeCommand.FrozenTime = Game1.timeOfDay; - monitor.Log($"OK, time is now {(this.FreezeTime ? "frozen" : "resumed")}.", LogLevel.Info); - } - } + // parse arguments + if (!args.TryGetInt(0, "value", out int value, min: 0, max: 1)) + return; - /// Perform any logic needed on update tick. - /// Writes messages to the console and log file. - public override void OnUpdated(IMonitor monitor) + // handle + this.FreezeTime = value == 1; + FreezeTimeCommand.FrozenTime = Game1.timeOfDay; + monitor.Log($"OK, time is now {(this.FreezeTime ? "frozen" : "resumed")}.", LogLevel.Info); + } + else { - if (this.FreezeTime && Context.IsWorldReady) - Game1.timeOfDay = FreezeTimeCommand.FrozenTime; + this.FreezeTime = !this.FreezeTime; + FreezeTimeCommand.FrozenTime = Game1.timeOfDay; + monitor.Log($"OK, time is now {(this.FreezeTime ? "frozen" : "resumed")}.", LogLevel.Info); } } + + /// Perform any logic needed on update tick. + /// Writes messages to the console and log file. + public override void OnUpdated(IMonitor monitor) + { + if (this.FreezeTime && Context.IsWorldReady) + Game1.timeOfDay = FreezeTimeCommand.FrozenTime; + } } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/HurryAllCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/HurryAllCommand.cs index fe4270a5a..81841e71c 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/HurryAllCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/HurryAllCommand.cs @@ -2,52 +2,55 @@ using System.Diagnostics.CodeAnalysis; using StardewValley; -namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World; + +/// A command which immediately warps all NPCs to their scheduled positions. To hurry a single NPC, see debug hurry npc-name instead. +[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] +internal class HurryAllCommand : ConsoleCommand { - /// A command which immediately warps all NPCs to their scheduled positions. To hurry a single NPC, see debug hurry npc-name instead. - [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] - internal class HurryAllCommand : ConsoleCommand + /********* + ** Public methods + *********/ + /// Construct an instance. + public HurryAllCommand() + : base( + name: "hurry_all", + description: + """ + Immediately warps all NPCs to their scheduled positions. (To hurry a single NPC, use `debug hurry npc-name` instead.) + + Usage: hurry_all + """ + ) + { } + + /// Handle the command. + /// Writes messages to the console and log file. + /// The command name. + /// The command arguments. + public override void Handle(IMonitor monitor, string command, ArgumentParser args) { - /********* - ** Public methods - *********/ - /// Construct an instance. - public HurryAllCommand() - : base( - name: "hurry_all", - description: "Immediately warps all NPCs to their scheduled positions. (To hurry a single NPC, use `debug hurry npc-name` instead.)\n\n" - + "Usage: hurry_all" - ) - { } + // check context + if (!Context.IsWorldReady) + { + monitor.Log("You need to load a save to use this command.", LogLevel.Error); + return; + } - /// Handle the command. - /// Writes messages to the console and log file. - /// The command name. - /// The command arguments. - public override void Handle(IMonitor monitor, string command, ArgumentParser args) + // hurry all NPCs + foreach (NPC npc in Utility.getAllVillagers()) // can't use Utility.ForEachVillager since they may warp mid-iteration { - // check context - if (!Context.IsWorldReady) + monitor.Log($"Hurrying {npc.Name}..."); + try { - monitor.Log("You need to load a save to use this command.", LogLevel.Error); - return; + npc.warpToPathControllerDestination(); } - - // hurry all NPCs - foreach (NPC npc in Utility.getAllVillagers()) // can't use Utility.ForEachVillager since they may warp mid-iteration + catch (Exception ex) { - monitor.Log($"Hurrying {npc.Name}..."); - try - { - npc.warpToPathControllerDestination(); - } - catch (Exception ex) - { - monitor.Log($"Failed hurrying {npc.Name}. Technical details:\n{ex}", LogLevel.Error); - } + monitor.Log($"Failed hurrying {npc.Name}. Technical details:\n{ex}", LogLevel.Error); } - - monitor.Log("Done!", LogLevel.Info); } + + monitor.Log("Done!", LogLevel.Info); } } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetDayCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetDayCommand.cs index 399fd9344..af0dffca4 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetDayCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetDayCommand.cs @@ -3,40 +3,39 @@ using StardewModdingAPI.Utilities; using StardewValley; -namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World; + +/// A command which sets the current day. +[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] +internal class SetDayCommand : ConsoleCommand { - /// A command which sets the current day. - [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] - internal class SetDayCommand : ConsoleCommand - { - /********* - ** Public methods - *********/ - /// Construct an instance. - public SetDayCommand() - : base("world_setday", "Sets the day to the specified value.\n\nUsage: world_setday .\n- value: the target day (a number from 1 to 28).") { } + /********* + ** Public methods + *********/ + /// Construct an instance. + public SetDayCommand() + : base("world_setday", "Sets the day to the specified value.\n\nUsage: world_setday .\n- value: the target day (a number from 1 to 28).") { } - /// Handle the command. - /// Writes messages to the console and log file. - /// The command name. - /// The command arguments. - public override void Handle(IMonitor monitor, string command, ArgumentParser args) + /// Handle the command. + /// Writes messages to the console and log file. + /// The command name. + /// The command arguments. + public override void Handle(IMonitor monitor, string command, ArgumentParser args) + { + // no-argument mode + if (!args.Any()) { - // no-argument mode - if (!args.Any()) - { - monitor.Log($"The current date is {Game1.currentSeason} {Game1.dayOfMonth}. Specify a value to change the day.", LogLevel.Info); - return; - } + monitor.Log($"The current date is {Game1.currentSeason} {Game1.dayOfMonth}. Specify a value to change the day.", LogLevel.Info); + return; + } - // parse arguments - if (!args.TryGetInt(0, "day", out int day, min: 1, max: 28)) - return; + // parse arguments + if (!args.TryGetInt(0, "day", out int day, min: 1, max: 28)) + return; - // handle - Game1.dayOfMonth = day; - Game1.stats.DaysPlayed = (uint)SDate.Now().DaysSinceStart; - monitor.Log($"OK, the date is now {Game1.currentSeason} {Game1.dayOfMonth}.", LogLevel.Info); - } + // handle + Game1.dayOfMonth = day; + Game1.stats.DaysPlayed = (uint)SDate.Now().DaysSinceStart; + monitor.Log($"OK, the date is now {Game1.currentSeason} {Game1.dayOfMonth}.", LogLevel.Info); } } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetMineLevelCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetMineLevelCommand.cs index f977fce35..1d9966737 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetMineLevelCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetMineLevelCommand.cs @@ -2,33 +2,32 @@ using System.Diagnostics.CodeAnalysis; using StardewValley; -namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World; + +/// A command which moves the player to the given mine level. +[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] +internal class SetMineLevelCommand : ConsoleCommand { - /// A command which moves the player to the given mine level. - [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] - internal class SetMineLevelCommand : ConsoleCommand - { - /********* - ** Public methods - *********/ - /// Construct an instance. - public SetMineLevelCommand() - : base("world_setminelevel", "Sets the mine level?\n\nUsage: world_setminelevel \n- value: The target level (a number starting at 1).") { } + /********* + ** Public methods + *********/ + /// Construct an instance. + public SetMineLevelCommand() + : base("world_setminelevel", "Sets the mine level?\n\nUsage: world_setminelevel \n- value: The target level (a number starting at 1).") { } - /// Handle the command. - /// Writes messages to the console and log file. - /// The command name. - /// The command arguments. - public override void Handle(IMonitor monitor, string command, ArgumentParser args) - { - // parse arguments - if (!args.TryGetInt(0, "mine level", out int level, min: 1)) - return; + /// Handle the command. + /// Writes messages to the console and log file. + /// The command name. + /// The command arguments. + public override void Handle(IMonitor monitor, string command, ArgumentParser args) + { + // parse arguments + if (!args.TryGetInt(0, "mine level", out int level, min: 1)) + return; - // handle - level = Math.Max(1, level); - monitor.Log($"OK, warping you to mine level {level}.", LogLevel.Info); - Game1.enterMine(level); - } + // handle + level = Math.Max(1, level); + monitor.Log($"OK, warping you to mine level {level}.", LogLevel.Info); + Game1.enterMine(level); } } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetSeasonCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetSeasonCommand.cs index 505c0d1d6..1fe77bef8 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetSeasonCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetSeasonCommand.cs @@ -3,48 +3,47 @@ using StardewModdingAPI.Utilities; using StardewValley; -namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World; + +/// A command which sets the current season. +[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] +internal class SetSeasonCommand : ConsoleCommand { - /// A command which sets the current season. - [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] - internal class SetSeasonCommand : ConsoleCommand - { - /********* - ** Fields - *********/ - /// The valid season names. - private readonly string[] ValidSeasons = { "winter", "spring", "summer", "fall" }; + /********* + ** Fields + *********/ + /// The valid season names. + private readonly string[] ValidSeasons = { "winter", "spring", "summer", "fall" }; - /********* - ** Public methods - *********/ - /// Construct an instance. - public SetSeasonCommand() - : base("world_setseason", "Sets the season to the specified value.\n\nUsage: world_setseason \n- season: the target season (one of 'spring', 'summer', 'fall', 'winter').") { } + /********* + ** Public methods + *********/ + /// Construct an instance. + public SetSeasonCommand() + : base("world_setseason", "Sets the season to the specified value.\n\nUsage: world_setseason \n- season: the target season (one of 'spring', 'summer', 'fall', 'winter').") { } - /// Handle the command. - /// Writes messages to the console and log file. - /// The command name. - /// The command arguments. - public override void Handle(IMonitor monitor, string command, ArgumentParser args) + /// Handle the command. + /// Writes messages to the console and log file. + /// The command name. + /// The command arguments. + public override void Handle(IMonitor monitor, string command, ArgumentParser args) + { + // no-argument mode + if (!args.Any()) { - // no-argument mode - if (!args.Any()) - { - monitor.Log($"The current season is {Game1.currentSeason}. Specify a value to change it.", LogLevel.Info); - return; - } + monitor.Log($"The current season is {Game1.currentSeason}. Specify a value to change it.", LogLevel.Info); + return; + } - // parse arguments - if (!args.TryGet(0, "season", out string? season, oneOf: this.ValidSeasons)) - return; + // parse arguments + if (!args.TryGet(0, "season", out string? season, oneOf: this.ValidSeasons)) + return; - // handle - Game1.currentSeason = season.ToLower(); - Game1.setGraphicsForSeason(); - Game1.stats.DaysPlayed = (uint)SDate.Now().DaysSinceStart; - monitor.Log($"OK, the date is now {Game1.currentSeason} {Game1.dayOfMonth}.", LogLevel.Info); - } + // handle + Game1.currentSeason = season.ToLower(); + Game1.setGraphicsForSeason(); + Game1.stats.DaysPlayed = (uint)SDate.Now().DaysSinceStart; + monitor.Log($"OK, the date is now {Game1.currentSeason} {Game1.dayOfMonth}.", LogLevel.Info); } } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetTimeCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetTimeCommand.cs index 8c4458dd8..9d6366207 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetTimeCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetTimeCommand.cs @@ -3,75 +3,74 @@ using Microsoft.Xna.Framework; using StardewValley; -namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World; + +/// A command which sets the current time. +[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] +internal class SetTimeCommand : ConsoleCommand { - /// A command which sets the current time. - [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] - internal class SetTimeCommand : ConsoleCommand - { - /********* - ** Public methods - *********/ - /// Construct an instance. - public SetTimeCommand() - : base("world_settime", "Sets the time to the specified value.\n\nUsage: world_settime \n- value: the target time in military time (like 0600 for 6am and 1800 for 6pm).") { } + /********* + ** Public methods + *********/ + /// Construct an instance. + public SetTimeCommand() + : base("world_settime", "Sets the time to the specified value.\n\nUsage: world_settime \n- value: the target time in military time (like 0600 for 6am and 1800 for 6pm).") { } - /// Handle the command. - /// Writes messages to the console and log file. - /// The command name. - /// The command arguments. - public override void Handle(IMonitor monitor, string command, ArgumentParser args) + /// Handle the command. + /// Writes messages to the console and log file. + /// The command name. + /// The command arguments. + public override void Handle(IMonitor monitor, string command, ArgumentParser args) + { + // no-argument mode + if (!args.Any()) { - // no-argument mode - if (!args.Any()) - { - monitor.Log($"The current time is {Game1.timeOfDay}. Specify a value to change it.", LogLevel.Info); - return; - } + monitor.Log($"The current time is {Game1.timeOfDay}. Specify a value to change it.", LogLevel.Info); + return; + } - // parse arguments - if (!args.TryGetInt(0, "time", out int time, min: 600, max: 2600)) - return; + // parse arguments + if (!args.TryGetInt(0, "time", out int time, min: 600, max: 2600)) + return; - // handle - this.SafelySetTime(time); - FreezeTimeCommand.FrozenTime = Game1.timeOfDay; - monitor.Log($"OK, the time is now {Game1.timeOfDay.ToString().PadLeft(4, '0')}.", LogLevel.Info); - } + // handle + this.SafelySetTime(time); + FreezeTimeCommand.FrozenTime = Game1.timeOfDay; + monitor.Log($"OK, the time is now {Game1.timeOfDay.ToString().PadLeft(4, '0')}.", LogLevel.Info); + } - /********* - ** Private methods - *********/ - /// Safely transition to the given time, allowing NPCs to update their schedule. - /// The time of day. - private void SafelySetTime(int time) + /********* + ** Private methods + *********/ + /// Safely transition to the given time, allowing NPCs to update their schedule. + /// The time of day. + private void SafelySetTime(int time) + { + // transition to new time + int intervals = Utility.CalculateMinutesBetweenTimes(Game1.timeOfDay, time) / 10; + if (intervals > 0) { - // transition to new time - int intervals = Utility.CalculateMinutesBetweenTimes(Game1.timeOfDay, time) / 10; - if (intervals > 0) - { - for (int i = 0; i < intervals; i++) - Game1.performTenMinuteClockUpdate(); - } - else if (intervals < 0) + for (int i = 0; i < intervals; i++) + Game1.performTenMinuteClockUpdate(); + } + else if (intervals < 0) + { + for (int i = 0; i > intervals; i--) { - for (int i = 0; i > intervals; i--) - { - Game1.timeOfDay = Utility.ModifyTime(Game1.timeOfDay, -20); // offset 20 mins so game updates to next interval - Game1.performTenMinuteClockUpdate(); - } + Game1.timeOfDay = Utility.ModifyTime(Game1.timeOfDay, -20); // offset 20 mins so game updates to next interval + Game1.performTenMinuteClockUpdate(); } + } - // reset ambient light - // White is the default non-raining color. If it's raining or dark out, UpdateGameClock - // below will update it automatically. - Game1.outdoorLight = Color.White; - Game1.ambientLight = Color.White; + // reset ambient light + // White is the default non-raining color. If it's raining or dark out, UpdateGameClock + // below will update it automatically. + Game1.outdoorLight = Color.White; + Game1.ambientLight = Color.White; - // run clock update (to correct lighting, etc) - Game1.gameTimeInterval = 0; - Game1.UpdateGameClock(Game1.currentGameTime); - } + // run clock update (to correct lighting, etc) + Game1.gameTimeInterval = 0; + Game1.UpdateGameClock(Game1.currentGameTime); } } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetYearCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetYearCommand.cs index a666a6347..544e39deb 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetYearCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetYearCommand.cs @@ -3,40 +3,39 @@ using StardewModdingAPI.Utilities; using StardewValley; -namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World; + +/// A command which sets the current year. +[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] +internal class SetYearCommand : ConsoleCommand { - /// A command which sets the current year. - [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] - internal class SetYearCommand : ConsoleCommand - { - /********* - ** Public methods - *********/ - /// Construct an instance. - public SetYearCommand() - : base("world_setyear", "Sets the year to the specified value.\n\nUsage: world_setyear \n- year: the target year (a number starting from 1).") { } + /********* + ** Public methods + *********/ + /// Construct an instance. + public SetYearCommand() + : base("world_setyear", "Sets the year to the specified value.\n\nUsage: world_setyear \n- year: the target year (a number starting from 1).") { } - /// Handle the command. - /// Writes messages to the console and log file. - /// The command name. - /// The command arguments. - public override void Handle(IMonitor monitor, string command, ArgumentParser args) + /// Handle the command. + /// Writes messages to the console and log file. + /// The command name. + /// The command arguments. + public override void Handle(IMonitor monitor, string command, ArgumentParser args) + { + // no-argument mode + if (!args.Any()) { - // no-argument mode - if (!args.Any()) - { - monitor.Log($"The current year is {Game1.year}. Specify a value to change the year.", LogLevel.Info); - return; - } + monitor.Log($"The current year is {Game1.year}. Specify a value to change the year.", LogLevel.Info); + return; + } - // parse arguments - if (!args.TryGetInt(0, "year", out int year, min: 1)) - return; + // parse arguments + if (!args.TryGetInt(0, "year", out int year, min: 1)) + return; - // handle - Game1.year = year; - Game1.stats.DaysPlayed = (uint)SDate.Now().DaysSinceStart; - monitor.Log($"OK, the year is now {Game1.year}.", LogLevel.Info); - } + // handle + Game1.year = year; + Game1.stats.DaysPlayed = (uint)SDate.Now().DaysSinceStart; + monitor.Log($"OK, the year is now {Game1.year}.", LogLevel.Info); } } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs index ab915d0fb..f37d97704 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs @@ -4,285 +4,264 @@ using System.Linq; using Microsoft.Xna.Framework.Content; using StardewValley; -using StardewValley.GameData.FishPonds; using StardewValley.ItemTypeDefinitions; using StardewValley.Objects; using SObject = StardewValley.Object; -namespace StardewModdingAPI.Mods.ConsoleCommands.Framework +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework; + +/// Provides methods for searching and constructing items. +internal class ItemRepository { - /// Provides methods for searching and constructing items. - internal class ItemRepository + /********* + ** Public methods + *********/ + /// Get all spawnable items. + /// Only include items for the given . + /// Whether to include flavored variants like "Sunflower Honey". + [SuppressMessage("ReSharper", "AccessToModifiedClosure", Justification = $"{nameof(ItemRepository.TryCreate)} invokes the lambda immediately.")] + public IEnumerable GetAll(string? onlyType = null, bool includeVariants = true) { - /********* - ** Public methods - *********/ - /// Get all spawnable items. - /// Only include items for the given . - /// Whether to include flavored variants like "Sunflower Honey". - [SuppressMessage("ReSharper", "AccessToModifiedClosure", Justification = $"{nameof(ItemRepository.TryCreate)} invokes the lambda immediately.")] - public IEnumerable GetAll(string? onlyType = null, bool includeVariants = true) + // + // + // Be careful about closure variable capture here! + // + // SearchableItem stores the Func to create new instances later. Loop variables passed into the + // function will be captured, so every func in the loop will use the value from the last iteration. Use the + // TryCreate(type, id, entity => item) form to avoid the issue, or create a local variable to pass in. + // + // + + IEnumerable GetAllRaw() { - // - // - // Be careful about closure variable capture here! - // - // SearchableItem stores the Func to create new instances later. Loop variables passed into the - // function will be captured, so every func in the loop will use the value from the last iteration. Use the - // TryCreate(type, id, entity => item) form to avoid the issue, or create a local variable to pass in. - // - // - - IEnumerable GetAllRaw() + // get from item data definitions + foreach (IItemDataDefinition itemType in ItemRegistry.ItemTypes) { - // get from item data definitions - foreach (IItemDataDefinition itemType in ItemRegistry.ItemTypes) + if (onlyType != null && itemType.Identifier != onlyType) + continue; + + switch (itemType.Identifier) { - if (onlyType != null && itemType.Identifier != onlyType) - continue; + // objects + case "(O)": + { + ObjectDataDefinition objectDataDefinition = (ObjectDataDefinition)ItemRegistry.GetTypeDefinition(ItemRegistry.type_object); - switch (itemType.Identifier) - { - // objects - case "(O)": + foreach (string id in itemType.GetAllIds()) { - ObjectDataDefinition objectDataDefinition = (ObjectDataDefinition)ItemRegistry.GetTypeDefinition(ItemRegistry.type_object); + // base item + SearchableItem? result = this.TryCreate(itemType.Identifier, id, p => ItemRegistry.Create(itemType.Identifier + p.Id)); - foreach (string id in itemType.GetAllIds()) - { - // base item - SearchableItem? result = this.TryCreate(itemType.Identifier, id, p => ItemRegistry.Create(itemType.Identifier + p.Id)); + // ring + if (result?.Item is Ring) + yield return result; - // ring - if (result?.Item is Ring) - yield return result; + // journal scraps + else if (result?.QualifiedItemId == "(O)842") + { + foreach (SearchableItem? journalScrap in this.GetSecretNotes(itemType, isJournalScrap: true)) + yield return journalScrap; + } - // journal scraps - else if (result?.QualifiedItemId == "(O)842") - { - foreach (SearchableItem? journalScrap in this.GetSecretNotes(itemType, isJournalScrap: true)) - yield return journalScrap; - } + // secret notes + else if (result?.QualifiedItemId == "(O)79") + { + foreach (SearchableItem? secretNote in this.GetSecretNotes(itemType, isJournalScrap: false)) + yield return secretNote; + } - // secret notes - else if (result?.QualifiedItemId == "(O)79") + // object + else + { + switch (result?.QualifiedItemId) { - foreach (SearchableItem? secretNote in this.GetSecretNotes(itemType, isJournalScrap: false)) - yield return secretNote; + // honey should be "Wild Honey" when there's no ingredient, instead of the base Honey item + case "(O)340": + yield return this.TryCreate(itemType.Identifier, result.Id, _ => objectDataDefinition.CreateFlavoredHoney(null)); + break; + + // don't return placeholder items + case "(O)DriedFruit": + case "(O)DriedMushrooms": + case "(O)SmokedFish": + case "(O)SpecificBait": + break; + + default: + if (result != null) + yield return result; + break; } - // object - else + if (includeVariants) { - yield return result?.QualifiedItemId == "(O)340" - ? this.TryCreate(itemType.Identifier, result.Id, _ => objectDataDefinition.CreateFlavoredHoney(null)) // game creates "Wild Honey" when there's no ingredient, instead of the base Honey item - : result; - - if (includeVariants) - { - foreach (SearchableItem? variant in this.GetFlavoredObjectVariants(objectDataDefinition, result?.Item as SObject, itemType)) - yield return variant; - } + foreach (SearchableItem? variant in this.GetFlavoredObjectVariants(objectDataDefinition, result?.Item as SObject, itemType)) + yield return variant; } } } - break; + } + break; - // no special handling needed - default: - foreach (string id in itemType.GetAllIds()) - yield return this.TryCreate(itemType.Identifier, id, p => ItemRegistry.Create(itemType.Identifier + p.Id)); - break; - } + // no special handling needed + default: + foreach (string id in itemType.GetAllIds()) + yield return this.TryCreate(itemType.Identifier, id, p => ItemRegistry.Create(itemType.Identifier + p.Id)); + break; } + } + } - // wallpapers - if (onlyType is null or "(WP)") - { - for (int id = 0; id < 112; id++) - yield return this.TryCreate("(WP)", id.ToString(), p => new Wallpaper(int.Parse(p.Id)) { Category = SObject.furnitureCategory }); - } + return ( + from item in GetAllRaw() + where item != null + select item + ); + } - // flooring - if (onlyType is null or "(FL)") - { - for (int id = 0; id < 56; id++) - yield return this.TryCreate("(FL)", id.ToString(), p => new Wallpaper(int.Parse(p.Id), isFloor: true) { Category = SObject.furnitureCategory }); - } - } - return ( - from item in GetAllRaw() - where item != null - select item + /********* + ** Private methods + *********/ + /// Get the individual secret note or journal scrap items. + /// The object data definition. + /// Whether to get journal scraps. + /// Derived from . + private IEnumerable GetSecretNotes(IItemDataDefinition itemType, bool isJournalScrap) + { + // get base item ID + string baseId = isJournalScrap ? "842" : "79"; + + // get secret note IDs + var ids = this + .TryLoad(() => DataLoader.SecretNotes(Game1.content)) + .Keys + .Where(isJournalScrap + ? id => (id >= GameLocation.JOURNAL_INDEX) + : id => (id < GameLocation.JOURNAL_INDEX) + ) + .Select(isJournalScrap + ? id => (id - GameLocation.JOURNAL_INDEX) + : id => id ); - } - - /********* - ** Private methods - *********/ - /// Get the individual secret note or journal scrap items. - /// The object data definition. - /// Whether to get journal scraps. - /// Derived from . - private IEnumerable GetSecretNotes(IItemDataDefinition itemType, bool isJournalScrap) + // build items + foreach (int i in ids) { - // get base item ID - string baseId = isJournalScrap ? "842" : "79"; - - // get secret note IDs - var ids = this - .TryLoad(() => DataLoader.SecretNotes(Game1.content)) - .Keys - .Where(isJournalScrap - ? id => (id >= GameLocation.JOURNAL_INDEX) - : id => (id < GameLocation.JOURNAL_INDEX) - ) - .Select(isJournalScrap - ? id => (id - GameLocation.JOURNAL_INDEX) - : id => id - ); - - // build items - foreach (int i in ids) - { - int id = i; // avoid closure capture + int id = i; // avoid closure capture - yield return this.TryCreate(itemType.Identifier, $"{baseId}/{id}", _ => - { - Item note = ItemRegistry.Create(itemType.Identifier + baseId); - note.Name = $"{note.Name} #{id}"; - return note; - }); - } + yield return this.TryCreate(itemType.Identifier, $"{baseId}/{id}", _ => + { + Item note = ItemRegistry.Create(itemType.Identifier + baseId); + note.Name = $"{note.Name} #{id}"; + return note; + }); } + } - /// Get flavored variants of a base item (like Blueberry Wine for Blueberry), if any. - /// The item data definition for object items. - /// A sample of the base item. - /// The object data definition. - private IEnumerable GetFlavoredObjectVariants(ObjectDataDefinition objectDataDefinition, SObject? item, IItemDataDefinition itemType) - { - if (item is null) - yield break; + /// Get flavored variants of a base item (like Blueberry Wine for Blueberry), if any. + /// The item data definition for object items. + /// A sample of the base item. + /// The object data definition. + private IEnumerable GetFlavoredObjectVariants(ObjectDataDefinition objectDataDefinition, SObject? item, IItemDataDefinition itemType) + { + if (item is null) + yield break; - string id = item.ItemId; + string id = item.ItemId; - // by category - switch (item.Category) - { - // fruit products - case SObject.FruitsCategory: - yield return this.TryCreate(itemType.Identifier, $"348/{id}", _ => objectDataDefinition.CreateFlavoredWine(item)); - yield return this.TryCreate(itemType.Identifier, $"344/{id}", _ => objectDataDefinition.CreateFlavoredJelly(item)); - break; - - // vegetable products - case SObject.VegetableCategory: - yield return this.TryCreate(itemType.Identifier, $"350/{id}", _ => objectDataDefinition.CreateFlavoredJuice(item)); - yield return this.TryCreate(itemType.Identifier, $"342/{id}", _ => objectDataDefinition.CreateFlavoredPickle(item)); - break; - - // flower honey - case SObject.flowersCategory: - yield return this.TryCreate(itemType.Identifier, $"340/{id}", _ => objectDataDefinition.CreateFlavoredHoney(item)); - break; - - // roe and aged roe (derived from FishPond.GetFishProduce) - case SObject.sellAtFishShopCategory when item.QualifiedItemId == "(O)812": - { - this.GetRoeContextTagLookups(out HashSet simpleTags, out List> complexTags); - - foreach (string key in Game1.objectData.Keys) - { - // get input - SObject? input = this.TryCreate(itemType.Identifier, key, p => new SObject(p.Id, 1))?.Item as SObject; - if (input == null) - continue; - - HashSet inputTags = input.GetContextTags(); - if (!inputTags.Any()) - continue; - - // check if roe-producing fish - if (!inputTags.Any(tag => simpleTags.Contains(tag)) && !complexTags.Any(set => set.All(tag => input.HasContextTag(tag)))) - continue; - - // create roe - SearchableItem? roe = this.TryCreate(itemType.Identifier, $"812/{input.ItemId}", _ => objectDataDefinition.CreateFlavoredRoe(input)); - yield return roe; - - // create aged roe - if (roe?.Item is SObject roeObj && input.QualifiedItemId != "(O)698") // skip aged sturgeon roe (which is a separate caviar item) - yield return this.TryCreate(itemType.Identifier, $"447/{input.ItemId}", _ => objectDataDefinition.CreateFlavoredAgedRoe(roeObj)); - } - } - break; - } + // by category + switch (item.Category) + { + // fish + case SObject.FishCategory: + yield return this.TryCreate(itemType.Identifier, $"SmokedFish/{id}", _ => objectDataDefinition.CreateFlavoredSmokedFish(item)); + yield return this.TryCreate(itemType.Identifier, $"SpecificBait/{id}", _ => objectDataDefinition.CreateFlavoredBait(item)); + break; + + // fruit products + case SObject.FruitsCategory: + yield return this.TryCreate(itemType.Identifier, $"348/{id}", _ => objectDataDefinition.CreateFlavoredWine(item)); + yield return this.TryCreate(itemType.Identifier, $"344/{id}", _ => objectDataDefinition.CreateFlavoredJelly(item)); + if (item.QualifiedItemId != "(O)398") // raisins are their own item + yield return this.TryCreate(itemType.Identifier, $"398/{id}", _ => objectDataDefinition.CreateFlavoredDriedFruit(item)); + break; + + // greens + case SObject.GreensCategory: + yield return this.TryCreate(itemType.Identifier, $"342/{id}", _ => objectDataDefinition.CreateFlavoredPickle(item)); + break; - // by context tag - if (item.HasContextTag("preserves_pickle") && item.Category != SObject.VegetableCategory) + // vegetable products + case SObject.VegetableCategory: + yield return this.TryCreate(itemType.Identifier, $"350/{id}", _ => objectDataDefinition.CreateFlavoredJuice(item)); yield return this.TryCreate(itemType.Identifier, $"342/{id}", _ => objectDataDefinition.CreateFlavoredPickle(item)); + break; + + // flower honey + case SObject.flowersCategory: + yield return this.TryCreate(itemType.Identifier, $"340/{id}", _ => objectDataDefinition.CreateFlavoredHoney(item)); + break; } - /// Get optimized lookups to match items which produce roe in a fish pond. - /// A lookup of simple singular tags which match a roe-producing fish. - /// A list of tag sets which match roe-producing fish. - private void GetRoeContextTagLookups(out HashSet simpleTags, out List> complexTags) + // by context tag { - simpleTags = new HashSet(); - complexTags = new List>(); - - foreach (FishPondData data in this.TryLoad(() => DataLoader.FishPondData(Game1.content))) + // roe + aged roe + if (item.HasContextTag("fish_has_roe")) { - if (data.ProducedItems.All(p => p.ItemId is not ("812" or "(O)812"))) - continue; // doesn't produce roe + SearchableItem? roe = this.TryCreate(itemType.Identifier, $"812/{item.ItemId}", _ => objectDataDefinition.CreateFlavoredRoe(item)); + yield return roe; - if (data.RequiredTags.Count == 1 && !data.RequiredTags[0].StartsWith("!")) - simpleTags.Add(data.RequiredTags[0]); - else - complexTags.Add(data.RequiredTags); + if (roe?.Item is SObject roeObj && item.QualifiedItemId != "(O)698") // skip aged sturgeon roe (which is a separate caviar item) + yield return this.TryCreate(itemType.Identifier, $"447/{item.ItemId}", _ => objectDataDefinition.CreateFlavoredAgedRoe(roeObj)); } + + // pickles + if (item.HasContextTag("preserves_pickle") && item.Category is not (SObject.GreensCategory or SObject.VegetableCategory)) + yield return this.TryCreate(itemType.Identifier, $"342/{id}", _ => objectDataDefinition.CreateFlavoredPickle(item)); + + // dried mushrooms + if (item.HasContextTag("edible_mushroom")) + yield return this.TryCreate(itemType.Identifier, $"DriedMushrooms/{id}", _ => objectDataDefinition.CreateFlavoredDriedMushroom(item)); } + } - /// Try to load a data asset, and return empty data if it's invalid. - /// The asset type. - /// A callback which loads the asset. - private TAsset TryLoad(Func load) - where TAsset : new() + /// Try to load a data asset, and return empty data if it's invalid. + /// The asset type. + /// A callback which loads the asset. + private TAsset TryLoad(Func load) + where TAsset : new() + { + try { - try - { - return load(); - } - catch (ContentLoadException) - { - // generally due to a player incorrectly replacing a data file with an XNB mod - return new TAsset(); - } + return load(); } + catch (ContentLoadException) + { + // generally due to a player incorrectly replacing a data file with an XNB mod + return new TAsset(); + } + } - /// Create a searchable item if valid. - /// The item type. - /// The locally unique item key. - /// Create an item instance. - private SearchableItem? TryCreate(string type, string key, Func createItem) + /// Create a searchable item if valid. + /// The item type. + /// The locally unique item key. + /// Create an item instance. + private SearchableItem? TryCreate(string type, string key, Func createItem) + { + try { - try - { - SearchableItem item = new SearchableItem(type, key, createItem); - item.Item.getDescription(); // force-load item data, so it crashes here if it's invalid + SearchableItem item = new SearchableItem(type, key, createItem); + item.Item.getDescription(); // force-load item data, so it crashes here if it's invalid - if (item.Item.Name is null or "Error Item") - return null; + if (item.Item.Name is null or "Error Item") + return null; - return item; - } - catch - { - return null; // if some item data is invalid, just don't include it - } + return item; + } + catch + { + return null; // if some item data is invalid, just don't include it } } } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/SearchableItem.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/SearchableItem.cs index a931d2065..5de342bd7 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/SearchableItem.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/SearchableItem.cs @@ -2,68 +2,67 @@ using StardewValley; using StardewValley.ItemTypeDefinitions; -namespace StardewModdingAPI.Mods.ConsoleCommands.Framework +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework; + +/// A game item with metadata. +internal class SearchableItem { - /// A game item with metadata. - internal class SearchableItem - { - /********* - ** Accessors - *********/ - /// The value for the item type. - public string Type { get; } + /********* + ** Accessors + *********/ + /// The value for the item type. + public string Type { get; } - /// A sample item instance. - public Item Item { get; } + /// A sample item instance. + public Item Item { get; } - /// Create an item instance. - public Func CreateItem { get; } + /// Create an item instance. + public Func CreateItem { get; } - /// The unqualified item ID. - public string Id { get; } + /// The unqualified item ID. + public string Id { get; } - /// The qualified item ID. - public string QualifiedItemId { get; } + /// The qualified item ID. + public string QualifiedItemId { get; } - /// The item's default name. - public string Name => this.Item.Name; + /// The item's default name. + public string Name => this.Item.Name; - /// The item's display name for the current language. - public string DisplayName => this.Item.DisplayName; + /// The item's display name for the current language. + public string DisplayName => this.Item.DisplayName; - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The item type. - /// The unqualified item ID. - /// Create an item instance. - public SearchableItem(string type, string id, Func createItem) - { - this.Type = type; - this.Id = id; - this.QualifiedItemId = this.Type + this.Id; - this.CreateItem = () => createItem(this); - this.Item = createItem(this); - } + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The item type. + /// The unqualified item ID. + /// Create an item instance. + public SearchableItem(string type, string id, Func createItem) + { + this.Type = type; + this.Id = id; + this.QualifiedItemId = this.Type + this.Id; + this.CreateItem = () => createItem(this); + this.Item = createItem(this); + } - /// Get whether the item name contains a case-insensitive substring. - /// The substring to find. - public bool NameContains(string substring) - { - return - this.Name.IndexOf(substring, StringComparison.OrdinalIgnoreCase) != -1 - || this.DisplayName.IndexOf(substring, StringComparison.OrdinalIgnoreCase) != -1; - } + /// Get whether the item name contains a case-insensitive substring. + /// The substring to find. + public bool NameContains(string substring) + { + return + this.Name.IndexOf(substring, StringComparison.OrdinalIgnoreCase) != -1 + || this.DisplayName.IndexOf(substring, StringComparison.OrdinalIgnoreCase) != -1; + } - /// Get whether the item name is exactly equal to a case-insensitive string. - /// The substring to find. - public bool NameEquivalentTo(string name) - { - return - this.Name.Equals(name, StringComparison.OrdinalIgnoreCase) - || this.DisplayName.Equals(name, StringComparison.OrdinalIgnoreCase); - } + /// Get whether the item name is exactly equal to a case-insensitive string. + /// The substring to find. + public bool NameEquivalentTo(string name) + { + return + this.Name.Equals(name, StringComparison.OrdinalIgnoreCase) + || this.DisplayName.Equals(name, StringComparison.OrdinalIgnoreCase); } } diff --git a/src/SMAPI.Mods.ConsoleCommands/ModEntry.cs b/src/SMAPI.Mods.ConsoleCommands/ModEntry.cs index 3fdea370a..82f832733 100644 --- a/src/SMAPI.Mods.ConsoleCommands/ModEntry.cs +++ b/src/SMAPI.Mods.ConsoleCommands/ModEntry.cs @@ -3,71 +3,70 @@ using System.Linq; using StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands; -namespace StardewModdingAPI.Mods.ConsoleCommands +namespace StardewModdingAPI.Mods.ConsoleCommands; + +/// The main entry point for the mod. +public class ModEntry : Mod { - /// The main entry point for the mod. - public class ModEntry : Mod - { - /********* - ** Fields - *********/ - /// The commands to handle. - private IConsoleCommand[] Commands = null!; + /********* + ** Fields + *********/ + /// The commands to handle. + private IConsoleCommand[] Commands = null!; - /// The commands which may need to handle update ticks. - private IConsoleCommand[] UpdateHandlers = null!; + /// The commands which may need to handle update ticks. + private IConsoleCommand[] UpdateHandlers = null!; - /********* - ** Public methods - *********/ - /// The mod entry point, called after the mod is first loaded. - /// Provides simplified APIs for writing mods. - public override void Entry(IModHelper helper) - { - // register commands - this.Commands = this.ScanForCommands().ToArray(); - foreach (IConsoleCommand command in this.Commands) - helper.ConsoleCommands.Add(command.Name, command.Description, (name, args) => this.HandleCommand(command, name, args)); + /********* + ** Public methods + *********/ + /// The mod entry point, called after the mod is first loaded. + /// Provides simplified APIs for writing mods. + public override void Entry(IModHelper helper) + { + // register commands + this.Commands = this.ScanForCommands().ToArray(); + foreach (IConsoleCommand command in this.Commands) + helper.ConsoleCommands.Add(command.Name, command.Description, (name, args) => this.HandleCommand(command, name, args)); - // cache commands - this.UpdateHandlers = this.Commands.Where(p => p.MayNeedUpdate).ToArray(); + // cache commands + this.UpdateHandlers = this.Commands.Where(p => p.MayNeedUpdate).ToArray(); - // hook events - helper.Events.GameLoop.UpdateTicked += this.OnUpdateTicked; - } + // hook events + helper.Events.GameLoop.UpdateTicked += this.OnUpdateTicked; + } - /********* - ** Private methods - *********/ - /// The method invoked when the game updates its state. - /// The event sender. - /// The event arguments. - private void OnUpdateTicked(object? sender, EventArgs e) - { - foreach (IConsoleCommand command in this.UpdateHandlers) - command.OnUpdated(this.Monitor); - } + /********* + ** Private methods + *********/ + /// The method invoked when the game updates its state. + /// The event sender. + /// The event arguments. + private void OnUpdateTicked(object? sender, EventArgs e) + { + foreach (IConsoleCommand command in this.UpdateHandlers) + command.OnUpdated(this.Monitor); + } - /// Handle a console command. - /// The command to invoke. - /// The command name specified by the user. - /// The command arguments. - private void HandleCommand(IConsoleCommand command, string commandName, string[] args) - { - ArgumentParser argParser = new(commandName, args, this.Monitor); - command.Handle(this.Monitor, commandName, argParser); - } + /// Handle a console command. + /// The command to invoke. + /// The command name specified by the user. + /// The command arguments. + private void HandleCommand(IConsoleCommand command, string commandName, string[] args) + { + ArgumentParser argParser = new(commandName, args, this.Monitor); + command.Handle(this.Monitor, commandName, argParser); + } - /// Find all commands in the assembly. - private IEnumerable ScanForCommands() - { - return ( - from type in this.GetType().Assembly.GetTypes() - where !type.IsAbstract && typeof(IConsoleCommand).IsAssignableFrom(type) - select (IConsoleCommand)Activator.CreateInstance(type)! - ); - } + /// Find all commands in the assembly. + private IEnumerable ScanForCommands() + { + return ( + from type in this.GetType().Assembly.GetTypes() + where !type.IsAbstract && typeof(IConsoleCommand).IsAssignableFrom(type) + select (IConsoleCommand)Activator.CreateInstance(type)! + ); } } diff --git a/src/SMAPI.Mods.ConsoleCommands/manifest.json b/src/SMAPI.Mods.ConsoleCommands/manifest.json index 9cb9e9936..711ba3031 100644 --- a/src/SMAPI.Mods.ConsoleCommands/manifest.json +++ b/src/SMAPI.Mods.ConsoleCommands/manifest.json @@ -1,9 +1,9 @@ { "Name": "Console Commands", "Author": "SMAPI", - "Version": "4.0.8", + "Version": "4.1.0", "Description": "Adds SMAPI console commands that let you manipulate the game.", "UniqueID": "SMAPI.ConsoleCommands", "EntryDll": "ConsoleCommands.dll", - "MinimumApiVersion": "4.0.8" + "MinimumApiVersion": "4.1.0" } diff --git a/src/SMAPI.Mods.SaveBackup/ModEntry.cs b/src/SMAPI.Mods.SaveBackup/ModEntry.cs index 8a22a5f38..b99074947 100644 --- a/src/SMAPI.Mods.SaveBackup/ModEntry.cs +++ b/src/SMAPI.Mods.SaveBackup/ModEntry.cs @@ -6,205 +6,204 @@ using System.Threading.Tasks; using StardewValley; -namespace StardewModdingAPI.Mods.SaveBackup +namespace StardewModdingAPI.Mods.SaveBackup; + +/// The main entry point for the mod. +public class ModEntry : Mod { - /// The main entry point for the mod. - public class ModEntry : Mod - { - /********* - ** Fields - *********/ - /// The number of backups to keep. - private readonly int BackupsToKeep = 10; + /********* + ** Fields + *********/ + /// The number of backups to keep. + private readonly int BackupsToKeep = 10; - /// The absolute path to the folder in which to store save backups. - private readonly string BackupFolder = Path.Combine(Constants.GamePath, "save-backups"); + /// The absolute path to the folder in which to store save backups. + private readonly string BackupFolder = Path.Combine(Constants.GamePath, "save-backups"); - /// A unique label for the save backup to create. - private readonly string BackupLabel = $"{DateTime.UtcNow:yyyy-MM-dd} - SMAPI {Constants.ApiVersion} with Stardew Valley {Game1.version}"; + /// A unique label for the save backup to create. + private readonly string BackupLabel = $"{DateTime.UtcNow:yyyy-MM-dd} - SMAPI {Constants.ApiVersion} with Stardew Valley {Game1.version}"; - /// The name of the save archive to create. - private string FileName => $"{this.BackupLabel}.zip"; + /// The name of the save archive to create. + private string FileName => $"{this.BackupLabel}.zip"; - /********* - ** Public methods - *********/ - /// The mod entry point, called after the mod is first loaded. - /// Provides simplified APIs for writing mods. - public override void Entry(IModHelper helper) + /********* + ** Public methods + *********/ + /// The mod entry point, called after the mod is first loaded. + /// Provides simplified APIs for writing mods. + public override void Entry(IModHelper helper) + { + try { - try - { - // init backup folder - DirectoryInfo backupFolder = new(this.BackupFolder); - backupFolder.Create(); - - // back up & prune saves - Task - .Run(() => this.CreateBackup(backupFolder)) - .ContinueWith(_ => this.PruneBackups(backupFolder, this.BackupsToKeep)); - } - catch (Exception ex) - { - this.Monitor.Log($"Error backing up saves: {ex}", LogLevel.Error); - } + // init backup folder + DirectoryInfo backupFolder = new(this.BackupFolder); + backupFolder.Create(); + + // back up & prune saves + Task + .Run(() => this.CreateBackup(backupFolder)) + .ContinueWith(_ => this.PruneBackups(backupFolder, this.BackupsToKeep)); } + catch (Exception ex) + { + this.Monitor.Log($"Error backing up saves: {ex}", LogLevel.Error); + } + } - /********* - ** Private methods - *********/ - /// Back up the current saves. - /// The folder containing save backups. - private void CreateBackup(DirectoryInfo backupFolder) + /********* + ** Private methods + *********/ + /// Back up the current saves. + /// The folder containing save backups. + private void CreateBackup(DirectoryInfo backupFolder) + { + try { - try + // get target path + FileInfo targetFile = new(Path.Combine(backupFolder.FullName, this.FileName)); + DirectoryInfo fallbackDir = new(Path.Combine(backupFolder.FullName, this.BackupLabel)); + if (targetFile.Exists || fallbackDir.Exists) { - // get target path - FileInfo targetFile = new(Path.Combine(backupFolder.FullName, this.FileName)); - DirectoryInfo fallbackDir = new(Path.Combine(backupFolder.FullName, this.BackupLabel)); - if (targetFile.Exists || fallbackDir.Exists) - { - this.Monitor.Log("Already backed up today."); - return; - } + this.Monitor.Log("Already backed up today."); + return; + } - // copy saves to fallback directory (ignore non-save files/folders) - DirectoryInfo savesDir = new(Constants.SavesPath); - if (!this.RecursiveCopy(savesDir, fallbackDir, entry => this.MatchSaveFolders(savesDir, entry), copyRoot: false)) - { - this.Monitor.Log("No saves found."); - return; - } + // copy saves to fallback directory (ignore non-save files/folders) + DirectoryInfo savesDir = new(Constants.SavesPath); + if (!this.RecursiveCopy(savesDir, fallbackDir, entry => this.MatchSaveFolders(savesDir, entry), copyRoot: false)) + { + this.Monitor.Log("No saves found."); + return; + } - // compress backup if possible - if (!this.TryCompressDir(fallbackDir.FullName, targetFile, out Exception? compressError)) - { - this.Monitor.Log(Constants.TargetPlatform != GamePlatform.Android - ? $"Backed up to {fallbackDir.FullName}." // expected to fail on Android - : $"Backed up to {fallbackDir.FullName}. Couldn't compress backup:\n{compressError}" - ); - } - else - { - this.Monitor.Log($"Backed up to {targetFile.FullName}."); - fallbackDir.Delete(recursive: true); - } + // compress backup if possible + if (!this.TryCompressDir(fallbackDir.FullName, targetFile, out Exception? compressError)) + { + this.Monitor.Log(Constants.TargetPlatform != GamePlatform.Android + ? $"Backed up to {fallbackDir.FullName}." // expected to fail on Android + : $"Backed up to {fallbackDir.FullName}. Couldn't compress backup:\n{compressError}" + ); } - catch (Exception ex) + else { - this.Monitor.Log("Couldn't back up saves (see log file for details).", LogLevel.Warn); - this.Monitor.Log(ex.ToString()); + this.Monitor.Log($"Backed up to {targetFile.FullName}."); + fallbackDir.Delete(recursive: true); } } + catch (Exception ex) + { + this.Monitor.Log("Couldn't back up saves (see log file for details).", LogLevel.Warn); + this.Monitor.Log(ex.ToString()); + } + } - /// Remove old backups if we've exceeded the limit. - /// The folder containing save backups. - /// The number of backups to keep. - private void PruneBackups(DirectoryInfo backupFolder, int backupsToKeep) + /// Remove old backups if we've exceeded the limit. + /// The folder containing save backups. + /// The number of backups to keep. + private void PruneBackups(DirectoryInfo backupFolder, int backupsToKeep) + { + try { - try - { - var oldBackups = backupFolder - .GetFileSystemInfos() - .OrderByDescending(p => p.CreationTimeUtc) - .Skip(backupsToKeep); + var oldBackups = backupFolder + .GetFileSystemInfos() + .OrderByDescending(p => p.CreationTimeUtc) + .Skip(backupsToKeep); - foreach (FileSystemInfo entry in oldBackups) + foreach (FileSystemInfo entry in oldBackups) + { + try { - try - { - this.Monitor.Log($"Deleting {entry.Name}..."); - if (entry is DirectoryInfo folder) - folder.Delete(recursive: true); - else - entry.Delete(); - } - catch (Exception ex) - { - this.Monitor.Log($"Error deleting old save backup '{entry.Name}': {ex}", LogLevel.Error); - } + this.Monitor.Log($"Deleting {entry.Name}..."); + if (entry is DirectoryInfo folder) + folder.Delete(recursive: true); + else + entry.Delete(); + } + catch (Exception ex) + { + this.Monitor.Log($"Error deleting old save backup '{entry.Name}': {ex}", LogLevel.Error); } - } - catch (Exception ex) - { - this.Monitor.Log("Couldn't remove old backups (see log file for details).", LogLevel.Warn); - this.Monitor.Log(ex.ToString()); } } + catch (Exception ex) + { + this.Monitor.Log("Couldn't remove old backups (see log file for details).", LogLevel.Warn); + this.Monitor.Log(ex.ToString()); + } + } - /// Try to create a compressed zip file for a directory. - /// The directory path to zip. - /// The destination file to create. - /// The error which occurred trying to compress, if applicable. This is if compression isn't supported on this platform. - /// Returns whether compression succeeded. - private bool TryCompressDir(string sourcePath, FileInfo destination, [NotNullWhen(false)] out Exception? error) + /// Try to create a compressed zip file for a directory. + /// The directory path to zip. + /// The destination file to create. + /// The error which occurred trying to compress, if applicable. This is if compression isn't supported on this platform. + /// Returns whether compression succeeded. + private bool TryCompressDir(string sourcePath, FileInfo destination, [NotNullWhen(false)] out Exception? error) + { + try { - try - { - ZipFile.CreateFromDirectory(sourcePath, destination.FullName, CompressionLevel.Fastest, false); + ZipFile.CreateFromDirectory(sourcePath, destination.FullName, CompressionLevel.Fastest, false); - error = null; - return true; - } - catch (Exception ex) - { - error = ex; - return false; - } + error = null; + return true; } - - /// Recursively copy a directory or file. - /// The file or folder to copy. - /// The folder to copy into. - /// Whether to copy the root folder itself, or false to only copy its contents. - /// A filter which matches the files or directories to copy, or null to copy everything. - /// Derived from the SMAPI installer code. - /// Returns whether any files were copied. - private bool RecursiveCopy(FileSystemInfo source, DirectoryInfo targetFolder, Func? filter, bool copyRoot = true) + catch (Exception ex) { - if (!source.Exists || filter?.Invoke(source) == false) - return false; + error = ex; + return false; + } + } - bool anyCopied = false; + /// Recursively copy a directory or file. + /// The file or folder to copy. + /// The folder to copy into. + /// Whether to copy the root folder itself, or false to only copy its contents. + /// A filter which matches the files or directories to copy, or null to copy everything. + /// Derived from the SMAPI installer code. + /// Returns whether any files were copied. + private bool RecursiveCopy(FileSystemInfo source, DirectoryInfo targetFolder, Func? filter, bool copyRoot = true) + { + if (!source.Exists || filter?.Invoke(source) == false) + return false; - switch (source) - { - case FileInfo sourceFile: - targetFolder.Create(); - sourceFile.CopyTo(Path.Combine(targetFolder.FullName, sourceFile.Name)); - anyCopied = true; - break; - - case DirectoryInfo sourceDir: - DirectoryInfo targetSubfolder = copyRoot ? new DirectoryInfo(Path.Combine(targetFolder.FullName, sourceDir.Name)) : targetFolder; - foreach (var entry in sourceDir.EnumerateFileSystemInfos()) - anyCopied = this.RecursiveCopy(entry, targetSubfolder, filter) || anyCopied; - break; - - default: - throw new NotSupportedException($"Unknown filesystem info type '{source.GetType().FullName}'."); - } + bool anyCopied = false; - return anyCopied; + switch (source) + { + case FileInfo sourceFile: + targetFolder.Create(); + sourceFile.CopyTo(Path.Combine(targetFolder.FullName, sourceFile.Name)); + anyCopied = true; + break; + + case DirectoryInfo sourceDir: + DirectoryInfo targetSubfolder = copyRoot ? new DirectoryInfo(Path.Combine(targetFolder.FullName, sourceDir.Name)) : targetFolder; + foreach (var entry in sourceDir.EnumerateFileSystemInfos()) + anyCopied = this.RecursiveCopy(entry, targetSubfolder, filter) || anyCopied; + break; + + default: + throw new NotSupportedException($"Unknown filesystem info type '{source.GetType().FullName}'."); } - /// A copy filter which matches save folders. - /// The folder containing save folders. - /// The current entry to check under . - private bool MatchSaveFolders(DirectoryInfo savesFolder, FileSystemInfo entry) - { - // only need to filter top-level entries - string? parentPath = (entry as FileInfo)?.DirectoryName ?? (entry as DirectoryInfo)?.Parent?.FullName; - if (parentPath != savesFolder.FullName) - return true; + return anyCopied; + } + /// A copy filter which matches save folders. + /// The folder containing save folders. + /// The current entry to check under . + private bool MatchSaveFolders(DirectoryInfo savesFolder, FileSystemInfo entry) + { + // only need to filter top-level entries + string? parentPath = (entry as FileInfo)?.DirectoryName ?? (entry as DirectoryInfo)?.Parent?.FullName; + if (parentPath != savesFolder.FullName) + return true; - // match folders with Name_ID format - return - entry is DirectoryInfo - && ulong.TryParse(entry.Name.Split('_').Last(), out _); - } + + // match folders with Name_ID format + return + entry is DirectoryInfo + && ulong.TryParse(entry.Name.Split('_').Last(), out _); } } diff --git a/src/SMAPI.Mods.SaveBackup/manifest.json b/src/SMAPI.Mods.SaveBackup/manifest.json index 2bd6e053c..91848afa0 100644 --- a/src/SMAPI.Mods.SaveBackup/manifest.json +++ b/src/SMAPI.Mods.SaveBackup/manifest.json @@ -1,9 +1,9 @@ { "Name": "Save Backup", "Author": "SMAPI", - "Version": "4.0.8", + "Version": "4.1.0", "Description": "Automatically backs up all your saves once per day into its folder.", "UniqueID": "SMAPI.SaveBackup", "EntryDll": "SaveBackup.dll", - "MinimumApiVersion": "4.0.8" + "MinimumApiVersion": "4.1.0" } diff --git a/src/SMAPI.Tests.ModApiConsumer/ApiConsumer.cs b/src/SMAPI.Tests.ModApiConsumer/ApiConsumer.cs index ac7bd3388..2dd5da288 100644 --- a/src/SMAPI.Tests.ModApiConsumer/ApiConsumer.cs +++ b/src/SMAPI.Tests.ModApiConsumer/ApiConsumer.cs @@ -1,46 +1,45 @@ using System; using SMAPI.Tests.ModApiConsumer.Interfaces; -namespace SMAPI.Tests.ModApiConsumer +namespace SMAPI.Tests.ModApiConsumer; + +/// A simulated API consumer. +public class ApiConsumer { - /// A simulated API consumer. - public class ApiConsumer + /********* + ** Public methods + *********/ + /// Call the event field on the given API. + /// The API to call. + /// Get the number of times the event was called and the last value received. + public void UseEventField(ISimpleApi api, out Func<(int timesCalled, int actualValue)> getValues) { - /********* - ** Public methods - *********/ - /// Call the event field on the given API. - /// The API to call. - /// Get the number of times the event was called and the last value received. - public void UseEventField(ISimpleApi api, out Func<(int timesCalled, int actualValue)> getValues) + // act + int calls = 0; + int lastValue = -1; + api.OnEventRaised += (_, value) => { - // act - int calls = 0; - int lastValue = -1; - api.OnEventRaised += (_, value) => - { - calls++; - lastValue = value; - }; + calls++; + lastValue = value; + }; - getValues = () => (timesCalled: calls, actualValue: lastValue); - } + getValues = () => (timesCalled: calls, actualValue: lastValue); + } - /// Call the event property on the given API. - /// The API to call. - /// Get the number of times the event was called and the last value received. - public void UseEventProperty(ISimpleApi api, out Func<(int timesCalled, int actualValue)> getValues) + /// Call the event property on the given API. + /// The API to call. + /// Get the number of times the event was called and the last value received. + public void UseEventProperty(ISimpleApi api, out Func<(int timesCalled, int actualValue)> getValues) + { + // act + int calls = 0; + int lastValue = -1; + api.OnEventRaisedProperty += (_, value) => { - // act - int calls = 0; - int lastValue = -1; - api.OnEventRaisedProperty += (_, value) => - { - calls++; - lastValue = value; - }; + calls++; + lastValue = value; + }; - getValues = () => (timesCalled: calls, actualValue: lastValue); - } + getValues = () => (timesCalled: calls, actualValue: lastValue); } } diff --git a/src/SMAPI.Tests.ModApiConsumer/Interfaces/ISimpleApi.cs b/src/SMAPI.Tests.ModApiConsumer/Interfaces/ISimpleApi.cs index c99605e4f..6c51ebff4 100644 --- a/src/SMAPI.Tests.ModApiConsumer/Interfaces/ISimpleApi.cs +++ b/src/SMAPI.Tests.ModApiConsumer/Interfaces/ISimpleApi.cs @@ -3,81 +3,80 @@ using System.Reflection; using StardewModdingAPI.Utilities; -namespace SMAPI.Tests.ModApiConsumer.Interfaces +namespace SMAPI.Tests.ModApiConsumer.Interfaces; + +/// A mod-provided API which provides basic events, properties, and methods. +public interface ISimpleApi { - /// A mod-provided API which provides basic events, properties, and methods. - public interface ISimpleApi - { - /********* - ** Test interface - *********/ - /**** - ** Events - ****/ - /// A simple event field. - event EventHandler OnEventRaised; + /********* + ** Test interface + *********/ + /**** + ** Events + ****/ + /// A simple event field. + event EventHandler OnEventRaised; - /// A simple event property with custom add/remove logic. - event EventHandler OnEventRaisedProperty; + /// A simple event property with custom add/remove logic. + event EventHandler OnEventRaisedProperty; - /**** - ** Properties - ****/ - /// A simple numeric property. - int NumberProperty { get; set; } + /**** + ** Properties + ****/ + /// A simple numeric property. + int NumberProperty { get; set; } - /// A simple object property. - object ObjectProperty { get; set; } + /// A simple object property. + object ObjectProperty { get; set; } - /// A simple list property. - List ListProperty { get; set; } + /// A simple list property. + List ListProperty { get; set; } - /// A simple list property with an interface. - IList ListPropertyWithInterface { get; set; } + /// A simple list property with an interface. + IList ListPropertyWithInterface { get; set; } - /// A property with nested generics. - IDictionary> GenericsProperty { get; set; } + /// A property with nested generics. + IDictionary> GenericsProperty { get; set; } - /// A property using an enum available to both mods. - BindingFlags EnumProperty { get; set; } + /// A property using an enum available to both mods. + BindingFlags EnumProperty { get; set; } - /// A read-only property. - int GetterProperty { get; } + /// A read-only property. + int GetterProperty { get; } - /**** - ** Methods - ****/ - /// A simple method with no return value. - void GetNothing(); + /**** + ** Methods + ****/ + /// A simple method with no return value. + void GetNothing(); - /// A simple method which returns a number. - int GetInt(int value); + /// A simple method which returns a number. + int GetInt(int value); - /// A simple method which returns an object. - object GetObject(object value); + /// A simple method which returns an object. + object GetObject(object value); - /// A simple method which returns a list. - List GetList(string value); + /// A simple method which returns a list. + List GetList(string value); - /// A simple method which returns a list with an interface. - IList GetListWithInterface(string value); + /// A simple method which returns a list with an interface. + IList GetListWithInterface(string value); - /// A simple method which returns nested generics. - IDictionary> GetGenerics(string key, string value); + /// A simple method which returns nested generics. + IDictionary> GetGenerics(string key, string value); - /// A simple method which returns a lambda. - Func GetLambda(Func value); + /// A simple method which returns a lambda. + Func GetLambda(Func value); - /// A simple method which returns out parameters. - bool TryGetOutParameter(int inputNumber, out int outNumber, out string outString, out PerScreen outReference, out IDictionary> outComplexType); + /// A simple method which returns out parameters. + bool TryGetOutParameter(int inputNumber, out int outNumber, out string outString, out PerScreen outReference, out IDictionary> outComplexType); - /**** - ** Inherited members - ****/ - /// A property inherited from a base class. - public string InheritedProperty { get; set; } - } + /**** + ** Inherited members + ****/ + /// A property inherited from a base class. + public string InheritedProperty { get; set; } } diff --git a/src/SMAPI.Tests.ModApiProvider/Framework/BaseApi.cs b/src/SMAPI.Tests.ModApiProvider/Framework/BaseApi.cs index 77001e4cf..5f4e95767 100644 --- a/src/SMAPI.Tests.ModApiProvider/Framework/BaseApi.cs +++ b/src/SMAPI.Tests.ModApiProvider/Framework/BaseApi.cs @@ -1,12 +1,11 @@ -namespace SMAPI.Tests.ModApiProvider.Framework +namespace SMAPI.Tests.ModApiProvider.Framework; + +/// The base class for . +public class BaseApi { - /// The base class for . - public class BaseApi - { - /********* - ** Test interface - *********/ - /// A property inherited from a base class. - public string? InheritedProperty { get; set; } - } + /********* + ** Test interface + *********/ + /// A property inherited from a base class. + public string? InheritedProperty { get; set; } } diff --git a/src/SMAPI.Tests.ModApiProvider/Framework/SimpleApi.cs b/src/SMAPI.Tests.ModApiProvider/Framework/SimpleApi.cs index c8781da52..0869cd49f 100644 --- a/src/SMAPI.Tests.ModApiProvider/Framework/SimpleApi.cs +++ b/src/SMAPI.Tests.ModApiProvider/Framework/SimpleApi.cs @@ -5,121 +5,120 @@ using System.Reflection; using StardewModdingAPI.Utilities; -namespace SMAPI.Tests.ModApiProvider.Framework +namespace SMAPI.Tests.ModApiProvider.Framework; + +/// A mod-provided API which provides basic events, properties, and methods. +public class SimpleApi : BaseApi { - /// A mod-provided API which provides basic events, properties, and methods. - public class SimpleApi : BaseApi + /********* + ** Test interface + *********/ + /**** + ** Events + ****/ + /// A simple event field. + public event EventHandler? OnEventRaised; + + /// A simple event property with custom add/remove logic. + public event EventHandler OnEventRaisedProperty { - /********* - ** Test interface - *********/ - /**** - ** Events - ****/ - /// A simple event field. - public event EventHandler? OnEventRaised; - - /// A simple event property with custom add/remove logic. - public event EventHandler OnEventRaisedProperty - { - add => this.OnEventRaised += value; - remove => this.OnEventRaised -= value; - } + add => this.OnEventRaised += value; + remove => this.OnEventRaised -= value; + } - /**** - ** Properties - ****/ - /// A simple numeric property. - public int NumberProperty { get; set; } + /**** + ** Properties + ****/ + /// A simple numeric property. + public int NumberProperty { get; set; } - /// A simple object property. - public object? ObjectProperty { get; set; } + /// A simple object property. + public object? ObjectProperty { get; set; } - /// A simple list property. - public List? ListProperty { get; set; } + /// A simple list property. + public List? ListProperty { get; set; } - /// A simple list property with an interface. - public IList? ListPropertyWithInterface { get; set; } + /// A simple list property with an interface. + public IList? ListPropertyWithInterface { get; set; } - /// A property with nested generics. - public IDictionary>? GenericsProperty { get; set; } + /// A property with nested generics. + public IDictionary>? GenericsProperty { get; set; } - /// A property using an enum available to both mods. - public BindingFlags EnumProperty { get; set; } + /// A property using an enum available to both mods. + public BindingFlags EnumProperty { get; set; } - /// A read-only property. - public int GetterProperty => 42; + /// A read-only property. + public int GetterProperty => 42; - /**** - ** Methods - ****/ - /// A simple method with no return value. - public void GetNothing() { } + /**** + ** Methods + ****/ + /// A simple method with no return value. + public void GetNothing() { } - /// A simple method which returns a number. - public int GetInt(int value) - { - return value; - } + /// A simple method which returns a number. + public int GetInt(int value) + { + return value; + } - /// A simple method which returns an object. - public object GetObject(object value) - { - return value; - } + /// A simple method which returns an object. + public object GetObject(object value) + { + return value; + } - /// A simple method which returns a list. - public List GetList(string value) - { - return new() { value }; - } + /// A simple method which returns a list. + public List GetList(string value) + { + return new() { value }; + } - /// A simple method which returns a list with an interface. - public IList GetListWithInterface(string value) - { - return new List { value }; - } + /// A simple method which returns a list with an interface. + public IList GetListWithInterface(string value) + { + return new List { value }; + } - /// A simple method which returns nested generics. - public IDictionary> GetGenerics(string key, string value) - { - return new Dictionary> - { - [key] = new List { value } - }; - } - - /// A simple method which returns a lambda. - public Func GetLambda(Func value) + /// A simple method which returns nested generics. + public IDictionary> GetGenerics(string key, string value) + { + return new Dictionary> { - return value; - } + [key] = new List { value } + }; + } - /// A simple method which returns out parameters. - public bool TryGetOutParameter(int inputNumber, out int outNumber, out string outString, out PerScreen outReference, out IDictionary> outComplexType) - { - outNumber = inputNumber; - outString = inputNumber.ToString(); - outReference = new PerScreen(() => inputNumber); - outComplexType = new Dictionary> - { - [inputNumber] = new PerScreen(() => inputNumber) - }; - - return true; - } - - - /********* - ** Helper methods - *********/ - /// Raise the event. - /// The value to pass to the event. - public void RaiseEventField(int value) + /// A simple method which returns a lambda. + public Func GetLambda(Func value) + { + return value; + } + + /// A simple method which returns out parameters. + public bool TryGetOutParameter(int inputNumber, out int outNumber, out string outString, out PerScreen outReference, out IDictionary> outComplexType) + { + outNumber = inputNumber; + outString = inputNumber.ToString(); + outReference = new PerScreen(() => inputNumber); + outComplexType = new Dictionary> { - this.OnEventRaised?.Invoke(null, value); - } + [inputNumber] = new PerScreen(() => inputNumber) + }; + + return true; + } + + + /********* + ** Helper methods + *********/ + /// Raise the event. + /// The value to pass to the event. + public void RaiseEventField(int value) + { + this.OnEventRaised?.Invoke(null, value); } } diff --git a/src/SMAPI.Tests.ModApiProvider/ProviderMod.cs b/src/SMAPI.Tests.ModApiProvider/ProviderMod.cs index c36e1c6dd..a1f9cb645 100644 --- a/src/SMAPI.Tests.ModApiProvider/ProviderMod.cs +++ b/src/SMAPI.Tests.ModApiProvider/ProviderMod.cs @@ -2,37 +2,36 @@ using System.Reflection; using SMAPI.Tests.ModApiProvider.Framework; -namespace SMAPI.Tests.ModApiProvider +namespace SMAPI.Tests.ModApiProvider; + +/// A simulated mod instance. +public class ProviderMod { - /// A simulated mod instance. - public class ProviderMod - { - /// The underlying API instance. - private readonly SimpleApi Api = new(); + /// The underlying API instance. + private readonly SimpleApi Api = new(); - /// Get the mod API instance. - public object GetModApi() - { - return this.Api; - } + /// Get the mod API instance. + public object GetModApi() + { + return this.Api; + } - /// Raise the event. - /// The value to send as an event argument. - public void RaiseEvent(int value) - { - this.Api.RaiseEventField(value); - } + /// Raise the event. + /// The value to send as an event argument. + public void RaiseEvent(int value) + { + this.Api.RaiseEventField(value); + } - /// Set the values for the API property. - public void SetPropertyValues(int number, object obj, string listValue, string listWithInterfaceValue, string dictionaryKey, string dictionaryListValue, BindingFlags enumValue, string inheritedValue) - { - this.Api.NumberProperty = number; - this.Api.ObjectProperty = obj; - this.Api.ListProperty = new List { listValue }; - this.Api.ListPropertyWithInterface = new List { listWithInterfaceValue }; - this.Api.GenericsProperty = new Dictionary> { [dictionaryKey] = new List { dictionaryListValue } }; - this.Api.EnumProperty = enumValue; - this.Api.InheritedProperty = inheritedValue; - } + /// Set the values for the API property. + public void SetPropertyValues(int number, object obj, string listValue, string listWithInterfaceValue, string dictionaryKey, string dictionaryListValue, BindingFlags enumValue, string inheritedValue) + { + this.Api.NumberProperty = number; + this.Api.ObjectProperty = obj; + this.Api.ListProperty = new List { listValue }; + this.Api.ListPropertyWithInterface = new List { listWithInterfaceValue }; + this.Api.GenericsProperty = new Dictionary> { [dictionaryKey] = new List { dictionaryListValue } }; + this.Api.EnumProperty = enumValue; + this.Api.InheritedProperty = inheritedValue; } } diff --git a/src/SMAPI.Tests/Core/AssetNameTests.cs b/src/SMAPI.Tests/Core/AssetNameTests.cs index 2d546ec77..1d8b89aa8 100644 --- a/src/SMAPI.Tests/Core/AssetNameTests.cs +++ b/src/SMAPI.Tests/Core/AssetNameTests.cs @@ -7,329 +7,327 @@ using StardewModdingAPI.Toolkit.Utilities; using StardewValley; -namespace SMAPI.Tests.Core +namespace SMAPI.Tests.Core; + +/// Unit tests for . +[TestFixture] +internal class AssetNameTests { - /// Unit tests for . - [TestFixture] - internal class AssetNameTests + /********* + ** Unit tests + *********/ + /**** + ** Constructor + ****/ + [Test(Description = $"Assert that the {nameof(AssetName)} constructor creates an instance with the expected values.")] + [TestCase("SimpleName", "SimpleName", null, null)] + [TestCase("Data/Achievements", "Data/Achievements", null, null)] + [TestCase("Characters/Dialogue/Abigail", "Characters/Dialogue/Abigail", null, null)] + [TestCase("Characters/Dialogue/Abigail.fr-FR", "Characters/Dialogue/Abigail", "fr-FR", LocalizedContentManager.LanguageCode.fr)] + [TestCase("Characters/Dialogue\\Abigail.fr-FR", "Characters/Dialogue/Abigail.fr-FR", null, null)] + [TestCase("Characters/Dialogue/Abigail.fr-FR", "Characters/Dialogue/Abigail", "fr-FR", LocalizedContentManager.LanguageCode.fr)] + public void Constructor_Valid(string name, string expectedBaseName, string? expectedLocale, LocalizedContentManager.LanguageCode? expectedLanguageCode) { - /********* - ** Unit tests - *********/ - /**** - ** Constructor - ****/ - [Test(Description = $"Assert that the {nameof(AssetName)} constructor creates an instance with the expected values.")] - [TestCase("SimpleName", "SimpleName", null, null)] - [TestCase("Data/Achievements", "Data/Achievements", null, null)] - [TestCase("Characters/Dialogue/Abigail", "Characters/Dialogue/Abigail", null, null)] - [TestCase("Characters/Dialogue/Abigail.fr-FR", "Characters/Dialogue/Abigail", "fr-FR", LocalizedContentManager.LanguageCode.fr)] - [TestCase("Characters/Dialogue\\Abigail.fr-FR", "Characters/Dialogue/Abigail.fr-FR", null, null)] - [TestCase("Characters/Dialogue/Abigail.fr-FR", "Characters/Dialogue/Abigail", "fr-FR", LocalizedContentManager.LanguageCode.fr)] - public void Constructor_Valid(string name, string expectedBaseName, string? expectedLocale, LocalizedContentManager.LanguageCode? expectedLanguageCode) - { - // arrange - name = PathUtilities.NormalizeAssetName(name); - - // act - IAssetName assetName = AssetName.Parse(name, parseLocale: _ => expectedLanguageCode); - - // assert - assetName.Name.Should() - .NotBeNull() - .And.Be(name.Replace("\\", "/")); - assetName.BaseName.Should() - .NotBeNull() - .And.Be(expectedBaseName); - assetName.LocaleCode.Should() - .Be(expectedLocale); - assetName.LanguageCode.Should() - .Be(expectedLanguageCode); - } - - [Test(Description = $"Assert that the {nameof(AssetName)} constructor throws an exception if the value is invalid.")] - [TestCase(null)] - [TestCase("")] - [TestCase(" ")] - [TestCase("\t")] - [TestCase(" \t ")] - public void Constructor_NullOrWhitespace(string? name) - { - // act - ArgumentException exception = Assert.Throws(() => _ = AssetName.Parse(name!, _ => null))!; + // arrange + name = PathUtilities.NormalizeAssetName(name); + + // act + IAssetName assetName = AssetName.Parse(name, parseLocale: _ => expectedLanguageCode); + + // assert + assetName.Name.Should() + .NotBeNull() + .And.Be(name.Replace("\\", "/")); + assetName.BaseName.Should() + .NotBeNull() + .And.Be(expectedBaseName); + assetName.LocaleCode.Should() + .Be(expectedLocale); + assetName.LanguageCode.Should() + .Be(expectedLanguageCode); + } - // assert - exception.ParamName.Should().Be("rawName"); - exception.Message.Should().Be("The asset name can't be null or empty. (Parameter 'rawName')"); - } + [Test(Description = $"Assert that the {nameof(AssetName)} constructor throws an exception if the value is invalid.")] + [TestCase(null)] + [TestCase("")] + [TestCase(" ")] + [TestCase("\t")] + [TestCase(" \t ")] + public void Constructor_NullOrWhitespace(string? name) + { + // act + FluentActions.Invoking(() => _ = AssetName.Parse(name!, _ => null)).Should() + .Throw() + .WithParameterName("rawName") + .WithMessage("The asset name can't be null or empty. (Parameter 'rawName')"); + } - /**** - ** IsEquivalentTo - ****/ - [Test(Description = $"Assert that {nameof(AssetName.IsEquivalentTo)} compares names as expected when the locale is included.")] + /**** + ** IsEquivalentTo + ****/ + [Test(Description = $"Assert that {nameof(AssetName.IsEquivalentTo)} compares names as expected when the locale is included.")] - // exact match (ignore case) - [TestCase("Data/Achievements", "Data/Achievements", ExpectedResult = true)] - [TestCase("DATA/achievements", "data/ACHIEVEMENTS", ExpectedResult = true)] + // exact match (ignore case) + [TestCase("Data/Achievements", "Data/Achievements", ExpectedResult = true)] + [TestCase("DATA/achievements", "data/ACHIEVEMENTS", ExpectedResult = true)] - // exact match (ignore formatting) - [TestCase("Data/Achievements", "Data\\Achievements", ExpectedResult = true)] - [TestCase("DATA\\achievements", "data/ACHIEVEMENTS", ExpectedResult = true)] - [TestCase("DATA\\\\achievements", "data////ACHIEVEMENTS", ExpectedResult = true)] + // exact match (ignore formatting) + [TestCase("Data/Achievements", "Data\\Achievements", ExpectedResult = true)] + [TestCase("DATA\\achievements", "data/ACHIEVEMENTS", ExpectedResult = true)] + [TestCase("DATA\\\\achievements", "data////ACHIEVEMENTS", ExpectedResult = true)] - // whitespace-insensitive - [TestCase("Data/Achievements", " Data/Achievements ", ExpectedResult = true)] - [TestCase(" Data/Achievements ", "Data/Achievements", ExpectedResult = true)] + // whitespace-insensitive + [TestCase("Data/Achievements", " Data/Achievements ", ExpectedResult = true)] + [TestCase(" Data/Achievements ", "Data/Achievements", ExpectedResult = true)] - // other is null or whitespace - [TestCase("Data/Achievements", null, ExpectedResult = false)] - [TestCase("Data/Achievements", "", ExpectedResult = false)] - [TestCase("Data/Achievements", " ", ExpectedResult = false)] + // other is null or whitespace + [TestCase("Data/Achievements", null, ExpectedResult = false)] + [TestCase("Data/Achievements", "", ExpectedResult = false)] + [TestCase("Data/Achievements", " ", ExpectedResult = false)] - // with locale codes - [TestCase("Data/Achievements", "Data/Achievements.fr-FR", ExpectedResult = false)] - [TestCase("Data/Achievements.fr-FR", "Data/Achievements", ExpectedResult = false)] - [TestCase("Data/Achievements.fr-FR", "Data/Achievements.fr-FR", ExpectedResult = true)] - public bool IsEquivalentTo_Name(string mainAssetName, string otherAssetName) - { - // arrange - mainAssetName = PathUtilities.NormalizeAssetName(mainAssetName); + // with locale codes + [TestCase("Data/Achievements", "Data/Achievements.fr-FR", ExpectedResult = false)] + [TestCase("Data/Achievements.fr-FR", "Data/Achievements", ExpectedResult = false)] + [TestCase("Data/Achievements.fr-FR", "Data/Achievements.fr-FR", ExpectedResult = true)] + public bool IsEquivalentTo_Name(string mainAssetName, string otherAssetName) + { + // arrange + mainAssetName = PathUtilities.NormalizeAssetName(mainAssetName); - // act - AssetName name = AssetName.Parse(mainAssetName, _ => LocalizedContentManager.LanguageCode.fr); + // act + AssetName name = AssetName.Parse(mainAssetName, _ => LocalizedContentManager.LanguageCode.fr); - // assert - return name.IsEquivalentTo(otherAssetName); - } + // assert + return name.IsEquivalentTo(otherAssetName); + } - [Test(Description = $"Assert that {nameof(AssetName.IsEquivalentTo)} compares names as expected when the locale is excluded.")] + [Test(Description = $"Assert that {nameof(AssetName.IsEquivalentTo)} compares names as expected when the locale is excluded.")] - // a few samples from previous test to make sure - [TestCase("Data/Achievements", "Data/Achievements", ExpectedResult = true)] - [TestCase("DATA/achievements", "data/ACHIEVEMENTS", ExpectedResult = true)] - [TestCase("DATA\\\\achievements", "data////ACHIEVEMENTS", ExpectedResult = true)] - [TestCase(" Data/Achievements ", "Data/Achievements", ExpectedResult = true)] - [TestCase("Data/Achievements", " ", ExpectedResult = false)] + // a few samples from previous test to make sure + [TestCase("Data/Achievements", "Data/Achievements", ExpectedResult = true)] + [TestCase("DATA/achievements", "data/ACHIEVEMENTS", ExpectedResult = true)] + [TestCase("DATA\\\\achievements", "data////ACHIEVEMENTS", ExpectedResult = true)] + [TestCase(" Data/Achievements ", "Data/Achievements", ExpectedResult = true)] + [TestCase("Data/Achievements", " ", ExpectedResult = false)] - // with locale codes - [TestCase("Data/Achievements", "Data/Achievements.fr-FR", ExpectedResult = false)] - [TestCase("Data/Achievements.fr-FR", "Data/Achievements", ExpectedResult = true)] - [TestCase("Data/Achievements.fr-FR", "Data/Achievements.fr-FR", ExpectedResult = false)] - public bool IsEquivalentTo_BaseName(string mainAssetName, string otherAssetName) - { - // arrange - mainAssetName = PathUtilities.NormalizeAssetName(mainAssetName); + // with locale codes + [TestCase("Data/Achievements", "Data/Achievements.fr-FR", ExpectedResult = false)] + [TestCase("Data/Achievements.fr-FR", "Data/Achievements", ExpectedResult = true)] + [TestCase("Data/Achievements.fr-FR", "Data/Achievements.fr-FR", ExpectedResult = false)] + public bool IsEquivalentTo_BaseName(string mainAssetName, string otherAssetName) + { + // arrange + mainAssetName = PathUtilities.NormalizeAssetName(mainAssetName); - // act - AssetName name = AssetName.Parse(mainAssetName, _ => LocalizedContentManager.LanguageCode.fr); + // act + AssetName name = AssetName.Parse(mainAssetName, _ => LocalizedContentManager.LanguageCode.fr); - // assert - return name.IsEquivalentTo(otherAssetName, useBaseName: true); - } + // assert + return name.IsEquivalentTo(otherAssetName, useBaseName: true); + } - /**** - ** StartsWith - ****/ - [Test(Description = $"Assert that {nameof(AssetName.StartsWith)} compares names as expected for inputs that aren't affected by the input options.")] + /**** + ** StartsWith + ****/ + [Test(Description = $"Assert that {nameof(AssetName.StartsWith)} compares names as expected for inputs that aren't affected by the input options.")] - // exact match (ignore case and formatting) - [TestCase("Data/Achievements", "Data/Achievements", ExpectedResult = true)] - [TestCase("DATA/achievements", "data/ACHIEVEMENTS", ExpectedResult = true)] - [TestCase("Data/Achievements", "Data\\Achievements", ExpectedResult = true)] - [TestCase("DATA\\achievements", "data/ACHIEVEMENTS", ExpectedResult = true)] - [TestCase("DATA\\\\achievements", "data////ACHIEVEMENTS", ExpectedResult = true)] + // exact match (ignore case and formatting) + [TestCase("Data/Achievements", "Data/Achievements", ExpectedResult = true)] + [TestCase("DATA/achievements", "data/ACHIEVEMENTS", ExpectedResult = true)] + [TestCase("Data/Achievements", "Data\\Achievements", ExpectedResult = true)] + [TestCase("DATA\\achievements", "data/ACHIEVEMENTS", ExpectedResult = true)] + [TestCase("DATA\\\\achievements", "data////ACHIEVEMENTS", ExpectedResult = true)] - // whitespace-insensitive - [TestCase("Data/Achievements", " Data/Achievements", ExpectedResult = true)] - [TestCase(" Data/Achievements ", "Data/Achievements", ExpectedResult = true)] - [TestCase("Data/Achievements", " ", ExpectedResult = true)] + // whitespace-insensitive + [TestCase("Data/Achievements", " Data/Achievements", ExpectedResult = true)] + [TestCase(" Data/Achievements ", "Data/Achievements", ExpectedResult = true)] + [TestCase("Data/Achievements", " ", ExpectedResult = true)] - // invalid prefixes - [TestCase("Data/Achievements", null, ExpectedResult = false)] + // invalid prefixes + [TestCase("Data/Achievements", null, ExpectedResult = false)] - // with locale codes - [TestCase("Data/Achievements.fr-FR", "Data/Achievements", ExpectedResult = true)] + // with locale codes + [TestCase("Data/Achievements.fr-FR", "Data/Achievements", ExpectedResult = true)] - // prefix ends with path separator - [TestCase("Data/Events/Boop", "Data/Events/", ExpectedResult = true)] - [TestCase("Data/Events/Boop", "Data/Events\\", ExpectedResult = true)] - [TestCase("Data/Events", "Data/Events/", ExpectedResult = false)] - [TestCase("Data/Events", "Data/Events\\", ExpectedResult = false)] - public bool StartsWith_SimpleCases(string mainAssetName, string prefix) - { - // arrange - mainAssetName = PathUtilities.NormalizeAssetName(mainAssetName); + // prefix ends with path separator + [TestCase("Data/Events/Boop", "Data/Events/", ExpectedResult = true)] + [TestCase("Data/Events/Boop", "Data/Events\\", ExpectedResult = true)] + [TestCase("Data/Events", "Data/Events/", ExpectedResult = false)] + [TestCase("Data/Events", "Data/Events\\", ExpectedResult = false)] + public bool StartsWith_SimpleCases(string mainAssetName, string prefix) + { + // arrange + mainAssetName = PathUtilities.NormalizeAssetName(mainAssetName); - // act - AssetName name = AssetName.Parse(mainAssetName, _ => null); + // act + AssetName name = AssetName.Parse(mainAssetName, _ => null); - // assert value is the same for any combination of options - bool result = name.StartsWith(prefix); - foreach (bool allowPartialWord in new[] { true, false }) + // assert value is the same for any combination of options + bool result = name.StartsWith(prefix); + foreach (bool allowPartialWord in new[] { true, false }) + { + foreach (bool allowSubfolder in new[] { true, true }) { - foreach (bool allowSubfolder in new[] { true, true }) - { - if (allowPartialWord && allowSubfolder) - continue; - - name.StartsWith(prefix, allowPartialWord, allowSubfolder) - .Should().Be(result, $"the value returned for options ({nameof(allowPartialWord)}: {allowPartialWord}, {nameof(allowSubfolder)}: {allowSubfolder}) should match the base case"); - } - } + if (allowPartialWord && allowSubfolder) + continue; - // assert value - return result; + name.StartsWith(prefix, allowPartialWord, allowSubfolder) + .Should().Be(result, $"the value returned for options ({nameof(allowPartialWord)}: {allowPartialWord}, {nameof(allowSubfolder)}: {allowSubfolder}) should match the base case"); + } } - [Test(Description = $"Assert that {nameof(AssetName.StartsWith)} compares names as expected for the 'allowPartialWord' option.")] - [TestCase("Data/AchievementsToIgnore", "Data/Achievements", true, ExpectedResult = true)] - [TestCase("Data/AchievementsToIgnore", "Data/Achievements", false, ExpectedResult = false)] - [TestCase("Data/Achievements X", "Data/Achievements", true, ExpectedResult = true)] - [TestCase("Data/Achievements X", "Data/Achievements", false, ExpectedResult = true)] - [TestCase("Data/Achievements.X", "Data/Achievements", true, ExpectedResult = true)] - [TestCase("Data/Achievements.X", "Data/Achievements", false, ExpectedResult = true)] - - // with locale codes - [TestCase("Data/Achievements.fr-FR", "Data/Achievements", true, ExpectedResult = true)] - [TestCase("Data/Achievements.fr-FR", "Data/Achievements", false, ExpectedResult = true)] - public bool StartsWith_PartialWord(string mainAssetName, string prefix, bool allowPartialWord) - { - // arrange - mainAssetName = PathUtilities.NormalizeAssetName(mainAssetName); + // assert value + return result; + } - // act - AssetName name = AssetName.Parse(mainAssetName, _ => null); + [Test(Description = $"Assert that {nameof(AssetName.StartsWith)} compares names as expected for the 'allowPartialWord' option.")] + [TestCase("Data/AchievementsToIgnore", "Data/Achievements", true, ExpectedResult = true)] + [TestCase("Data/AchievementsToIgnore", "Data/Achievements", false, ExpectedResult = false)] + [TestCase("Data/Achievements X", "Data/Achievements", true, ExpectedResult = true)] + [TestCase("Data/Achievements X", "Data/Achievements", false, ExpectedResult = true)] + [TestCase("Data/Achievements.X", "Data/Achievements", true, ExpectedResult = true)] + [TestCase("Data/Achievements.X", "Data/Achievements", false, ExpectedResult = true)] + + // with locale codes + [TestCase("Data/Achievements.fr-FR", "Data/Achievements", true, ExpectedResult = true)] + [TestCase("Data/Achievements.fr-FR", "Data/Achievements", false, ExpectedResult = true)] + public bool StartsWith_PartialWord(string mainAssetName, string prefix, bool allowPartialWord) + { + // arrange + mainAssetName = PathUtilities.NormalizeAssetName(mainAssetName); - // assert value is the same for any combination of options - bool result = name.StartsWith(prefix, allowPartialWord: allowPartialWord, allowSubfolder: true); - name.StartsWith(prefix, allowPartialWord, allowSubfolder: false) - .Should().Be(result, "specifying allowSubfolder should have no effect for these inputs"); + // act + AssetName name = AssetName.Parse(mainAssetName, _ => null); - // assert value - return result; - } + // assert value is the same for any combination of options + bool result = name.StartsWith(prefix, allowPartialWord: allowPartialWord, allowSubfolder: true); + name.StartsWith(prefix, allowPartialWord, allowSubfolder: false) + .Should().Be(result, "specifying allowSubfolder should have no effect for these inputs"); - [Test(Description = $"Assert that {nameof(AssetName.StartsWith)} compares names as expected for the 'allowSubfolder' option.")] + // assert value + return result; + } - // simple cases - [TestCase("Data/Achievements/Path", "Data/Achievements", true, ExpectedResult = true)] - [TestCase("Data/Achievements/Path", "Data/Achievements", false, ExpectedResult = false)] - [TestCase("Data/Achievements/Path", "Data\\Achievements", true, ExpectedResult = true)] - [TestCase("Data/Achievements/Path", "Data\\Achievements", false, ExpectedResult = false)] + [Test(Description = $"Assert that {nameof(AssetName.StartsWith)} compares names as expected for the 'allowSubfolder' option.")] - // trailing slash - [TestCase("Data/Achievements/Path", "Data/", true, ExpectedResult = true)] - [TestCase("Data/Achievements/Path", "Data/", false, ExpectedResult = false)] + // simple cases + [TestCase("Data/Achievements/Path", "Data/Achievements", true, ExpectedResult = true)] + [TestCase("Data/Achievements/Path", "Data/Achievements", false, ExpectedResult = false)] + [TestCase("Data/Achievements/Path", "Data\\Achievements", true, ExpectedResult = true)] + [TestCase("Data/Achievements/Path", "Data\\Achievements", false, ExpectedResult = false)] - // normalize slash style - [TestCase("Data/Achievements/Path", "Data\\", true, ExpectedResult = true)] - [TestCase("Data/Achievements/Path", "Data\\", false, ExpectedResult = false)] - [TestCase("Data/Achievements/Path", "Data/\\/", true, ExpectedResult = true)] - [TestCase("Data/Achievements/Path", "Data/\\/", false, ExpectedResult = false)] + // trailing slash + [TestCase("Data/Achievements/Path", "Data/", true, ExpectedResult = true)] + [TestCase("Data/Achievements/Path", "Data/", false, ExpectedResult = false)] - // with locale code - [TestCase("Data/Achievements/Path.fr-FR", "Data/Achievements", true, ExpectedResult = true)] - [TestCase("Data/Achievements/Path.fr-FR", "Data/Achievements", false, ExpectedResult = false)] - public bool StartsWith_Subfolder(string mainAssetName, string otherAssetName, bool allowSubfolder) - { - // arrange - mainAssetName = PathUtilities.NormalizeAssetName(mainAssetName); + // normalize slash style + [TestCase("Data/Achievements/Path", "Data\\", true, ExpectedResult = true)] + [TestCase("Data/Achievements/Path", "Data\\", false, ExpectedResult = false)] + [TestCase("Data/Achievements/Path", "Data/\\/", true, ExpectedResult = true)] + [TestCase("Data/Achievements/Path", "Data/\\/", false, ExpectedResult = false)] - // act - AssetName name = AssetName.Parse(mainAssetName, _ => null); + // with locale code + [TestCase("Data/Achievements/Path.fr-FR", "Data/Achievements", true, ExpectedResult = true)] + [TestCase("Data/Achievements/Path.fr-FR", "Data/Achievements", false, ExpectedResult = false)] + public bool StartsWith_Subfolder(string mainAssetName, string otherAssetName, bool allowSubfolder) + { + // arrange + mainAssetName = PathUtilities.NormalizeAssetName(mainAssetName); - // assert value is the same for any combination of options - bool result = name.StartsWith(otherAssetName, allowPartialWord: true, allowSubfolder: allowSubfolder); - name.StartsWith(otherAssetName, allowPartialWord: false, allowSubfolder: allowSubfolder) - .Should().Be(result, "specifying allowPartialWord should have no effect for these inputs"); + // act + AssetName name = AssetName.Parse(mainAssetName, _ => null); - // assert value - return result; - } + // assert value is the same for any combination of options + bool result = name.StartsWith(otherAssetName, allowPartialWord: true, allowSubfolder: allowSubfolder); + name.StartsWith(otherAssetName, allowPartialWord: false, allowSubfolder: allowSubfolder) + .Should().Be(result, "specifying allowPartialWord should have no effect for these inputs"); - [TestCase("Mods/SomeMod/SomeSubdirectory", "Mods/Some", true, ExpectedResult = true)] - [TestCase("Mods/SomeMod/SomeSubdirectory", "Mods/Some", false, ExpectedResult = false)] - [TestCase("Mods/Jasper/Data", "Mods/Jas/Image", true, ExpectedResult = false)] - [TestCase("Mods/Jasper/Data", "Mods/Jas/Image", true, ExpectedResult = false)] - public bool StartsWith_PartialMatchInPathSegment(string mainAssetName, string otherAssetName, bool allowSubfolder) - { - // arrange - mainAssetName = PathUtilities.NormalizeAssetName(mainAssetName); + // assert value + return result; + } - // act - AssetName name = AssetName.Parse(mainAssetName, _ => null); + [TestCase("Mods/SomeMod/SomeSubdirectory", "Mods/Some", true, ExpectedResult = true)] + [TestCase("Mods/SomeMod/SomeSubdirectory", "Mods/Some", false, ExpectedResult = false)] + [TestCase("Mods/Jasper/Data", "Mods/Jas/Image", true, ExpectedResult = false)] + [TestCase("Mods/Jasper/Data", "Mods/Jas/Image", true, ExpectedResult = false)] + public bool StartsWith_PartialMatchInPathSegment(string mainAssetName, string otherAssetName, bool allowSubfolder) + { + // arrange + mainAssetName = PathUtilities.NormalizeAssetName(mainAssetName); - // assert value - return name.StartsWith(otherAssetName, allowPartialWord: true, allowSubfolder: allowSubfolder); - } + // act + AssetName name = AssetName.Parse(mainAssetName, _ => null); - // The enumerator strips the trailing path separator, so each of these cases has to be handled on each branch. - [TestCase("Mods/SomeMod", "Mods/", false, ExpectedResult = true)] - [TestCase("Mods/SomeMod", "Mods", false, ExpectedResult = false)] - [TestCase("Mods/Jasper/Data", "Mods/Jas/", false, ExpectedResult = false)] - [TestCase("Mods/Jasper/Data", "Mods/Jas", false, ExpectedResult = false)] - [TestCase("Mods/Jas", "Mods/Jas/", false, ExpectedResult = false)] - [TestCase("Mods/Jas", "Mods/Jas", false, ExpectedResult = true)] - public bool StartsWith_PrefixHasSeparator(string mainAssetName, string otherAssetName, bool allowSubfolder) - { - // arrange - mainAssetName = PathUtilities.NormalizeAssetName(mainAssetName); + // assert value + return name.StartsWith(otherAssetName, allowPartialWord: true, allowSubfolder: allowSubfolder); + } + + // The enumerator strips the trailing path separator, so each of these cases has to be handled on each branch. + [TestCase("Mods/SomeMod", "Mods/", false, ExpectedResult = true)] + [TestCase("Mods/SomeMod", "Mods", false, ExpectedResult = false)] + [TestCase("Mods/Jasper/Data", "Mods/Jas/", false, ExpectedResult = false)] + [TestCase("Mods/Jasper/Data", "Mods/Jas", false, ExpectedResult = false)] + [TestCase("Mods/Jas", "Mods/Jas/", false, ExpectedResult = false)] + [TestCase("Mods/Jas", "Mods/Jas", false, ExpectedResult = true)] + public bool StartsWith_PrefixHasSeparator(string mainAssetName, string otherAssetName, bool allowSubfolder) + { + // arrange + mainAssetName = PathUtilities.NormalizeAssetName(mainAssetName); - // act - AssetName name = AssetName.Parse(mainAssetName, _ => null); + // act + AssetName name = AssetName.Parse(mainAssetName, _ => null); - // assert value - return name.StartsWith(otherAssetName, allowPartialWord: true, allowSubfolder: allowSubfolder); - } + // assert value + return name.StartsWith(otherAssetName, allowPartialWord: true, allowSubfolder: allowSubfolder); + } - /**** - ** GetHashCode - ****/ - [Test(Description = $"Assert that {nameof(AssetName.GetHashCode)} generates the same hash code for two asset names which differ only by capitalization.")] - public void GetHashCode_IsCaseInsensitive() - { - // arrange - string left = "data/ACHIEVEMENTS"; - string right = "DATA/achievements"; + /**** + ** GetHashCode + ****/ + [Test(Description = $"Assert that {nameof(AssetName.GetHashCode)} generates the same hash code for two asset names which differ only by capitalization.")] + public void GetHashCode_IsCaseInsensitive() + { + // arrange + string left = "data/ACHIEVEMENTS"; + string right = "DATA/achievements"; - // act - int leftHash = AssetName.Parse(left, _ => null).GetHashCode(); - int rightHash = AssetName.Parse(right, _ => null).GetHashCode(); + // act + int leftHash = AssetName.Parse(left, _ => null).GetHashCode(); + int rightHash = AssetName.Parse(right, _ => null).GetHashCode(); - // assert - leftHash.Should().Be(rightHash, "two asset names which differ only by capitalization should produce the same hash code"); - } + // assert + leftHash.Should().Be(rightHash, "two asset names which differ only by capitalization should produce the same hash code"); + } - [Test(Description = $"Assert that {nameof(AssetName.GetHashCode)} generates few hash code collisions for an arbitrary set of asset names.")] - public void GetHashCode_HasFewCollisions() + [Test(Description = $"Assert that {nameof(AssetName.GetHashCode)} generates few hash code collisions for an arbitrary set of asset names.")] + public void GetHashCode_HasFewCollisions() + { + // generate list of names + List names = new(); { - // generate list of names - List names = new(); - { - Random random = new(); - string characters = "abcdefghijklmnopqrstuvwxyz1234567890/"; + Random random = new(); + string characters = "abcdefghijklmnopqrstuvwxyz1234567890/"; - while (names.Count < 1000) - { - char[] name = new char[random.Next(5, 20)]; - for (int i = 0; i < name.Length; i++) - name[i] = characters[random.Next(0, characters.Length)]; + while (names.Count < 1000) + { + char[] name = new char[random.Next(5, 20)]; + for (int i = 0; i < name.Length; i++) + name[i] = characters[random.Next(0, characters.Length)]; - names.Add(new string(name)); - } + names.Add(new string(name)); } + } - // get distinct hash codes - HashSet hashCodes = new(); - foreach (string name in names) - hashCodes.Add(AssetName.Parse(name, _ => null).GetHashCode()); + // get distinct hash codes + HashSet hashCodes = new(); + foreach (string name in names) + hashCodes.Add(AssetName.Parse(name, _ => null).GetHashCode()); - // assert a collision frequency under 0.1% - float collisionFrequency = 1 - (hashCodes.Count / (names.Count * 1f)); - collisionFrequency.Should().BeLessOrEqualTo(0.001f, "hash codes should be relatively distinct with a collision rate under 0.1% for a small sample set"); - } + // assert a collision frequency under 0.1% + float collisionFrequency = 1 - (hashCodes.Count / (names.Count * 1f)); + collisionFrequency.Should().BeLessOrEqualTo(0.001f, "hash codes should be relatively distinct with a collision rate under 0.1% for a small sample set"); } } diff --git a/src/SMAPI.Tests/Core/AssumptionTests.cs b/src/SMAPI.Tests/Core/AssumptionTests.cs index efc9da3f7..8ec1156a0 100644 --- a/src/SMAPI.Tests/Core/AssumptionTests.cs +++ b/src/SMAPI.Tests/Core/AssumptionTests.cs @@ -6,57 +6,56 @@ using NUnit.Framework; using StardewModdingAPI.Framework.Models; -namespace SMAPI.Tests.Core +namespace SMAPI.Tests.Core; + +/// Unit tests which validate assumptions about .NET used in the SMAPI implementation. +[TestFixture] +internal class AssumptionTests { - /// Unit tests which validate assumptions about .NET used in the SMAPI implementation. - [TestFixture] - internal class AssumptionTests + /********* + ** Unit tests + *********/ + /**** + ** Constructor + ****/ + [Test(Description = $"Assert that {nameof(HashSet)} maintains insertion order when no elements are removed. If this fails, we'll need to change the implementation for the {nameof(SConfig.ModsToLoadEarly)} and {nameof(SConfig.ModsToLoadLate)} options.")] + [TestCase("construct from array")] + [TestCase("add incrementally")] + public void HashSet_MaintainsInsertionOrderWhenNoElementsAreRemoved(string populateMethod) { - /********* - ** Unit tests - *********/ - /**** - ** Constructor - ****/ - [Test(Description = $"Assert that {nameof(HashSet)} maintains insertion order when no elements are removed. If this fails, we'll need to change the implementation for the {nameof(SConfig.ModsToLoadEarly)} and {nameof(SConfig.ModsToLoadLate)} options.")] - [TestCase("construct from array")] - [TestCase("add incrementally")] - public void HashSet_MaintainsInsertionOrderWhenNoElementsAreRemoved(string populateMethod) - { - // arrange - string[] inserted = Enumerable.Range(0, 1000) - .Select(_ => Guid.NewGuid().ToString("N")) - .ToArray(); + // arrange + string[] inserted = Enumerable.Range(0, 1000) + .Select(_ => Guid.NewGuid().ToString("N")) + .ToArray(); - // act - HashSet set; - switch (populateMethod) - { - case "construct from array": - set = new(inserted, StringComparer.OrdinalIgnoreCase); - break; + // act + HashSet set; + switch (populateMethod) + { + case "construct from array": + set = new(inserted, StringComparer.OrdinalIgnoreCase); + break; - case "add incrementally": - set = new(StringComparer.OrdinalIgnoreCase); - foreach (string value in inserted) - set.Add(value); - break; + case "add incrementally": + set = new(StringComparer.OrdinalIgnoreCase); + foreach (string value in inserted) + set.Add(value); + break; - default: - throw new AssertionFailedException($"Unknown populate method '{populateMethod}'."); - } + default: + throw new AssertionFailedException($"Unknown populate method '{populateMethod}'."); + } - // assert - string[] actualOrder = set.ToArray(); - actualOrder.Should().HaveCount(inserted.Length); - for (int i = 0; i < inserted.Length; i++) - { - string expected = inserted[i]; - string actual = actualOrder[i]; + // assert + string[] actualOrder = set.ToArray(); + actualOrder.Should().HaveCount(inserted.Length); + for (int i = 0; i < inserted.Length; i++) + { + string expected = inserted[i]; + string actual = actualOrder[i]; - if (actual != expected) - throw new AssertionFailedException($"The hash set differed at index {i}: expected {expected}, but found {actual} instead."); - } + if (actual != expected) + throw new AssertionFailedException($"The hash set differed at index {i}: expected {expected}, but found {actual} instead."); } } } diff --git a/src/SMAPI.Tests/Core/InterfaceProxyTests.cs b/src/SMAPI.Tests/Core/InterfaceProxyTests.cs index d14c116f9..203abc192 100644 --- a/src/SMAPI.Tests/Core/InterfaceProxyTests.cs +++ b/src/SMAPI.Tests/Core/InterfaceProxyTests.cs @@ -11,387 +11,386 @@ using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Utilities; -namespace SMAPI.Tests.Core +namespace SMAPI.Tests.Core; + +/// Unit tests for . +[TestFixture] +internal class InterfaceProxyTests { - /// Unit tests for . - [TestFixture] - internal class InterfaceProxyTests + /********* + ** Fields + *********/ + /// The mod ID providing an API. + private readonly string FromModId = "From.ModId"; + + /// The mod ID consuming an API. + private readonly string ToModId = "From.ModId"; + + /// The random number generator with which to create sample values. + private readonly Random Random = new(); + + /// The proxy factory to use in unit tests. + private static readonly IInterfaceProxyFactory[] ProxyFactories = { new InterfaceProxyFactory() }; + + + /********* + ** Unit tests + *********/ + /**** + ** Events + ****/ + /// Assert that an event field can be proxied correctly. + /// The proxy factory to test. + [Test] + public void CanProxy_EventField([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory) { - /********* - ** Fields - *********/ - /// The mod ID providing an API. - private readonly string FromModId = "From.ModId"; - - /// The mod ID consuming an API. - private readonly string ToModId = "From.ModId"; - - /// The random number generator with which to create sample values. - private readonly Random Random = new(); - - /// The proxy factory to use in unit tests. - private static readonly IInterfaceProxyFactory[] ProxyFactories = { new InterfaceProxyFactory() }; - - - /********* - ** Unit tests - *********/ - /**** - ** Events - ****/ - /// Assert that an event field can be proxied correctly. - /// The proxy factory to test. - [Test] - public void CanProxy_EventField([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory) - { - // arrange - ProviderMod providerMod = new(); - object implementation = providerMod.GetModApi(); - int expectedValue = this.Random.Next(); - - // act - ISimpleApi proxy = this.GetProxy(proxyFactory, implementation); - new ApiConsumer().UseEventField(proxy, out Func<(int timesCalled, int lastValue)> getValues); - providerMod.RaiseEvent(expectedValue); - (int timesCalled, int lastValue) = getValues(); - - // assert - timesCalled.Should().Be(1, "Expected the proxied event to be raised once."); - lastValue.Should().Be(expectedValue, "The proxy received a different event argument than the implementation raised."); - } + // arrange + ProviderMod providerMod = new(); + object implementation = providerMod.GetModApi(); + int expectedValue = this.Random.Next(); + + // act + ISimpleApi proxy = this.GetProxy(proxyFactory, implementation); + new ApiConsumer().UseEventField(proxy, out Func<(int timesCalled, int lastValue)> getValues); + providerMod.RaiseEvent(expectedValue); + (int timesCalled, int lastValue) = getValues(); + + // assert + timesCalled.Should().Be(1, "Expected the proxied event to be raised once."); + lastValue.Should().Be(expectedValue, "The proxy received a different event argument than the implementation raised."); + } - /// Assert that an event property can be proxied correctly. - /// The proxy factory to test. - [Test] - public void CanProxy_EventProperty([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory) - { - // arrange - ProviderMod providerMod = new(); - object implementation = providerMod.GetModApi(); - int expectedValue = this.Random.Next(); - - // act - ISimpleApi proxy = this.GetProxy(proxyFactory, implementation); - new ApiConsumer().UseEventProperty(proxy, out Func<(int timesCalled, int lastValue)> getValues); - providerMod.RaiseEvent(expectedValue); - (int timesCalled, int lastValue) = getValues(); - - // assert - timesCalled.Should().Be(1, "Expected the proxied event to be raised once."); - lastValue.Should().Be(expectedValue, "The proxy received a different event argument than the implementation raised."); - } + /// Assert that an event property can be proxied correctly. + /// The proxy factory to test. + [Test] + public void CanProxy_EventProperty([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory) + { + // arrange + ProviderMod providerMod = new(); + object implementation = providerMod.GetModApi(); + int expectedValue = this.Random.Next(); + + // act + ISimpleApi proxy = this.GetProxy(proxyFactory, implementation); + new ApiConsumer().UseEventProperty(proxy, out Func<(int timesCalled, int lastValue)> getValues); + providerMod.RaiseEvent(expectedValue); + (int timesCalled, int lastValue) = getValues(); + + // assert + timesCalled.Should().Be(1, "Expected the proxied event to be raised once."); + lastValue.Should().Be(expectedValue, "The proxy received a different event argument than the implementation raised."); + } - /**** - ** Properties - ****/ - /// Assert that properties can be proxied correctly. - /// The proxy factory to test. - /// Whether to set the properties through the provider mod or proxy interface. - [Test] - public void CanProxy_Properties([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory, [Values("set via provider mod", "set via proxy interface")] string setVia) + /**** + ** Properties + ****/ + /// Assert that properties can be proxied correctly. + /// The proxy factory to test. + /// Whether to set the properties through the provider mod or proxy interface. + [Test] + public void CanProxy_Properties([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory, [Values("set via provider mod", "set via proxy interface")] string setVia) + { + // arrange + ProviderMod providerMod = new(); + object implementation = providerMod.GetModApi(); + int expectedNumber = this.Random.Next(); + int expectedObject = this.Random.Next(); + string expectedListValue = this.GetRandomString(); + string expectedListWithInterfaceValue = this.GetRandomString(); + string expectedDictionaryKey = this.GetRandomString(); + string expectedDictionaryListValue = this.GetRandomString(); + string expectedInheritedString = this.GetRandomString(); + BindingFlags expectedEnum = BindingFlags.Instance | BindingFlags.Public; + + // act + ISimpleApi proxy = this.GetProxy(proxyFactory, implementation); + switch (setVia) { - // arrange - ProviderMod providerMod = new(); - object implementation = providerMod.GetModApi(); - int expectedNumber = this.Random.Next(); - int expectedObject = this.Random.Next(); - string expectedListValue = this.GetRandomString(); - string expectedListWithInterfaceValue = this.GetRandomString(); - string expectedDictionaryKey = this.GetRandomString(); - string expectedDictionaryListValue = this.GetRandomString(); - string expectedInheritedString = this.GetRandomString(); - BindingFlags expectedEnum = BindingFlags.Instance | BindingFlags.Public; - - // act - ISimpleApi proxy = this.GetProxy(proxyFactory, implementation); - switch (setVia) - { - case "set via provider mod": - providerMod.SetPropertyValues( - number: expectedNumber, - obj: expectedObject, - listValue: expectedListValue, - listWithInterfaceValue: expectedListWithInterfaceValue, - dictionaryKey: expectedDictionaryKey, - dictionaryListValue: expectedDictionaryListValue, - enumValue: expectedEnum, - inheritedValue: expectedInheritedString - ); - break; - - case "set via proxy interface": - proxy.NumberProperty = expectedNumber; - proxy.ObjectProperty = expectedObject; - proxy.ListProperty = new() { expectedListValue }; - proxy.ListPropertyWithInterface = new List { expectedListWithInterfaceValue }; - proxy.GenericsProperty = new Dictionary> - { - [expectedDictionaryKey] = new List { expectedDictionaryListValue } - }; - proxy.EnumProperty = expectedEnum; - proxy.InheritedProperty = expectedInheritedString; - break; - - default: - throw new InvalidOperationException($"Invalid 'set via' option '{setVia}."); - } - - // assert number - this - .GetPropertyValue(implementation, nameof(proxy.NumberProperty)) - .Should().Be(expectedNumber); - proxy.NumberProperty - .Should().Be(expectedNumber); - - // assert object - this - .GetPropertyValue(implementation, nameof(proxy.ObjectProperty)) - .Should().Be(expectedObject); - proxy.ObjectProperty - .Should().Be(expectedObject); - - // assert list - (this.GetPropertyValue(implementation, nameof(proxy.ListProperty)) as IList) - .Should().NotBeNull() - .And.HaveCount(1) - .And.BeEquivalentTo(expectedListValue); - proxy.ListProperty - .Should().NotBeNull() - .And.HaveCount(1) - .And.BeEquivalentTo(expectedListValue); - - // assert list with interface - (this.GetPropertyValue(implementation, nameof(proxy.ListPropertyWithInterface)) as IList) - .Should().NotBeNull() - .And.HaveCount(1) - .And.BeEquivalentTo(expectedListWithInterfaceValue); - proxy.ListPropertyWithInterface - .Should().NotBeNull() - .And.HaveCount(1) - .And.BeEquivalentTo(expectedListWithInterfaceValue); - - // assert generics - (this.GetPropertyValue(implementation, nameof(proxy.GenericsProperty)) as IDictionary>) - .Should().NotBeNull() - .And.HaveCount(1) - .And.ContainKey(expectedDictionaryKey).WhoseValue.Should().BeEquivalentTo(expectedDictionaryListValue); - proxy.GenericsProperty - .Should().NotBeNull() - .And.HaveCount(1) - .And.ContainKey(expectedDictionaryKey).WhoseValue.Should().BeEquivalentTo(expectedDictionaryListValue); - - // assert enum - this - .GetPropertyValue(implementation, nameof(proxy.EnumProperty)) - .Should().Be(expectedEnum); - proxy.EnumProperty - .Should().Be(expectedEnum); - - // assert getter - this - .GetPropertyValue(implementation, nameof(proxy.GetterProperty)) - .Should().Be(42); - proxy.GetterProperty - .Should().Be(42); - - // assert inherited methods - this - .GetPropertyValue(implementation, nameof(proxy.InheritedProperty)) - .Should().Be(expectedInheritedString); - proxy.InheritedProperty - .Should().Be(expectedInheritedString); + case "set via provider mod": + providerMod.SetPropertyValues( + number: expectedNumber, + obj: expectedObject, + listValue: expectedListValue, + listWithInterfaceValue: expectedListWithInterfaceValue, + dictionaryKey: expectedDictionaryKey, + dictionaryListValue: expectedDictionaryListValue, + enumValue: expectedEnum, + inheritedValue: expectedInheritedString + ); + break; + + case "set via proxy interface": + proxy.NumberProperty = expectedNumber; + proxy.ObjectProperty = expectedObject; + proxy.ListProperty = new() { expectedListValue }; + proxy.ListPropertyWithInterface = new List { expectedListWithInterfaceValue }; + proxy.GenericsProperty = new Dictionary> + { + [expectedDictionaryKey] = new List { expectedDictionaryListValue } + }; + proxy.EnumProperty = expectedEnum; + proxy.InheritedProperty = expectedInheritedString; + break; + + default: + throw new InvalidOperationException($"Invalid 'set via' option '{setVia}."); } - /// Assert that a simple method with no return value can be proxied correctly. - /// The proxy factory to test. - [Test] - public void CanProxy_SimpleMethod_Void([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory) - { - // arrange - object implementation = new ProviderMod().GetModApi(); + // assert number + this + .GetPropertyValue(implementation, nameof(proxy.NumberProperty)) + .Should().Be(expectedNumber); + proxy.NumberProperty + .Should().Be(expectedNumber); + + // assert object + this + .GetPropertyValue(implementation, nameof(proxy.ObjectProperty)) + .Should().Be(expectedObject); + proxy.ObjectProperty + .Should().Be(expectedObject); + + // assert list + (this.GetPropertyValue(implementation, nameof(proxy.ListProperty)) as IList) + .Should().NotBeNull() + .And.HaveCount(1) + .And.BeEquivalentTo(expectedListValue); + proxy.ListProperty + .Should().NotBeNull() + .And.HaveCount(1) + .And.BeEquivalentTo(expectedListValue); + + // assert list with interface + (this.GetPropertyValue(implementation, nameof(proxy.ListPropertyWithInterface)) as IList) + .Should().NotBeNull() + .And.HaveCount(1) + .And.BeEquivalentTo(expectedListWithInterfaceValue); + proxy.ListPropertyWithInterface + .Should().NotBeNull() + .And.HaveCount(1) + .And.BeEquivalentTo(expectedListWithInterfaceValue); + + // assert generics + (this.GetPropertyValue(implementation, nameof(proxy.GenericsProperty)) as IDictionary>) + .Should().NotBeNull() + .And.HaveCount(1) + .And.ContainKey(expectedDictionaryKey).WhoseValue.Should().BeEquivalentTo(expectedDictionaryListValue); + proxy.GenericsProperty + .Should().NotBeNull() + .And.HaveCount(1) + .And.ContainKey(expectedDictionaryKey).WhoseValue.Should().BeEquivalentTo(expectedDictionaryListValue); + + // assert enum + this + .GetPropertyValue(implementation, nameof(proxy.EnumProperty)) + .Should().Be(expectedEnum); + proxy.EnumProperty + .Should().Be(expectedEnum); + + // assert getter + this + .GetPropertyValue(implementation, nameof(proxy.GetterProperty)) + .Should().Be(42); + proxy.GetterProperty + .Should().Be(42); + + // assert inherited methods + this + .GetPropertyValue(implementation, nameof(proxy.InheritedProperty)) + .Should().Be(expectedInheritedString); + proxy.InheritedProperty + .Should().Be(expectedInheritedString); + } - // act - ISimpleApi proxy = this.GetProxy(proxyFactory, implementation); - proxy.GetNothing(); - } + /// Assert that a simple method with no return value can be proxied correctly. + /// The proxy factory to test. + [Test] + public void CanProxy_SimpleMethod_Void([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory) + { + // arrange + object implementation = new ProviderMod().GetModApi(); - /// Assert that a simple int method can be proxied correctly. - /// The proxy factory to test. - [Test] - public void CanProxy_SimpleMethod_Int([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory) - { - // arrange - object implementation = new ProviderMod().GetModApi(); - int expectedValue = this.Random.Next(); + // act + ISimpleApi proxy = this.GetProxy(proxyFactory, implementation); + proxy.GetNothing(); + } - // act - ISimpleApi proxy = this.GetProxy(proxyFactory, implementation); - int actualValue = proxy.GetInt(expectedValue); + /// Assert that a simple int method can be proxied correctly. + /// The proxy factory to test. + [Test] + public void CanProxy_SimpleMethod_Int([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory) + { + // arrange + object implementation = new ProviderMod().GetModApi(); + int expectedValue = this.Random.Next(); - // assert - actualValue.Should().Be(expectedValue); - } + // act + ISimpleApi proxy = this.GetProxy(proxyFactory, implementation); + int actualValue = proxy.GetInt(expectedValue); - /// Assert that a simple object method can be proxied correctly. - /// The proxy factory to test. - [Test] - public void CanProxy_SimpleMethod_Object([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory) - { - // arrange - object implementation = new ProviderMod().GetModApi(); - object expectedValue = new(); + // assert + actualValue.Should().Be(expectedValue); + } - // act - ISimpleApi proxy = this.GetProxy(proxyFactory, implementation); - object actualValue = proxy.GetObject(expectedValue); + /// Assert that a simple object method can be proxied correctly. + /// The proxy factory to test. + [Test] + public void CanProxy_SimpleMethod_Object([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory) + { + // arrange + object implementation = new ProviderMod().GetModApi(); + object expectedValue = new(); - // assert - actualValue.Should().BeSameAs(expectedValue); - } + // act + ISimpleApi proxy = this.GetProxy(proxyFactory, implementation); + object actualValue = proxy.GetObject(expectedValue); - /// Assert that a simple list method can be proxied correctly. - /// The proxy factory to test. - [Test] - public void CanProxy_SimpleMethod_List([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory) - { - // arrange - object implementation = new ProviderMod().GetModApi(); - string expectedValue = this.GetRandomString(); + // assert + actualValue.Should().BeSameAs(expectedValue); + } - // act - ISimpleApi proxy = this.GetProxy(proxyFactory, implementation); - IList actualValue = proxy.GetList(expectedValue); + /// Assert that a simple list method can be proxied correctly. + /// The proxy factory to test. + [Test] + public void CanProxy_SimpleMethod_List([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory) + { + // arrange + object implementation = new ProviderMod().GetModApi(); + string expectedValue = this.GetRandomString(); - // assert - actualValue.Should().BeEquivalentTo(expectedValue); - } + // act + ISimpleApi proxy = this.GetProxy(proxyFactory, implementation); + IList actualValue = proxy.GetList(expectedValue); - /// Assert that a simple list with interface method can be proxied correctly. - /// The proxy factory to test. - [Test] - public void CanProxy_SimpleMethod_ListWithInterface([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory) - { - // arrange - object implementation = new ProviderMod().GetModApi(); - string expectedValue = this.GetRandomString(); + // assert + actualValue.Should().BeEquivalentTo(expectedValue); + } - // act - ISimpleApi proxy = this.GetProxy(proxyFactory, implementation); - IList actualValue = proxy.GetListWithInterface(expectedValue); + /// Assert that a simple list with interface method can be proxied correctly. + /// The proxy factory to test. + [Test] + public void CanProxy_SimpleMethod_ListWithInterface([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory) + { + // arrange + object implementation = new ProviderMod().GetModApi(); + string expectedValue = this.GetRandomString(); - // assert - actualValue.Should().BeEquivalentTo(expectedValue); - } + // act + ISimpleApi proxy = this.GetProxy(proxyFactory, implementation); + IList actualValue = proxy.GetListWithInterface(expectedValue); - /// Assert that a simple method which returns generic types can be proxied correctly. - /// The proxy factory to test. - [Test] - public void CanProxy_SimpleMethod_GenericTypes([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory) - { - // arrange - object implementation = new ProviderMod().GetModApi(); - string expectedKey = this.GetRandomString(); - string expectedValue = this.GetRandomString(); - - // act - ISimpleApi proxy = this.GetProxy(proxyFactory, implementation); - IDictionary> actualValue = proxy.GetGenerics(expectedKey, expectedValue); - - // assert - actualValue - .Should().NotBeNull() - .And.HaveCount(1) - .And.ContainKey(expectedKey).WhoseValue.Should().BeEquivalentTo(expectedValue); - } + // assert + actualValue.Should().BeEquivalentTo(expectedValue); + } - /// Assert that a simple lambda method can be proxied correctly. - /// The proxy factory to test. - [Test] - [SuppressMessage("ReSharper", "ConvertToLocalFunction")] - public void CanProxy_SimpleMethod_Lambda([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory) - { - // arrange - object implementation = new ProviderMod().GetModApi(); - Func expectedValue = _ => "test"; + /// Assert that a simple method which returns generic types can be proxied correctly. + /// The proxy factory to test. + [Test] + public void CanProxy_SimpleMethod_GenericTypes([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory) + { + // arrange + object implementation = new ProviderMod().GetModApi(); + string expectedKey = this.GetRandomString(); + string expectedValue = this.GetRandomString(); + + // act + ISimpleApi proxy = this.GetProxy(proxyFactory, implementation); + IDictionary> actualValue = proxy.GetGenerics(expectedKey, expectedValue); + + // assert + actualValue + .Should().NotBeNull() + .And.HaveCount(1) + .And.ContainKey(expectedKey).WhoseValue.Should().BeEquivalentTo(expectedValue); + } - // act - ISimpleApi proxy = this.GetProxy(proxyFactory, implementation); - object actualValue = proxy.GetObject(expectedValue); + /// Assert that a simple lambda method can be proxied correctly. + /// The proxy factory to test. + [Test] + [SuppressMessage("ReSharper", "ConvertToLocalFunction")] + public void CanProxy_SimpleMethod_Lambda([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory) + { + // arrange + object implementation = new ProviderMod().GetModApi(); + Func expectedValue = _ => "test"; - // assert - actualValue.Should().BeSameAs(expectedValue); - } + // act + ISimpleApi proxy = this.GetProxy(proxyFactory, implementation); + object actualValue = proxy.GetObject(expectedValue); - /// Assert that a method with out parameters can be proxied correctly. - /// The proxy factory to test. - [Test] - [SuppressMessage("ReSharper", "ConvertToLocalFunction")] - public void CanProxy_Method_OutParameters([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory) - { - // arrange - object implementation = new ProviderMod().GetModApi(); - const int expectedNumber = 42; + // assert + actualValue.Should().BeSameAs(expectedValue); + } - // act - ISimpleApi proxy = this.GetProxy(proxyFactory, implementation); - bool result = proxy.TryGetOutParameter( - inputNumber: expectedNumber, + /// Assert that a method with out parameters can be proxied correctly. + /// The proxy factory to test. + [Test] + [SuppressMessage("ReSharper", "ConvertToLocalFunction")] + public void CanProxy_Method_OutParameters([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory) + { + // arrange + object implementation = new ProviderMod().GetModApi(); + const int expectedNumber = 42; - out int outNumber, - out string outString, - out PerScreen outReference, - out IDictionary> outComplexType - ); + // act + ISimpleApi proxy = this.GetProxy(proxyFactory, implementation); + bool result = proxy.TryGetOutParameter( + inputNumber: expectedNumber, - // assert - result.Should().BeTrue(); + out int outNumber, + out string outString, + out PerScreen outReference, + out IDictionary> outComplexType + ); - outNumber.Should().Be(expectedNumber); + // assert + result.Should().BeTrue(); - outString.Should().Be(expectedNumber.ToString()); + outNumber.Should().Be(expectedNumber); - outReference.Should().NotBeNull(); - outReference.Value.Should().Be(expectedNumber); + outString.Should().Be(expectedNumber.ToString()); - outComplexType.Should().NotBeNull(); - outComplexType.Count.Should().Be(1); - outComplexType.Keys.First().Should().Be(expectedNumber); - outComplexType.Values.First().Should().NotBeNull(); - outComplexType.Values.First().Value.Should().Be(expectedNumber); - } + outReference.Should().NotBeNull(); + outReference.Value.Should().Be(expectedNumber); + outComplexType.Should().NotBeNull(); + outComplexType.Count.Should().Be(1); + outComplexType.Keys.First().Should().Be(expectedNumber); + outComplexType.Values.First().Should().NotBeNull(); + outComplexType.Values.First().Value.Should().Be(expectedNumber); + } - /********* - ** Private methods - *********/ - /// Get a property value from an instance. - /// The instance whose property to read. - /// The property name. - private object? GetPropertyValue(object parent, string name) - { - if (parent is null) - throw new ArgumentNullException(nameof(parent)); - Type type = parent.GetType(); - PropertyInfo? property = type.GetProperty(name); - if (property is null) - throw new InvalidOperationException($"The '{type.FullName}' type has no public property named '{name}'."); + /********* + ** Private methods + *********/ + /// Get a property value from an instance. + /// The instance whose property to read. + /// The property name. + private object? GetPropertyValue(object parent, string name) + { + if (parent is null) + throw new ArgumentNullException(nameof(parent)); - return property.GetValue(parent); - } + Type type = parent.GetType(); + PropertyInfo? property = type.GetProperty(name); + if (property is null) + throw new InvalidOperationException($"The '{type.FullName}' type has no public property named '{name}'."); - /// Get a random test string. - private string GetRandomString() - { - return this.Random.Next().ToString(); - } + return property.GetValue(parent); + } - /// Get a proxy API instance. - /// The proxy factory to use. - /// The underlying API instance. - private ISimpleApi GetProxy(IInterfaceProxyFactory proxyFactory, object implementation) - { - return proxyFactory.CreateProxy(implementation, this.FromModId, this.ToModId); - } + /// Get a random test string. + private string GetRandomString() + { + return this.Random.Next().ToString(); + } + + /// Get a proxy API instance. + /// The proxy factory to use. + /// The underlying API instance. + private ISimpleApi GetProxy(IInterfaceProxyFactory proxyFactory, object implementation) + { + return proxyFactory.CreateProxy(implementation, this.FromModId, this.ToModId); } } diff --git a/src/SMAPI.Tests/Core/ModResolverTests.cs b/src/SMAPI.Tests/Core/ModResolverTests.cs index 55df823ae..dd26355b4 100644 --- a/src/SMAPI.Tests/Core/ModResolverTests.cs +++ b/src/SMAPI.Tests/Core/ModResolverTests.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using FluentAssertions; using Moq; using Newtonsoft.Json; using NUnit.Framework; @@ -15,577 +16,574 @@ using StardewModdingAPI.Toolkit.Utilities.PathLookups; using SemanticVersion = StardewModdingAPI.SemanticVersion; -namespace SMAPI.Tests.Core +namespace SMAPI.Tests.Core; + +/// Unit tests for . +[TestFixture] +public class ModResolverTests { - /// Unit tests for . - [TestFixture] - public class ModResolverTests + /********* + ** Unit tests + *********/ + /**** + ** ReadManifests + ****/ + [Test(Description = "Assert that the resolver correctly returns an empty list if there are no mods installed.")] + public void ReadBasicManifest_NoMods_ReturnsEmptyList() { - /********* - ** Unit tests - *********/ - /**** - ** ReadManifests - ****/ - [Test(Description = "Assert that the resolver correctly returns an empty list if there are no mods installed.")] - public void ReadBasicManifest_NoMods_ReturnsEmptyList() - { - // arrange - string rootFolder = this.GetTempFolderPath(); - Directory.CreateDirectory(rootFolder); + // arrange + string rootFolder = this.GetTempFolderPath(); + Directory.CreateDirectory(rootFolder); - // act - IModMetadata[] mods = new ModResolver().ReadManifests(new ModToolkit(), rootFolder, new ModDatabase(), useCaseInsensitiveFilePaths: true).ToArray(); + // act + IModMetadata[] mods = new ModResolver().ReadManifests(new ModToolkit(), rootFolder, new ModDatabase(), useCaseInsensitiveFilePaths: true).ToArray(); - // assert - Assert.AreEqual(0, mods.Length, 0, $"Expected to find zero manifests, found {mods.Length} instead."); + // assert + mods.Should().BeEmpty("it should match number of mods input"); - // cleanup - Directory.Delete(rootFolder, recursive: true); - } + // cleanup + Directory.Delete(rootFolder, recursive: true); + } - [Test(Description = "Assert that the resolver correctly returns a failed metadata if there's an empty mod folder.")] - public void ReadBasicManifest_EmptyModFolder_ReturnsFailedManifest() - { - // arrange - string rootFolder = this.GetTempFolderPath(); - string modFolder = Path.Combine(rootFolder, Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(modFolder); - - // act - IModMetadata[] mods = new ModResolver().ReadManifests(new ModToolkit(), rootFolder, new ModDatabase(), useCaseInsensitiveFilePaths: true).ToArray(); - IModMetadata? mod = mods.FirstOrDefault(); - - // assert - Assert.AreEqual(1, mods.Length, 0, $"Expected to find one manifest, found {mods.Length} instead."); - Assert.AreEqual(ModMetadataStatus.Failed, mod!.Status, "The mod metadata was not marked failed."); - Assert.IsNotNull(mod.Error, "The mod metadata did not have an error message set."); - - // cleanup - Directory.Delete(rootFolder, recursive: true); - } + [Test(Description = "Assert that the resolver correctly returns a failed metadata if there's an empty mod folder.")] + public void ReadBasicManifest_EmptyModFolder_ReturnsFailedManifest() + { + // arrange + string rootFolder = this.GetTempFolderPath(); + string modFolder = Path.Combine(rootFolder, Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(modFolder); + + // act + IModMetadata[] mods = new ModResolver().ReadManifests(new ModToolkit(), rootFolder, new ModDatabase(), useCaseInsensitiveFilePaths: true).ToArray(); + IModMetadata? mod = mods.FirstOrDefault(); + + // assert + mods.Should().HaveCount(1, "it should match number of mods input"); + mod!.Status.Should().Be(ModMetadataStatus.Failed); + mod.Error.Should().NotBeNull(); + + // cleanup + Directory.Delete(rootFolder, recursive: true); + } - [Test(Description = "Assert that the resolver correctly reads manifest data from a randomized file.")] - public void ReadBasicManifest_CanReadFile() + [Test(Description = "Assert that the resolver correctly reads manifest data from a randomized file.")] + public void ReadBasicManifest_CanReadFile() + { + // create manifest data + IDictionary originalDependency = new Dictionary { - // create manifest data - IDictionary originalDependency = new Dictionary - { - [nameof(IManifestDependency.UniqueID)] = Sample.String() - }; - IDictionary original = new Dictionary - { - [nameof(IManifest.Name)] = Sample.String(), - [nameof(IManifest.Author)] = Sample.String(), - [nameof(IManifest.Version)] = new SemanticVersion(Sample.Int(), Sample.Int(), Sample.Int(), Sample.String()), - [nameof(IManifest.Description)] = Sample.String(), - [nameof(IManifest.UniqueID)] = $"{Sample.String()}.{Sample.String()}", - [nameof(IManifest.EntryDll)] = $"{Sample.String()}.dll", - [nameof(IManifest.MinimumApiVersion)] = $"{Sample.Int()}.{Sample.Int()}.{Sample.Int()}-{Sample.String()}", - [nameof(IManifest.MinimumGameVersion)] = $"{Sample.Int()}.{Sample.Int()}.{Sample.Int()}-{Sample.String()}", - [nameof(IManifest.Dependencies)] = new[] { originalDependency }, - ["ExtraString"] = Sample.String(), - ["ExtraInt"] = Sample.Int() - }; - - // write to filesystem - string rootFolder = this.GetTempFolderPath(); - string modFolder = Path.Combine(rootFolder, Guid.NewGuid().ToString("N")); - string filename = Path.Combine(modFolder, "manifest.json"); - Directory.CreateDirectory(modFolder); - File.WriteAllText(filename, JsonConvert.SerializeObject(original)); - - // act - IModMetadata[] mods = new ModResolver().ReadManifests(new ModToolkit(), rootFolder, new ModDatabase(), useCaseInsensitiveFilePaths: true).ToArray(); - IModMetadata? mod = mods.FirstOrDefault(); - - // assert - Assert.AreEqual(1, mods.Length, 0, "Expected to find one manifest."); - Assert.IsNotNull(mod, "The loaded manifest shouldn't be null."); - Assert.AreEqual(null, mod!.DataRecord, "The data record should be null since we didn't provide one."); - Assert.AreEqual(modFolder, mod.DirectoryPath, "The directory path doesn't match."); - Assert.AreEqual(null, mod.Error, "The error should be null since parsing should have succeeded."); - Assert.AreEqual(ModMetadataStatus.Found, mod.Status, "The status doesn't match."); - - Assert.AreEqual(original[nameof(IManifest.Name)], mod.DisplayName, "The display name should use the manifest name."); - Assert.AreEqual(original[nameof(IManifest.Name)], mod.Manifest.Name, "The manifest's name doesn't match."); - Assert.AreEqual(original[nameof(IManifest.Author)], mod.Manifest.Author, "The manifest's author doesn't match."); - Assert.AreEqual(original[nameof(IManifest.Description)], mod.Manifest.Description, "The manifest's description doesn't match."); - Assert.AreEqual(original[nameof(IManifest.EntryDll)], mod.Manifest.EntryDll, "The manifest's entry DLL doesn't match."); - Assert.AreEqual(original[nameof(IManifest.MinimumApiVersion)], mod.Manifest.MinimumApiVersion?.ToString(), "The manifest's minimum API version doesn't match."); - Assert.AreEqual(original[nameof(IManifest.MinimumGameVersion)], mod.Manifest.MinimumGameVersion?.ToString(), "The manifest's minimum game version doesn't match."); - Assert.AreEqual(original[nameof(IManifest.Version)].ToString(), mod.Manifest.Version.ToString(), "The manifest's version doesn't match."); - - Assert.IsNotNull(mod.Manifest.ExtraFields, "The extra fields should not be null."); - Assert.AreEqual(2, mod.Manifest.ExtraFields.Count, "The extra fields should contain two values."); - Assert.AreEqual(original["ExtraString"], mod.Manifest.ExtraFields["ExtraString"], "The manifest's extra fields should contain an 'ExtraString' value."); - Assert.AreEqual(original["ExtraInt"], mod.Manifest.ExtraFields["ExtraInt"], "The manifest's extra fields should contain an 'ExtraInt' value."); - - Assert.IsNotNull(mod.Manifest.Dependencies, "The dependencies field should not be null."); - Assert.AreEqual(1, mod.Manifest.Dependencies.Length, "The dependencies field should contain one value."); - Assert.AreEqual(originalDependency[nameof(IManifestDependency.UniqueID)], mod.Manifest.Dependencies[0].UniqueID, "The first dependency's unique ID doesn't match."); - - // cleanup - Directory.Delete(rootFolder, recursive: true); - } - - /**** - ** ValidateManifests - ****/ - [Test(Description = "Assert that validation doesn't fail if there are no mods installed.")] - public void ValidateManifests_NoMods_DoesNothing() + [nameof(IManifestDependency.UniqueID)] = Sample.String() + }; + IDictionary original = new Dictionary { - new ModResolver().ValidateManifests(Array.Empty(), apiVersion: new SemanticVersion("1.0.0"), gameVersion: new SemanticVersion("1.0.0"), getUpdateUrl: _ => null, getFileLookup: this.GetFileLookup, validateFilesExist: false); - } + [nameof(IManifest.Name)] = Sample.String(), + [nameof(IManifest.Author)] = Sample.String(), + [nameof(IManifest.Version)] = new SemanticVersion(Sample.Int(), Sample.Int(), Sample.Int(), Sample.String()), + [nameof(IManifest.Description)] = Sample.String(), + [nameof(IManifest.UniqueID)] = $"{Sample.String()}.{Sample.String()}", + [nameof(IManifest.EntryDll)] = $"{Sample.String()}.dll", + [nameof(IManifest.MinimumApiVersion)] = $"{Sample.Int()}.{Sample.Int()}.{Sample.Int()}-{Sample.String()}", + [nameof(IManifest.MinimumGameVersion)] = $"{Sample.Int()}.{Sample.Int()}.{Sample.Int()}-{Sample.String()}", + [nameof(IManifest.Dependencies)] = new[] { originalDependency }, + ["ExtraString"] = Sample.String(), + ["ExtraInt"] = Sample.Int() + }; + + // write to filesystem + string rootFolder = this.GetTempFolderPath(); + string modFolder = Path.Combine(rootFolder, Guid.NewGuid().ToString("N")); + string filename = Path.Combine(modFolder, "manifest.json"); + Directory.CreateDirectory(modFolder); + File.WriteAllText(filename, JsonConvert.SerializeObject(original)); + + // act + IModMetadata[] mods = new ModResolver().ReadManifests(new ModToolkit(), rootFolder, new ModDatabase(), useCaseInsensitiveFilePaths: true).ToArray(); + IModMetadata? mod = mods.FirstOrDefault(); + + // assert + mods.Should().HaveCount(1, "it should match number of mods input"); + mod.Should().NotBeNull(); + mod!.DataRecord.Should().BeNull("we didn't provide one"); + mod.DirectoryPath.Should().Be(modFolder); + mod.Error.Should().BeNull(); + mod.Status.Should().Be(ModMetadataStatus.Found); + + mod.DisplayName.Should().Be((string)original[nameof(IManifest.Name)], mod.DisplayName); + mod.Manifest.Name.Should().Be((string)original[nameof(IManifest.Name)], mod.Manifest.Name); + mod.Manifest.ExtraFields.Should() + .NotBeNull() + .And.HaveCount(2) + .And.ContainKeys("ExtraString", "ExtraInt"); + mod.Manifest.ExtraFields["ExtraString"].Should().Be(original["ExtraString"]); + mod.Manifest.ExtraFields["ExtraInt"].Should().Be(original["ExtraInt"]); + + mod.Manifest.Dependencies.Should() + .NotBeNull() + .And.HaveCount(1); + mod.Manifest.Dependencies[0].Should().NotBeNull(); + mod.Manifest.Dependencies[0].UniqueID.Should().Be((string)originalDependency[nameof(IManifestDependency.UniqueID)]); + + // cleanup + Directory.Delete(rootFolder, recursive: true); + } - [Test(Description = "Assert that validation skips manifests that have already failed without calling any other properties.")] - public void ValidateManifests_Skips_Failed() - { - // arrange - Mock mock = this.GetMetadata("Mod A"); - mock.Setup(p => p.Status).Returns(ModMetadataStatus.Failed); + /**** + ** ValidateManifests + ****/ + [Test(Description = "Assert that validation doesn't fail if there are no mods installed.")] + public void ValidateManifests_NoMods_DoesNothing() + { + new ModResolver().ValidateManifests(Array.Empty(), apiVersion: new SemanticVersion("1.0.0"), gameVersion: new SemanticVersion("1.0.0"), getUpdateUrl: _ => null, getFileLookup: this.GetFileLookup, validateFilesExist: false); + } - // act - new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0.0"), gameVersion: new SemanticVersion("1.0.0"), getUpdateUrl: _ => null, getFileLookup: this.GetFileLookup, validateFilesExist: false); + [Test(Description = "Assert that validation skips manifests that have already failed without calling any other properties.")] + public void ValidateManifests_Skips_Failed() + { + // arrange + Mock mock = this.GetMetadata("Mod A"); + mock.Setup(p => p.Status).Returns(ModMetadataStatus.Failed); - // assert - mock.VerifyGet(p => p.Status, Times.Once, "The validation did not check the manifest status."); - } + // act + new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0.0"), gameVersion: new SemanticVersion("1.0.0"), getUpdateUrl: _ => null, getFileLookup: this.GetFileLookup, validateFilesExist: false); - [Test(Description = "Assert that validation fails if the mod has 'assume broken' status.")] - public void ValidateManifests_ModStatus_AssumeBroken_Fails() - { - // arrange - Mock mock = this.GetMetadata("Mod A", Array.Empty(), allowStatusChange: true); - mock.Setup(p => p.DataRecord).Returns(() => new ModDataRecordVersionedFields(this.GetModDataRecord()) - { - Status = ModStatus.AssumeBroken - }); - - // act - new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0.0"), gameVersion: new SemanticVersion("1.0.0"), getUpdateUrl: _ => null, getFileLookup: this.GetFileLookup, validateFilesExist: false); - - // assert - mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once, "The validation did not fail the metadata."); - } + // assert + mock.VerifyGet(p => p.Status, Times.Once, "The validation did not check the manifest status."); + } - [Test(Description = "Assert that validation fails when the minimum API version is higher than the current SMAPI version.")] - public void ValidateManifests_MinimumApiVersion_Fails() + [Test(Description = "Assert that validation fails if the mod has 'assume broken' status.")] + public void ValidateManifests_ModStatus_AssumeBroken_Fails() + { + // arrange + Mock mock = this.GetMetadata("Mod A", Array.Empty(), allowStatusChange: true); + mock.Setup(p => p.DataRecord).Returns(() => new ModDataRecordVersionedFields(this.GetModDataRecord()) { - // arrange - Mock mock = this.GetMetadata("Mod A", Array.Empty(), allowStatusChange: true); - mock.Setup(p => p.Manifest).Returns(this.GetManifest(minimumApiVersion: "1.1")); + Status = ModStatus.AssumeBroken + }); - // act - new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0.0"), gameVersion: new SemanticVersion("1.0.0"), getUpdateUrl: _ => null, getFileLookup: this.GetFileLookup, validateFilesExist: false); + // act + new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0.0"), gameVersion: new SemanticVersion("1.0.0"), getUpdateUrl: _ => null, getFileLookup: this.GetFileLookup, validateFilesExist: false); - // assert - mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once, "The validation did not fail the metadata."); - } + // assert + mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once, "The validation did not fail the metadata."); + } - [Test(Description = "Assert that validation fails when the minimum game version is higher than the current Stardew Valley version.")] - public void ValidateManifests_MinimumGameVersion_Fails() - { - // arrange - Mock mock = this.GetMetadata("Mod A", Array.Empty(), allowStatusChange: true); - mock.Setup(p => p.Manifest).Returns(this.GetManifest(minimumGameVersion: "1.6.9")); + [Test(Description = "Assert that validation fails when the minimum API version is higher than the current SMAPI version.")] + public void ValidateManifests_MinimumApiVersion_Fails() + { + // arrange + Mock mock = this.GetMetadata("Mod A", Array.Empty(), allowStatusChange: true); + mock.Setup(p => p.Manifest).Returns(this.GetManifest(minimumApiVersion: "1.1")); - // act - new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0.0"), gameVersion: new SemanticVersion("1.0.0"), getUpdateUrl: _ => null, getFileLookup: this.GetFileLookup, validateFilesExist: false); + // act + new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0.0"), gameVersion: new SemanticVersion("1.0.0"), getUpdateUrl: _ => null, getFileLookup: this.GetFileLookup, validateFilesExist: false); - // assert - mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once, "The validation did not fail the metadata."); - } + // assert + mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once, "The validation did not fail the metadata."); + } - [Test(Description = "Assert that validation fails when the manifest references a DLL that does not exist.")] - public void ValidateManifests_MissingEntryDLL_Fails() - { - // arrange - string directoryPath = this.GetTempFolderPath(); - Mock mock = this.GetMetadata(this.GetManifest(id: "Mod A", version: "1.0", entryDll: "Missing.dll"), allowStatusChange: true, directoryPath: directoryPath); - Directory.CreateDirectory(directoryPath); + [Test(Description = "Assert that validation fails when the minimum game version is higher than the current Stardew Valley version.")] + public void ValidateManifests_MinimumGameVersion_Fails() + { + // arrange + Mock mock = this.GetMetadata("Mod A", Array.Empty(), allowStatusChange: true); + mock.Setup(p => p.Manifest).Returns(this.GetManifest(minimumGameVersion: "1.6.9")); - // act - new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0.0"), gameVersion: new SemanticVersion("1.0.0"), getUpdateUrl: _ => null, getFileLookup: this.GetFileLookup); + // act + new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0.0"), gameVersion: new SemanticVersion("1.0.0"), getUpdateUrl: _ => null, getFileLookup: this.GetFileLookup, validateFilesExist: false); - // assert - mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once, "The validation did not fail the metadata."); + // assert + mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once, "The validation did not fail the metadata."); + } - // cleanup - Directory.Delete(directoryPath); - } + [Test(Description = "Assert that validation fails when the manifest references a DLL that does not exist.")] + public void ValidateManifests_MissingEntryDLL_Fails() + { + // arrange + string directoryPath = this.GetTempFolderPath(); + Mock mock = this.GetMetadata(this.GetManifest(id: "Mod A", version: "1.0", entryDll: "Missing.dll"), allowStatusChange: true, directoryPath: directoryPath); + Directory.CreateDirectory(directoryPath); - [Test(Description = "Assert that validation fails when multiple mods have the same unique ID.")] - public void ValidateManifests_DuplicateUniqueID_Fails() - { - // arrange - Mock modA = this.GetMetadata("Mod A", Array.Empty(), allowStatusChange: true); - Mock modB = this.GetMetadata(this.GetManifest(id: "Mod A", name: "Mod B", version: "1.0"), allowStatusChange: true); + // act + new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0.0"), gameVersion: new SemanticVersion("1.0.0"), getUpdateUrl: _ => null, getFileLookup: this.GetFileLookup); - // act - new ModResolver().ValidateManifests(new[] { modA.Object, modB.Object }, apiVersion: new SemanticVersion("1.0.0"), gameVersion: new SemanticVersion("1.0.0"), getUpdateUrl: _ => null, getFileLookup: this.GetFileLookup, validateFilesExist: false); + // assert + mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once, "The validation did not fail the metadata."); - // assert - modA.Verify(p => p.SetStatus(ModMetadataStatus.Failed, ModFailReason.Duplicate, It.IsAny(), It.IsAny()), Times.AtLeastOnce, "The validation did not fail the first mod with a unique ID."); - modB.Verify(p => p.SetStatus(ModMetadataStatus.Failed, ModFailReason.Duplicate, It.IsAny(), It.IsAny()), Times.AtLeastOnce, "The validation did not fail the second mod with a unique ID."); - } + // cleanup + Directory.Delete(directoryPath); + } - [Test(Description = "Assert that validation fails when the manifest references a DLL that does not exist.")] - public void ValidateManifests_Valid_Passes() - { - // set up manifest - IManifest manifest = this.GetManifest(); + [Test(Description = "Assert that validation fails when multiple mods have the same unique ID.")] + public void ValidateManifests_DuplicateUniqueID_Fails() + { + // arrange + Mock modA = this.GetMetadata("Mod A", Array.Empty(), allowStatusChange: true); + Mock modB = this.GetMetadata(this.GetManifest(id: "Mod A", name: "Mod B", version: "1.0"), allowStatusChange: true); - // create DLL - string modFolder = Path.Combine(this.GetTempFolderPath(), Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(modFolder); - File.WriteAllText(Path.Combine(modFolder, manifest.EntryDll!), ""); + // act + new ModResolver().ValidateManifests(new[] { modA.Object, modB.Object }, apiVersion: new SemanticVersion("1.0.0"), gameVersion: new SemanticVersion("1.0.0"), getUpdateUrl: _ => null, getFileLookup: this.GetFileLookup, validateFilesExist: false); - // arrange - Mock mock = new(MockBehavior.Strict); - mock.Setup(p => p.Status).Returns(ModMetadataStatus.Found); - mock.Setup(p => p.DataRecord).Returns(this.GetModDataRecordVersionedFields()); - mock.Setup(p => p.Manifest).Returns(manifest); - mock.Setup(p => p.DirectoryPath).Returns(modFolder); + // assert + modA.Verify(p => p.SetStatus(ModMetadataStatus.Failed, ModFailReason.Duplicate, It.IsAny(), It.IsAny()), Times.AtLeastOnce, "The validation did not fail the first mod with a unique ID."); + modB.Verify(p => p.SetStatus(ModMetadataStatus.Failed, ModFailReason.Duplicate, It.IsAny(), It.IsAny()), Times.AtLeastOnce, "The validation did not fail the second mod with a unique ID."); + } - // act - new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0.0"), gameVersion: new SemanticVersion("1.0.0"), getUpdateUrl: _ => null, getFileLookup: this.GetFileLookup); + [Test(Description = "Assert that validation fails when the manifest references a DLL that does not exist.")] + public void ValidateManifests_Valid_Passes() + { + // set up manifest + IManifest manifest = this.GetManifest(); - // assert - // if Moq doesn't throw a method-not-setup exception, the validation didn't override the status. + // create DLL + string modFolder = Path.Combine(this.GetTempFolderPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(modFolder); + File.WriteAllText(Path.Combine(modFolder, manifest.EntryDll!), ""); - // cleanup - Directory.Delete(modFolder, recursive: true); - } + // arrange + Mock mock = new(MockBehavior.Strict); + mock.Setup(p => p.Status).Returns(ModMetadataStatus.Found); + mock.Setup(p => p.DataRecord).Returns(this.GetModDataRecordVersionedFields()); + mock.Setup(p => p.Manifest).Returns(manifest); + mock.Setup(p => p.DirectoryPath).Returns(modFolder); - /**** - ** ProcessDependencies - ****/ - [Test(Description = "Assert that processing dependencies doesn't fail if there are no mods installed.")] - public void ProcessDependencies_NoMods_DoesNothing() - { - // act - IModMetadata[] mods = new ModResolver().ProcessDependencies(Array.Empty(), new ModDatabase()).ToArray(); + // act + new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0.0"), gameVersion: new SemanticVersion("1.0.0"), getUpdateUrl: _ => null, getFileLookup: this.GetFileLookup); - // assert - Assert.AreEqual(0, mods.Length, 0, "Expected to get an empty list of mods."); - } + // assert + // if Moq doesn't throw a method-not-setup exception, the validation didn't override the status. - [Test(Description = "Assert that processing dependencies doesn't change the order if there are no mod dependencies.")] - public void ProcessDependencies_NoDependencies_DoesNothing() - { - // arrange - // A B C - Mock modA = this.GetMetadata("Mod A"); - Mock modB = this.GetMetadata("Mod B"); - Mock modC = this.GetMetadata("Mod C"); - - // act - IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modA.Object, modB.Object, modC.Object }, new ModDatabase()).ToArray(); - - // assert - Assert.AreEqual(3, mods.Length, 0, "Expected to get the same number of mods input."); - Assert.AreSame(modA.Object, mods[0], "The load order unexpectedly changed with no dependencies."); - Assert.AreSame(modB.Object, mods[1], "The load order unexpectedly changed with no dependencies."); - Assert.AreSame(modC.Object, mods[2], "The load order unexpectedly changed with no dependencies."); - } + // cleanup + Directory.Delete(modFolder, recursive: true); + } - [Test(Description = "Assert that processing dependencies skips mods that have already failed without calling any other properties.")] - public void ProcessDependencies_Skips_Failed() - { - // arrange - Mock mock = new(MockBehavior.Strict); - mock.Setup(p => p.Status).Returns(ModMetadataStatus.Failed); + /**** + ** ProcessDependencies + ****/ + [Test(Description = "Assert that processing dependencies doesn't fail if there are no mods installed.")] + public void ProcessDependencies_NoMods_DoesNothing() + { + // act + IModMetadata[] mods = new ModResolver().ProcessDependencies(Array.Empty(), new ModDatabase()).ToArray(); - // act - new ModResolver().ProcessDependencies(new[] { mock.Object }, new ModDatabase()); + // assert + mods.Should().BeEmpty("it should match number of mods input"); + } - // assert - mock.VerifyGet(p => p.Status, Times.Once, "The validation did not check the manifest status."); - } + [Test(Description = "Assert that processing dependencies doesn't change the order if there are no mod dependencies.")] + public void ProcessDependencies_NoDependencies_DoesNothing() + { + // arrange + // A B C + Mock modA = this.GetMetadata("Mod A"); + Mock modB = this.GetMetadata("Mod B"); + Mock modC = this.GetMetadata("Mod C"); + + // act + IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modA.Object, modB.Object, modC.Object }, new ModDatabase()).ToArray(); + + // assert + mods.Should().HaveCount(3, "it should match number of mods input"); + mods[0].Should().BeSameAs(modA.Object, "the load order shouldn't change with no dependencies"); + mods[1].Should().BeSameAs(modB.Object, "the load order shouldn't change with no dependencies"); + mods[2].Should().BeSameAs(modC.Object, "the load order shouldn't change with no dependencies"); + } - [Test(Description = "Assert that simple dependencies are reordered correctly.")] - public void ProcessDependencies_Reorders_SimpleDependencies() - { - // arrange - // A ◀── B - // ▲ ▲ - // │ │ - // └─ C ─┘ - Mock modA = this.GetMetadata("Mod A"); - Mock modB = this.GetMetadata("Mod B", dependencies: new[] { "Mod A" }); - Mock modC = this.GetMetadata("Mod C", dependencies: new[] { "Mod A", "Mod B" }); - - // act - IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modC.Object, modA.Object, modB.Object }, new ModDatabase()).ToArray(); - - // assert - Assert.AreEqual(3, mods.Length, 0, "Expected to get the same number of mods input."); - Assert.AreSame(modA.Object, mods[0], "The load order is incorrect: mod A should be first since the other mods depend on it."); - Assert.AreSame(modB.Object, mods[1], "The load order is incorrect: mod B should be second since it needs mod A, and is needed by mod C."); - Assert.AreSame(modC.Object, mods[2], "The load order is incorrect: mod C should be third since it needs both mod A and mod B."); - } + [Test(Description = "Assert that processing dependencies skips mods that have already failed without calling any other properties.")] + public void ProcessDependencies_Skips_Failed() + { + // arrange + Mock mock = new(MockBehavior.Strict); + mock.Setup(p => p.Status).Returns(ModMetadataStatus.Failed); - [Test(Description = "Assert that simple dependency chains are reordered correctly.")] - public void ProcessDependencies_Reorders_DependencyChain() - { - // arrange - // A ◀── B ◀── C ◀── D - Mock modA = this.GetMetadata("Mod A"); - Mock modB = this.GetMetadata("Mod B", dependencies: new[] { "Mod A" }); - Mock modC = this.GetMetadata("Mod C", dependencies: new[] { "Mod B" }); - Mock modD = this.GetMetadata("Mod D", dependencies: new[] { "Mod C" }); - - // act - IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modC.Object, modA.Object, modB.Object, modD.Object }, new ModDatabase()).ToArray(); - - // assert - Assert.AreEqual(4, mods.Length, 0, "Expected to get the same number of mods input."); - Assert.AreSame(modA.Object, mods[0], "The load order is incorrect: mod A should be first since it's needed by mod B."); - Assert.AreSame(modB.Object, mods[1], "The load order is incorrect: mod B should be second since it needs mod A, and is needed by mod C."); - Assert.AreSame(modC.Object, mods[2], "The load order is incorrect: mod C should be third since it needs mod B, and is needed by mod D."); - Assert.AreSame(modD.Object, mods[3], "The load order is incorrect: mod D should be fourth since it needs mod C."); - } + // act + new ModResolver().ProcessDependencies(new[] { mock.Object }, new ModDatabase()); - [Test(Description = "Assert that overlapping dependency chains are reordered correctly.")] - public void ProcessDependencies_Reorders_OverlappingDependencyChain() - { - // arrange - // A ◀── B ◀── C ◀── D - // ▲ ▲ - // │ │ - // E ◀── F - Mock modA = this.GetMetadata("Mod A"); - Mock modB = this.GetMetadata("Mod B", dependencies: new[] { "Mod A" }); - Mock modC = this.GetMetadata("Mod C", dependencies: new[] { "Mod B" }); - Mock modD = this.GetMetadata("Mod D", dependencies: new[] { "Mod C" }); - Mock modE = this.GetMetadata("Mod E", dependencies: new[] { "Mod B" }); - Mock modF = this.GetMetadata("Mod F", dependencies: new[] { "Mod C", "Mod E" }); - - // act - IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modC.Object, modA.Object, modB.Object, modD.Object, modF.Object, modE.Object }, new ModDatabase()).ToArray(); - - // assert - Assert.AreEqual(6, mods.Length, 0, "Expected to get the same number of mods input."); - Assert.AreSame(modA.Object, mods[0], "The load order is incorrect: mod A should be first since it's needed by mod B."); - Assert.AreSame(modB.Object, mods[1], "The load order is incorrect: mod B should be second since it needs mod A, and is needed by mod C."); - Assert.AreSame(modC.Object, mods[2], "The load order is incorrect: mod C should be third since it needs mod B, and is needed by mod D."); - Assert.AreSame(modD.Object, mods[3], "The load order is incorrect: mod D should be fourth since it needs mod C."); - Assert.AreSame(modE.Object, mods[4], "The load order is incorrect: mod E should be fifth since it needs mod B, but is specified after C which also needs mod B."); - Assert.AreSame(modF.Object, mods[5], "The load order is incorrect: mod F should be last since it needs mods E and C."); - } + // assert + mock.VerifyGet(p => p.Status, Times.Once, "The validation did not check the manifest status."); + } - [Test(Description = "Assert that mods with circular dependency chains are skipped, but any other mods are loaded in the correct order.")] - public void ProcessDependencies_Skips_CircularDependentMods() - { - // arrange - // A ◀── B ◀── C ──▶ D - // ▲ │ - // │ ▼ - // └──── E - Mock modA = this.GetMetadata("Mod A"); - Mock modB = this.GetMetadata("Mod B", dependencies: new[] { "Mod A" }); - Mock modC = this.GetMetadata("Mod C", dependencies: new[] { "Mod B", "Mod D" }, allowStatusChange: true); - Mock modD = this.GetMetadata("Mod D", dependencies: new[] { "Mod E" }, allowStatusChange: true); - Mock modE = this.GetMetadata("Mod E", dependencies: new[] { "Mod C" }, allowStatusChange: true); - - // act - IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modC.Object, modA.Object, modB.Object, modD.Object, modE.Object }, new ModDatabase()).ToArray(); - - // assert - Assert.AreEqual(5, mods.Length, 0, "Expected to get the same number of mods input."); - Assert.AreSame(modA.Object, mods[0], "The load order is incorrect: mod A should be first since it's needed by mod B."); - Assert.AreSame(modB.Object, mods[1], "The load order is incorrect: mod B should be second since it needs mod A."); - modC.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once, "Mod C was expected to fail since it's part of a dependency loop."); - modD.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once, "Mod D was expected to fail since it's part of a dependency loop."); - modE.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once, "Mod E was expected to fail since it's part of a dependency loop."); - } + [Test(Description = "Assert that simple dependencies are reordered correctly.")] + public void ProcessDependencies_Reorders_SimpleDependencies() + { + // arrange + // A ◀── B + // ▲ ▲ + // │ │ + // └─ C ─┘ + Mock modA = this.GetMetadata("Mod A"); + Mock modB = this.GetMetadata("Mod B", dependencies: new[] { "Mod A" }); + Mock modC = this.GetMetadata("Mod C", dependencies: new[] { "Mod A", "Mod B" }); + + // act + IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modC.Object, modA.Object, modB.Object }, new ModDatabase()).ToArray(); + + // assert + mods.Should().HaveCount(3, "it should match number of mods input"); + mods[0].Should().BeSameAs(modA.Object, "mod A should be first since the other mods depend on it"); + mods[1].Should().BeSameAs(modB.Object, "mod B should be second since it needs mod A, and is needed by mod C"); + mods[2].Should().BeSameAs(modC.Object, "mod C should be third since it needs both mod A and mod B"); + } - [Test(Description = "Assert that dependencies are sorted correctly even if some of the mods failed during metadata loading.")] - public void ProcessDependencies_WithSomeFailedMods_Succeeds() - { - // arrange - // A ◀── B ◀── C D (failed) - Mock modA = this.GetMetadata("Mod A"); - Mock modB = this.GetMetadata("Mod B", dependencies: new[] { "Mod A" }); - Mock modC = this.GetMetadata("Mod C", dependencies: new[] { "Mod B" }, allowStatusChange: true); - Mock modD = new(MockBehavior.Strict); - modD.Setup(p => p.Manifest).Returns(null); - modD.Setup(p => p.Status).Returns(ModMetadataStatus.Failed); - - // act - IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modC.Object, modA.Object, modB.Object, modD.Object }, new ModDatabase()).ToArray(); - - // assert - Assert.AreEqual(4, mods.Length, 0, "Expected to get the same number of mods input."); - Assert.AreSame(modD.Object, mods[0], "The load order is incorrect: mod D should be first since it was already failed."); - Assert.AreSame(modA.Object, mods[1], "The load order is incorrect: mod A should be second since it's needed by mod B."); - Assert.AreSame(modB.Object, mods[2], "The load order is incorrect: mod B should be third since it needs mod A, and is needed by mod C."); - Assert.AreSame(modC.Object, mods[3], "The load order is incorrect: mod C should be fourth since it needs mod B, and is needed by mod D."); - } + [Test(Description = "Assert that simple dependency chains are reordered correctly.")] + public void ProcessDependencies_Reorders_DependencyChain() + { + // arrange + // A ◀── B ◀── C ◀── D + Mock modA = this.GetMetadata("Mod A"); + Mock modB = this.GetMetadata("Mod B", dependencies: new[] { "Mod A" }); + Mock modC = this.GetMetadata("Mod C", dependencies: new[] { "Mod B" }); + Mock modD = this.GetMetadata("Mod D", dependencies: new[] { "Mod C" }); + + // act + IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modC.Object, modA.Object, modB.Object, modD.Object }, new ModDatabase()).ToArray(); + + // assert + mods.Should().HaveCount(4, "it should match number of mods input"); + mods[0].Should().BeSameAs(modA.Object, "mod A should be first since it's needed by mod B"); + mods[1].Should().BeSameAs(modB.Object, "mod B should be second since it needs mod A, and is needed by mod C"); + mods[2].Should().BeSameAs(modC.Object, "mod C should be third since it needs mod B, and is needed by mod D"); + mods[3].Should().BeSameAs(modD.Object, "mod D should be fourth since it needs mod C"); + } - [Test(Description = "Assert that dependencies are failed if they don't meet the minimum version.")] - public void ProcessDependencies_WithMinVersions_FailsIfNotMet() - { - // arrange - // A 1.0 ◀── B (need A 1.1) - Mock modA = this.GetMetadata(this.GetManifest(id: "Mod A", version: "1.0")); - Mock modB = this.GetMetadata(this.GetManifest(id: "Mod B", version: "1.0", dependencies: new IManifestDependency[] { new ManifestDependency("Mod A", "1.1") }), allowStatusChange: true); + [Test(Description = "Assert that overlapping dependency chains are reordered correctly.")] + public void ProcessDependencies_Reorders_OverlappingDependencyChain() + { + // arrange + // A ◀── B ◀── C ◀── D + // ▲ ▲ + // │ │ + // E ◀── F + Mock modA = this.GetMetadata("Mod A"); + Mock modB = this.GetMetadata("Mod B", dependencies: new[] { "Mod A" }); + Mock modC = this.GetMetadata("Mod C", dependencies: new[] { "Mod B" }); + Mock modD = this.GetMetadata("Mod D", dependencies: new[] { "Mod C" }); + Mock modE = this.GetMetadata("Mod E", dependencies: new[] { "Mod B" }); + Mock modF = this.GetMetadata("Mod F", dependencies: new[] { "Mod C", "Mod E" }); + + // act + IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modC.Object, modA.Object, modB.Object, modD.Object, modF.Object, modE.Object }, new ModDatabase()).ToArray(); + + // assert + mods.Should().HaveCount(6, "it should match number of mods input"); + mods[0].Should().BeSameAs(modA.Object, "mod A should be first since it's needed by mod B"); + mods[1].Should().BeSameAs(modB.Object, "mod B should be second since it needs mod A, and is needed by mod C"); + mods[2].Should().BeSameAs(modC.Object, "mod C should be third since it needs mod B, and is needed by mod D"); + mods[3].Should().BeSameAs(modD.Object, "mod D should be fourth since it needs mod C"); + mods[4].Should().BeSameAs(modE.Object, "mod E should be fifth since it needs mod B, but is specified after C which also needs mod B"); + mods[5].Should().BeSameAs(modF.Object, "mod F should be last since it needs mods E and C"); + } - // act - IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modA.Object, modB.Object }, new ModDatabase()).ToArray(); + [Test(Description = "Assert that mods with circular dependency chains are skipped, but any other mods are loaded in the correct order.")] + public void ProcessDependencies_Skips_CircularDependentMods() + { + // arrange + // A ◀── B ◀── C ──▶ D + // ▲ │ + // │ ▼ + // └──── E + Mock modA = this.GetMetadata("Mod A"); + Mock modB = this.GetMetadata("Mod B", dependencies: new[] { "Mod A" }); + Mock modC = this.GetMetadata("Mod C", dependencies: new[] { "Mod B", "Mod D" }, allowStatusChange: true); + Mock modD = this.GetMetadata("Mod D", dependencies: new[] { "Mod E" }, allowStatusChange: true); + Mock modE = this.GetMetadata("Mod E", dependencies: new[] { "Mod C" }, allowStatusChange: true); + + // act + IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modC.Object, modA.Object, modB.Object, modD.Object, modE.Object }, new ModDatabase()).ToArray(); + + // assert + mods.Should().HaveCount(5, "it should match number of mods input"); + mods[0].Should().BeSameAs(modA.Object, "mod A should be first since it's needed by mod B"); + mods[1].Should().BeSameAs(modB.Object, "mod B should be second since it needs mod A"); + modC.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once, "Mod C was expected to fail since it's part of a dependency loop."); + modD.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once, "Mod D was expected to fail since it's part of a dependency loop."); + modE.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once, "Mod E was expected to fail since it's part of a dependency loop."); + } - // assert - Assert.AreEqual(2, mods.Length, 0, "Expected to get the same number of mods input."); - modB.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once, "Mod B unexpectedly didn't fail even though it needs a newer version of Mod A."); - } + [Test(Description = "Assert that dependencies are sorted correctly even if some of the mods failed during metadata loading.")] + public void ProcessDependencies_WithSomeFailedMods_Succeeds() + { + // arrange + // A ◀── B ◀── C D (failed) + Mock modA = this.GetMetadata("Mod A"); + Mock modB = this.GetMetadata("Mod B", dependencies: new[] { "Mod A" }); + Mock modC = this.GetMetadata("Mod C", dependencies: new[] { "Mod B" }, allowStatusChange: true); + Mock modD = new(MockBehavior.Strict); + modD.Setup(p => p.Manifest).Returns(null!); // deliberately testing null handling + modD.Setup(p => p.Status).Returns(ModMetadataStatus.Failed); + + // act + IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modC.Object, modA.Object, modB.Object, modD.Object }, new ModDatabase()).ToArray(); + + // assert + mods.Should().HaveCount(4, "it should match number of mods input"); + mods[0].Should().BeSameAs(modD.Object, "mod D should be first since it was already failed"); + mods[1].Should().BeSameAs(modA.Object, "mod A should be second since it's needed by mod B"); + mods[2].Should().BeSameAs(modB.Object, "mod B should be third since it needs mod A, and is needed by mod C"); + mods[3].Should().BeSameAs(modC.Object, "mod C should be fourth since it needs mod B, and is needed by mod D"); + } - [Test(Description = "Assert that dependencies are accepted if they meet the minimum version.")] - public void ProcessDependencies_WithMinVersions_SucceedsIfMet() - { - // arrange - // A 1.0 ◀── B (need A 1.0-beta) - Mock modA = this.GetMetadata(this.GetManifest(id: "Mod A", version: "1.0")); - Mock modB = this.GetMetadata(this.GetManifest(id: "Mod B", version: "1.0", dependencies: new IManifestDependency[] { new ManifestDependency("Mod A", "1.0-beta") }), allowStatusChange: false); - - // act - IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modA.Object, modB.Object }, new ModDatabase()).ToArray(); - - // assert - Assert.AreEqual(2, mods.Length, 0, "Expected to get the same number of mods input."); - Assert.AreSame(modA.Object, mods[0], "The load order is incorrect: mod A should be first since it's needed by mod B."); - Assert.AreSame(modB.Object, mods[1], "The load order is incorrect: mod B should be second since it needs mod A."); - } + [Test(Description = "Assert that dependencies are failed if they don't meet the minimum version.")] + public void ProcessDependencies_WithMinVersions_FailsIfNotMet() + { + // arrange + // A 1.0 ◀── B (need A 1.1) + Mock modA = this.GetMetadata(this.GetManifest(id: "Mod A", version: "1.0")); + Mock modB = this.GetMetadata(this.GetManifest(id: "Mod B", version: "1.0", dependencies: new IManifestDependency[] { new ManifestDependency("Mod A", "1.1") }), allowStatusChange: true); - [Test(Description = "Assert that optional dependencies are sorted correctly if present.")] - public void ProcessDependencies_IfOptional() - { - // arrange - // A ◀── B - Mock modA = this.GetMetadata(this.GetManifest(id: "Mod A", version: "1.0")); - Mock modB = this.GetMetadata(this.GetManifest(id: "Mod B", version: "1.0", dependencies: new IManifestDependency[] { new ManifestDependency("Mod A", "1.0", required: false) }), allowStatusChange: false); - - // act - IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modB.Object, modA.Object }, new ModDatabase()).ToArray(); - - // assert - Assert.AreEqual(2, mods.Length, 0, "Expected to get the same number of mods input."); - Assert.AreSame(modA.Object, mods[0], "The load order is incorrect: mod A should be first since it's needed by mod B."); - Assert.AreSame(modB.Object, mods[1], "The load order is incorrect: mod B should be second since it needs mod A."); - } + // act + IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modA.Object, modB.Object }, new ModDatabase()).ToArray(); - [Test(Description = "Assert that optional dependencies are accepted if they're missing.")] - public void ProcessDependencies_IfOptional_SucceedsIfMissing() - { - // arrange - // A ◀── B where A doesn't exist - Mock modB = this.GetMetadata(this.GetManifest(id: "Mod B", version: "1.0", dependencies: new IManifestDependency[] { new ManifestDependency("Mod A", "1.0", required: false) }), allowStatusChange: false); + // assert + mods.Should().HaveCount(2, "it should match number of mods input"); + modB.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once, "Mod B unexpectedly didn't fail even though it needs a newer version of Mod A."); + } - // act - IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modB.Object }, new ModDatabase()).ToArray(); + [Test(Description = "Assert that dependencies are accepted if they meet the minimum version.")] + public void ProcessDependencies_WithMinVersions_SucceedsIfMet() + { + // arrange + // A 1.0 ◀── B (need A 1.0-beta) + Mock modA = this.GetMetadata(this.GetManifest(id: "Mod A", version: "1.0")); + Mock modB = this.GetMetadata(this.GetManifest(id: "Mod B", version: "1.0", dependencies: new IManifestDependency[] { new ManifestDependency("Mod A", "1.0-beta") }), allowStatusChange: false); + + // act + IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modA.Object, modB.Object }, new ModDatabase()).ToArray(); + + // assert + mods.Should().HaveCount(2, "it should match number of mods input"); + mods[0].Should().BeSameAs(modA.Object, "mod A should be first since it's needed by mod B"); + mods[1].Should().BeSameAs(modB.Object, "mod B should be second since it needs mod A"); + } - // assert - Assert.AreEqual(1, mods.Length, 0, "Expected to get the same number of mods input."); - Assert.AreSame(modB.Object, mods[0], "The load order is incorrect: mod B should be first since it's the only mod."); - } + [Test(Description = "Assert that optional dependencies are sorted correctly if present.")] + public void ProcessDependencies_IfOptional() + { + // arrange + // A ◀── B + Mock modA = this.GetMetadata(this.GetManifest(id: "Mod A", version: "1.0")); + Mock modB = this.GetMetadata(this.GetManifest(id: "Mod B", version: "1.0", dependencies: new IManifestDependency[] { new ManifestDependency("Mod A", "1.0", required: false) }), allowStatusChange: false); + + // act + IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modB.Object, modA.Object }, new ModDatabase()).ToArray(); + + // assert + mods.Should().HaveCount(2, "it should match number of mods input"); + mods[0].Should().BeSameAs(modA.Object, "mod A should be first since it's needed by mod B"); + mods[1].Should().BeSameAs(modB.Object, "mod B should be second since it needs mod A"); + } + [Test(Description = "Assert that optional dependencies are accepted if they're missing.")] + public void ProcessDependencies_IfOptional_SucceedsIfMissing() + { + // arrange + // A ◀── B where A doesn't exist + Mock modB = this.GetMetadata(this.GetManifest(id: "Mod B", version: "1.0", dependencies: new IManifestDependency[] { new ManifestDependency("Mod A", "1.0", required: false) }), allowStatusChange: false); - /********* - ** Private methods - *********/ - /// Get a generated folder path in the temp folder. This folder isn't created automatically. - private string GetTempFolderPath() - { - return Path.Combine(Path.GetTempPath(), "smapi-unit-tests", Guid.NewGuid().ToString("N")); - } + // act + IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modB.Object }, new ModDatabase()).ToArray(); - /// Get a file lookup for a given directory. - /// The full path to the directory. - private IFileLookup GetFileLookup(string rootDirectory) - { - return MinimalFileLookup.GetCachedFor(rootDirectory); - } + // assert + mods.Should().HaveCount(1, "should match number of mods input"); + mods[0].Should().BeSameAs(modB.Object, "mod B should be first since it's the only mod"); + } - /// Get a randomized basic manifest. - /// The value, or null for a generated value. - /// The value, or null for a generated value. - /// The value, or null for a generated value. - /// The value, or null for a generated value. - /// The value. - /// The value. - /// The value. - /// The value. - private Manifest GetManifest(string? id = null, string? name = null, string? version = null, string? entryDll = null, string? contentPackForID = null, string? minimumApiVersion = null, string? minimumGameVersion = null, IManifestDependency[]? dependencies = null) - { - return new Manifest( - uniqueId: id ?? $"{Sample.String()}.{Sample.String()}", - name: name ?? id ?? Sample.String(), - author: Sample.String(), - description: Sample.String(), - version: version != null ? new SemanticVersion(version) : new SemanticVersion(Sample.Int(), Sample.Int(), Sample.Int(), Sample.String()), - entryDll: entryDll ?? $"{Sample.String()}.dll", - contentPackFor: contentPackForID != null ? new ManifestContentPackFor(contentPackForID, null) : null, - minimumApiVersion: minimumApiVersion != null ? new SemanticVersion(minimumApiVersion) : null, - minimumGameVersion: minimumGameVersion != null ? new SemanticVersion(minimumGameVersion) : null, - dependencies: dependencies ?? Array.Empty(), - updateKeys: Array.Empty() - ); - } - /// Get a randomized basic manifest. - /// The mod's name and unique ID. - private Mock GetMetadata(string uniqueID) - { - return this.GetMetadata(this.GetManifest(uniqueID, "1.0")); - } + /********* + ** Private methods + *********/ + /// Get a generated folder path in the temp folder. This folder isn't created automatically. + private string GetTempFolderPath() + { + return Path.Combine(Path.GetTempPath(), "smapi-unit-tests", Guid.NewGuid().ToString("N")); + } - /// Get a randomized basic manifest. - /// The mod's name and unique ID. - /// The dependencies this mod requires. - /// Whether the code being tested is allowed to change the mod status. - private Mock GetMetadata(string uniqueID, string[] dependencies, bool allowStatusChange = false) - { - IManifest manifest = this.GetManifest(id: uniqueID, version: "1.0", dependencies: dependencies.Select(dependencyID => (IManifestDependency)new ManifestDependency(dependencyID, null as ISemanticVersion)).ToArray()); - return this.GetMetadata(manifest, allowStatusChange); - } + /// Get a file lookup for a given directory. + /// The full path to the directory. + private IFileLookup GetFileLookup(string rootDirectory) + { + return MinimalFileLookup.GetCachedFor(rootDirectory); + } - /// Get a randomized basic manifest. - /// The mod manifest. - /// Whether the code being tested is allowed to change the mod status. - /// The directory path the mod metadata should be pointed at, or null to generate a fake path. - private Mock GetMetadata(IManifest manifest, bool allowStatusChange = false, string? directoryPath = null) - { - directoryPath ??= this.GetTempFolderPath(); - - Mock mod = new(MockBehavior.Strict); - mod.Setup(p => p.DataRecord).Returns(this.GetModDataRecordVersionedFields()); - mod.Setup(p => p.Status).Returns(ModMetadataStatus.Found); - mod.Setup(p => p.DisplayName).Returns(manifest.UniqueID); - mod.Setup(p => p.DirectoryPath).Returns(directoryPath); - mod.Setup(p => p.Manifest).Returns(manifest); - mod.Setup(p => p.HasID(It.IsAny())).Returns((string id) => manifest.UniqueID == id); - mod.Setup(p => p.GetUpdateKeys(It.IsAny())).Returns(Enumerable.Empty()); - mod.Setup(p => p.GetRelativePathWithRoot()).Returns(directoryPath); - if (allowStatusChange) - { - mod - .Setup(p => p.SetStatus(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Callback((status, failReason, message, errorDetails) => Console.WriteLine($"<{manifest.UniqueID} changed status: [{status}] {message}\n{failReason}\n{errorDetails}")) - .Returns(mod.Object); - } - return mod; - } + /// Get a randomized basic manifest. + /// The value, or null for a generated value. + /// The value, or null for a generated value. + /// The value, or null for a generated value. + /// The value, or null for a generated value. + /// The value. + /// The value. + /// The value. + /// The value. + private Manifest GetManifest(string? id = null, string? name = null, string? version = null, string? entryDll = null, string? contentPackForID = null, string? minimumApiVersion = null, string? minimumGameVersion = null, IManifestDependency[]? dependencies = null) + { + return new Manifest( + uniqueId: id ?? $"{Sample.String()}.{Sample.String()}", + name: name ?? id ?? Sample.String(), + author: Sample.String(), + description: Sample.String(), + version: version != null ? new SemanticVersion(version) : new SemanticVersion(Sample.Int(), Sample.Int(), Sample.Int(), Sample.String()), + entryDll: entryDll ?? $"{Sample.String()}.dll", + contentPackFor: contentPackForID != null ? new ManifestContentPackFor(contentPackForID, null) : null, + minimumApiVersion: minimumApiVersion != null ? new SemanticVersion(minimumApiVersion) : null, + minimumGameVersion: minimumGameVersion != null ? new SemanticVersion(minimumGameVersion) : null, + dependencies: dependencies ?? Array.Empty(), + privateAssemblies: Array.Empty(), + updateKeys: Array.Empty() + ); + } - /// Generate a default mod data record. - private ModDataRecord GetModDataRecord() - { - return new("Default Display Name", new ModDataModel("Sample ID", null, ModWarning.None, false)); - } + /// Get a randomized basic manifest. + /// The mod's name and unique ID. + private Mock GetMetadata(string uniqueID) + { + return this.GetMetadata(this.GetManifest(uniqueID, "1.0")); + } + + /// Get a randomized basic manifest. + /// The mod's name and unique ID. + /// The dependencies this mod requires. + /// Whether the code being tested is allowed to change the mod status. + private Mock GetMetadata(string uniqueID, string[] dependencies, bool allowStatusChange = false) + { + IManifest manifest = this.GetManifest(id: uniqueID, version: "1.0", dependencies: dependencies.Select(dependencyID => (IManifestDependency)new ManifestDependency(dependencyID, null as ISemanticVersion)).ToArray()); + return this.GetMetadata(manifest, allowStatusChange); + } - /// Generate a default mod data versioned fields instance. - private ModDataRecordVersionedFields GetModDataRecordVersionedFields() + /// Get a randomized basic manifest. + /// The mod manifest. + /// Whether the code being tested is allowed to change the mod status. + /// The directory path the mod metadata should be pointed at, or null to generate a fake path. + private Mock GetMetadata(IManifest manifest, bool allowStatusChange = false, string? directoryPath = null) + { + directoryPath ??= this.GetTempFolderPath(); + + Mock mod = new(MockBehavior.Strict); + mod.Setup(p => p.DataRecord).Returns(this.GetModDataRecordVersionedFields()); + mod.Setup(p => p.Status).Returns(ModMetadataStatus.Found); + mod.Setup(p => p.DisplayName).Returns(manifest.UniqueID); + mod.Setup(p => p.DirectoryPath).Returns(directoryPath); + mod.Setup(p => p.Manifest).Returns(manifest); + mod.Setup(p => p.HasID(It.IsAny())).Returns((string id) => manifest.UniqueID == id); + mod.Setup(p => p.GetUpdateKeys(It.IsAny())).Returns(Enumerable.Empty()); + mod.Setup(p => p.GetRelativePathWithRoot()).Returns(directoryPath); + if (allowStatusChange) { - return new ModDataRecordVersionedFields(this.GetModDataRecord()); + mod + .Setup(p => p.SetStatus(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((status, failReason, message, errorDetails) => Console.WriteLine($"<{manifest.UniqueID} changed status: [{status}] {message}\n{failReason}\n{errorDetails}")) + .Returns(mod.Object); } + return mod; + } + + /// Generate a default mod data record. + private ModDataRecord GetModDataRecord() + { + return new("Default Display Name", new ModDataModel("Sample ID", null, ModWarning.None, false)); + } + + /// Generate a default mod data versioned fields instance. + private ModDataRecordVersionedFields GetModDataRecordVersionedFields() + { + return new ModDataRecordVersionedFields(this.GetModDataRecord()); } } diff --git a/src/SMAPI.Tests/Core/TranslationTests.cs b/src/SMAPI.Tests/Core/TranslationTests.cs index a52df6072..77dd56cf6 100644 --- a/src/SMAPI.Tests/Core/TranslationTests.cs +++ b/src/SMAPI.Tests/Core/TranslationTests.cs @@ -3,6 +3,7 @@ using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; +using FluentAssertions; using NUnit.Framework; using StardewModdingAPI; using StardewModdingAPI.Framework; @@ -11,381 +12,416 @@ using StardewModdingAPI.Toolkit.Serialization.Models; using StardewValley; -namespace SMAPI.Tests.Core +namespace SMAPI.Tests.Core; + +/// Unit tests for and . +[TestFixture] +public class TranslationTests { - /// Unit tests for and . - [TestFixture] - public class TranslationTests + /********* + ** Data + *********/ + /// Sample translation text for unit tests. + public static string?[] Samples = { null, "", " ", "boop", " boop " }; + + + /********* + ** Unit tests + *********/ + /**** + ** Translation helper + ****/ + [Test(Description = "Assert that the translation helper correctly handles no translations.")] + public void Helper_HandlesNoTranslations() { - /********* - ** Data - *********/ - /// Sample translation text for unit tests. - public static string?[] Samples = { null, "", " ", "boop", " boop " }; + // arrange + var data = new Dictionary>(); + // act + ITranslationHelper helper = new TranslationHelper(this.CreateModMetadata(), "en", LocalizedContentManager.LanguageCode.en).SetTranslations(data); + Translation translation = helper.Get("key"); + Translation[]? translationList = helper.GetTranslations()?.ToArray(); - /********* - ** Unit tests - *********/ - /**** - ** Translation helper - ****/ - [Test(Description = "Assert that the translation helper correctly handles no translations.")] - public void Helper_HandlesNoTranslations() + // assert + helper.Locale.Should().Be("en"); + helper.LocaleEnum.Should().Be(LocalizedContentManager.LanguageCode.en); + translationList.Should().NotBeNull().And.BeEmpty(); + + translation.Should().NotBeNull(); + translation.ToString().Should().Be(this.GetPlaceholderText("key")); + } + + [Test(Description = "Assert that the translation helper returns the expected translations correctly.")] + public void Helper_GetTranslations_ReturnsExpectedText() + { + // arrange + var data = this.GetSampleData(); + var expected = this.GetExpectedTranslations(); + + // act + var actual = new Dictionary(); + TranslationHelper helper = new TranslationHelper(this.CreateModMetadata(), "en", LocalizedContentManager.LanguageCode.en).SetTranslations(data); + foreach (string locale in expected.Keys) { - // arrange - var data = new Dictionary>(); - - // act - ITranslationHelper helper = new TranslationHelper(this.CreateModMetadata(), "en", LocalizedContentManager.LanguageCode.en).SetTranslations(data); - Translation translation = helper.Get("key"); - Translation[]? translationList = helper.GetTranslations()?.ToArray(); - - // assert - Assert.AreEqual("en", helper.Locale, "The locale doesn't match the input value."); - Assert.AreEqual(LocalizedContentManager.LanguageCode.en, helper.LocaleEnum, "The locale enum doesn't match the input value."); - Assert.IsNotNull(translationList, "The full list of translations is unexpectedly null."); - Assert.AreEqual(0, translationList!.Length, "The full list of translations is unexpectedly not empty."); - - Assert.IsNotNull(translation, "The translation helper unexpectedly returned a null translation."); - Assert.AreEqual(this.GetPlaceholderText("key"), translation.ToString(), "The translation returned an unexpected value."); + this.AssertSetLocale(helper, locale, LocalizedContentManager.LanguageCode.en); + actual[locale] = helper.GetTranslations()?.ToArray(); } - [Test(Description = "Assert that the translation helper returns the expected translations correctly.")] - public void Helper_GetTranslations_ReturnsExpectedText() + // assert + foreach (string locale in expected.Keys) { - // arrange - var data = this.GetSampleData(); - var expected = this.GetExpectedTranslations(); - - // act - var actual = new Dictionary(); - TranslationHelper helper = new TranslationHelper(this.CreateModMetadata(), "en", LocalizedContentManager.LanguageCode.en).SetTranslations(data); - foreach (string locale in expected.Keys) - { - this.AssertSetLocale(helper, locale, LocalizedContentManager.LanguageCode.en); - actual[locale] = helper.GetTranslations()?.ToArray(); - } - - // assert - foreach (string locale in expected.Keys) - { - Assert.IsNotNull(actual[locale], $"The translations for {locale} is unexpectedly null."); - Assert.That(actual[locale], Is.EquivalentTo(expected[locale]).Using(this.CompareEquality), $"The translations for {locale} don't match the expected values."); - } + actual[locale].Should() + .NotBeNull($"the translations for {locale} should be set") + .And.BeEquivalentTo(expected[locale], $"the translations for {locale} should match the input values"); } + } - [Test(Description = "Assert that the translations returned by the helper has the expected text.")] - public void Helper_Get_ReturnsExpectedText() + [Test(Description = "Assert that the translations returned by the helper has the expected text.")] + public void Helper_Get_ReturnsExpectedText() + { + // arrange + var data = this.GetSampleData(); + var expected = this.GetExpectedTranslations(); + + // act + var actual = new Dictionary(); + TranslationHelper helper = new TranslationHelper(this.CreateModMetadata(), "en", LocalizedContentManager.LanguageCode.en).SetTranslations(data); + foreach (string locale in expected.Keys) { - // arrange - var data = this.GetSampleData(); - var expected = this.GetExpectedTranslations(); - - // act - var actual = new Dictionary(); - TranslationHelper helper = new TranslationHelper(this.CreateModMetadata(), "en", LocalizedContentManager.LanguageCode.en).SetTranslations(data); - foreach (string locale in expected.Keys) - { - this.AssertSetLocale(helper, locale, LocalizedContentManager.LanguageCode.en); + this.AssertSetLocale(helper, locale, LocalizedContentManager.LanguageCode.en); - List translations = new List(); - foreach (Translation translation in expected[locale]) - translations.Add(helper.Get(translation.Key)); - actual[locale] = translations.ToArray(); - } - - // assert - foreach (string locale in expected.Keys) - { - Assert.IsNotNull(actual[locale], $"The translations for {locale} is unexpectedly null."); - Assert.That(actual[locale], Is.EquivalentTo(expected[locale]).Using(this.CompareEquality), $"The translations for {locale} don't match the expected values."); - } + List translations = new List(); + foreach (Translation translation in expected[locale]) + translations.Add(helper.Get(translation.Key)); + actual[locale] = translations.ToArray(); } - /**** - ** Translation - ****/ - [Test(Description = "Assert that HasValue returns the expected result for various inputs.")] - [TestCase(null, ExpectedResult = false)] - [TestCase("", ExpectedResult = false)] - [TestCase(" ", ExpectedResult = true)] - [TestCase("boop", ExpectedResult = true)] - [TestCase(" boop ", ExpectedResult = true)] - public bool Translation_HasValue(string? text) + // assert + foreach (string locale in expected.Keys) { - return new Translation("pt-BR", "key", text).HasValue(); + actual[locale].Should() + .NotBeNull($"the translations for {locale} should be set") + .And.BeEquivalentTo(expected[locale], $"the translations for {locale} should match the input values"); } + } - [Test(Description = "Assert that the translation's ToString method returns the expected text for various inputs.")] - public void Translation_ToString([ValueSource(nameof(TranslationTests.Samples))] string? text) - { - // act - Translation translation = new("pt-BR", "key", text); - - // assert - if (translation.HasValue()) - Assert.AreEqual(text, translation.ToString(), "The translation returned an unexpected value given a valid input."); - else - Assert.AreEqual(this.GetPlaceholderText("key"), translation.ToString(), "The translation returned an unexpected value given a null or empty input."); - } + /**** + ** Translation + ****/ + [Test(Description = "Assert that HasValue returns the expected result for various inputs.")] + [TestCase(null, ExpectedResult = false)] + [TestCase("", ExpectedResult = false)] + [TestCase(" ", ExpectedResult = true)] + [TestCase("boop", ExpectedResult = true)] + [TestCase(" boop ", ExpectedResult = true)] + public bool Translation_HasValue(string? text) + { + return new Translation("pt-BR", "key", text).HasValue(); + } - [Test(Description = "Assert that the translation's implicit string conversion returns the expected text for various inputs.")] - public void Translation_ImplicitStringConversion([ValueSource(nameof(TranslationTests.Samples))] string? text) - { - // act - Translation translation = new("pt-BR", "key", text); - - // assert - if (translation.HasValue()) - Assert.AreEqual(text, (string?)translation, "The translation returned an unexpected value given a valid input."); - else - Assert.AreEqual(this.GetPlaceholderText("key"), (string?)translation, "The translation returned an unexpected value given a null or empty input."); - } + [Test(Description = "Assert that the translation's ToString method returns the expected text for various inputs.")] + public void Translation_ToString([ValueSource(nameof(TranslationTests.Samples))] string? text) + { + // act + Translation translation = new("pt-BR", "key", text); + + // assert + if (!string.IsNullOrEmpty(text)) + translation.ToString().Should().Be(text, "the translation should match the valid input"); + else + translation.ToString().Should().Be(this.GetPlaceholderText("key"), "the translation should match the placeholder given a null or empty input"); + } - [Test(Description = "Assert that the translation returns the expected text given a use-placeholder setting.")] - public void Translation_UsePlaceholder([Values(true, false)] bool value, [ValueSource(nameof(TranslationTests.Samples))] string? text) - { - // act - Translation translation = new Translation("pt-BR", "key", text).UsePlaceholder(value); - - // assert - if (translation.HasValue()) - Assert.AreEqual(text, translation.ToString(), "The translation returned an unexpected value given a valid input."); - else if (!value) - Assert.AreEqual(text, translation.ToString(), "The translation returned an unexpected value given a null or empty input with the placeholder disabled."); - else - Assert.AreEqual(this.GetPlaceholderText("key"), translation.ToString(), "The translation returned an unexpected value given a null or empty input with the placeholder enabled."); - } + [Test(Description = "Assert that the translation's implicit string conversion returns the expected text for various inputs.")] + public void Translation_ImplicitStringConversion([ValueSource(nameof(TranslationTests.Samples))] string? text) + { + // act + Translation translation = new("pt-BR", "key", text); + + // assert + if (!string.IsNullOrEmpty(text)) + ((string?)translation).Should().Be(text, "the translation should match the valid input"); + else + ((string?)translation).Should().Be(this.GetPlaceholderText("key"), "the translation should match the placeholder given a null or empty input"); + } - [Test(Description = "Assert that the translation returns the expected text after setting the default.")] - public void Translation_Default([ValueSource(nameof(TranslationTests.Samples))] string? text, [ValueSource(nameof(TranslationTests.Samples))] string? @default) + [Test(Description = "Assert that the translation returns the expected text for a translation containing gender switch blocks.")] + [TestCase("Hello ${lad^lass^there}$ on this fine ${male¦female¦other}$ day.", Gender.Male, ExpectedResult = "Hello lad on this fine male day.")] + [TestCase("Hello ${lad^lass^there}$ on this fine ${male¦female¦other}$ day.", Gender.Female, ExpectedResult = "Hello lass on this fine female day.")] + [TestCase("Hello ${lad^lass^there}$ on this fine ${male¦female¦other}$ day.", Gender.Undefined, ExpectedResult = "Hello there on this fine other day.")] + public string Translation_WithGenderSwitchBlocks(string text, Gender gender) + { + // arrange + Translation translation = new("pt-BR", "key", text) { - // act - Translation translation = new Translation("pt-BR", "key", text).Default(@default); - - // assert - if (!string.IsNullOrEmpty(text)) - Assert.AreEqual(text, translation.ToString(), "The translation returned an unexpected value given a valid base text."); - else if (!string.IsNullOrEmpty(@default)) - Assert.AreEqual(@default, translation.ToString(), "The translation returned an unexpected value given a null or empty base text, but valid default."); - else - Assert.AreEqual(this.GetPlaceholderText("key"), translation.ToString(), "The translation returned an unexpected value given a null or empty base and default text."); - } + ForceGender = () => gender + }; - /**** - ** Translation tokens - ****/ - [Test(Description = "Assert that multiple translation tokens are replaced correctly regardless of the token structure.")] - public void Translation_Tokens([Values("anonymous object", "class", "IDictionary", "IDictionary")] string structure) - { - // arrange - string start = Guid.NewGuid().ToString("N"); - string middle = Guid.NewGuid().ToString("N"); - string end = Guid.NewGuid().ToString("N"); - const string input = "{{start}} tokens are properly replaced (including {{middle}} {{ MIDdlE}}) {{end}}"; - string expected = $"{start} tokens are properly replaced (including {middle} {middle}) {end}"; - - // act - Translation translation = new("pt-BR", "key", input); - switch (structure) - { - case "anonymous object": - translation = translation.Tokens(new { start, middle, end }); - break; + // assert + return translation.ToString(); + } - case "class": - translation = translation.Tokens(new TokenModel(start, middle, end)); - break; + [Test(Description = "Assert that the translation returns the expected text given a use-placeholder setting.")] + public void Translation_UsePlaceholder([Values(true, false)] bool value, [ValueSource(nameof(TranslationTests.Samples))] string? text) + { + // act + Translation translation = new Translation("pt-BR", "key", text).UsePlaceholder(value); + + // assert + if (!string.IsNullOrEmpty(text)) + translation.ToString().Should().Be(text, "the translation should match the valid input"); + else if (!value) + translation.ToString().Should().Be(text, "the translation should return the text as-is given a null or empty input with the placeholder disabled"); + else + translation.ToString().Should().Be(this.GetPlaceholderText("key"), "the translation should match the placeholder given a null or empty input with the placeholder enabled"); + } - case "IDictionary": - translation = translation.Tokens(new Dictionary { ["start"] = start, ["middle"] = middle, ["end"] = end }); - break; + [Test(Description = "Assert that the translation returns the expected text after setting the default.")] + public void Translation_Default([ValueSource(nameof(TranslationTests.Samples))] string? text, [ValueSource(nameof(TranslationTests.Samples))] string? @default) + { + // act + Translation translation = new Translation("pt-BR", "key", text).Default(@default); + + // assert + if (!string.IsNullOrEmpty(text)) + translation.ToString().Should().Be(text, "the translation should match the valid base text"); + else if (!string.IsNullOrEmpty(@default)) + translation.ToString().Should().Be(@default, "the translation should match the default text, given a null or empty base text and valid default."); + else + translation.ToString().Should().Be(this.GetPlaceholderText("key"), translation.ToString(), "the translation should match the placeholder, given a null or empty base text and no default text"); + } - case "IDictionary": - translation = translation.Tokens(new Dictionary { ["start"] = start, ["middle"] = middle, ["end"] = end }); - break; + [Test(Description = "Assert that the translation returns the expected text after setting the default to a value containing tokens.")] + public void Translation_Default_WithTokens() + { + // act + Translation translation = new Translation("pt-BR", "key", null).Default("The {{token}} is {{value}}.").Tokens(new { token = "token value", value = "some value" }); - default: - throw new NotSupportedException($"Unknown structure '{structure}'."); - } + // assert + translation.ToString().Should().Be("The token value is some value."); + } - // assert - Assert.AreEqual(expected, translation.ToString(), "The translation returned an unexpected text."); - } + [Test(Description = "Assert that the translation returns the expected text after setting the default to a value containing gender switch blocks.")] + [TestCase("Hello ${lad^lass^there}$ on this fine ${male¦female¦other}$ day.", Gender.Male, ExpectedResult = "Hello lad on this fine male day.")] + [TestCase("Hello ${lad^lass^there}$ on this fine ${male¦female¦other}$ day.", Gender.Female, ExpectedResult = "Hello lass on this fine female day.")] + [TestCase("Hello ${lad^lass^there}$ on this fine ${male¦female¦other}$ day.", Gender.Undefined, ExpectedResult = "Hello there on this fine other day.")] + public string Translation_Default_WithGenderSwitchBlocks(string placeholder, Gender gender) + { + // arrange + Translation translation = new("pt-BR", "key", null) + { + ForceGender = () => gender + }; + translation = translation.Default(placeholder); + + // assert + return translation.ToString(); + } - [Test(Description = "Assert that the translation can replace tokens in all valid formats.")] - [TestCase("{{value}}", "value")] - [TestCase("{{ value }}", "value")] - [TestCase("{{value }}", "value")] - [TestCase("{{ the_value }}", "the_value")] - [TestCase("{{ the.value_here }}", "the.value_here")] - [TestCase("{{ the_value-here.... }}", "the_value-here....")] - [TestCase("{{ tHe_vALuE-HEre.... }}", "tHe_vALuE-HEre....")] - public void Translation_Tokens_ValidFormats(string text, string key) + /**** + ** Translation tokens + ****/ + [Test(Description = "Assert that multiple translation tokens are replaced correctly regardless of the token structure.")] + public void Translation_Tokens([Values("anonymous object", "class", "IDictionary", "IDictionary")] string structure) + { + // arrange + string start = Guid.NewGuid().ToString("N"); + string middle = Guid.NewGuid().ToString("N"); + string end = Guid.NewGuid().ToString("N"); + const string input = "{{start}} tokens are properly replaced (including {{middle}} {{ MIDdlE}}) {{end}}"; + string expected = $"{start} tokens are properly replaced (including {middle} {middle}) {end}"; + + // act + Translation translation = new("pt-BR", "key", input); + switch (structure) { - // arrange - string value = Guid.NewGuid().ToString("N"); + case "anonymous object": + translation = translation.Tokens(new { start, middle, end }); + break; + + case "class": + translation = translation.Tokens(new TokenModel(start, middle, end)); + break; - // act - Translation translation = new Translation("pt-BR", "key", text).Tokens(new Dictionary { [key] = value }); + case "IDictionary": + translation = translation.Tokens(new Dictionary { ["start"] = start, ["middle"] = middle, ["end"] = end }); + break; - // assert - Assert.AreEqual(value, translation.ToString(), "The translation returned an unexpected value given a valid base text."); + case "IDictionary": + translation = translation.Tokens(new Dictionary { ["start"] = start, ["middle"] = middle, ["end"] = end }); + break; + + default: + throw new NotSupportedException($"Unknown structure '{structure}'."); } - [Test(Description = "Assert that translation tokens are case-insensitive and surrounding-whitespace-insensitive.")] - [TestCase("{{value}}", "value")] - [TestCase("{{VaLuE}}", "vAlUe")] - [TestCase("{{VaLuE }}", " vAlUe")] - public void Translation_Tokens_KeysAreNormalized(string text, string key) - { - // arrange - string value = Guid.NewGuid().ToString("N"); + // assert + translation.ToString().Should().Be(expected); + } - // act - Translation translation = new Translation("pt-BR", "key", text).Tokens(new Dictionary { [key] = value }); + [Test(Description = "Assert that the translation can replace tokens in all valid formats.")] + [TestCase("{{value}}", "value")] + [TestCase("{{ value }}", "value")] + [TestCase("{{value }}", "value")] + [TestCase("{{ the_value }}", "the_value")] + [TestCase("{{ the.value_here }}", "the.value_here")] + [TestCase("{{ the_value-here.... }}", "the_value-here....")] + [TestCase("{{ tHe_vALuE-HEre.... }}", "tHe_vALuE-HEre....")] + public void Translation_Tokens_ValidFormats(string text, string key) + { + // arrange + string value = Guid.NewGuid().ToString("N"); - // assert - Assert.AreEqual(value, translation.ToString(), "The translation returned an unexpected value given a valid base text."); - } + // act + Translation translation = new Translation("pt-BR", "key", text).Tokens(new Dictionary { [key] = value }); + // assert + translation.ToString().Should().Be(value); + } - /********* - ** Private methods - *********/ - /// Set a translation helper's locale and assert that it was set correctly. - /// The translation helper to change. - /// The expected locale. - /// The expected game language code. - private void AssertSetLocale(TranslationHelper helper, string locale, LocalizedContentManager.LanguageCode localeEnum) - { - helper.SetLocale(locale, localeEnum); - Assert.AreEqual(locale, helper.Locale, "The locale doesn't match the input value."); - Assert.AreEqual(localeEnum, helper.LocaleEnum, "The locale enum doesn't match the input value."); - } + [Test(Description = "Assert that translation tokens are case-insensitive and surrounding-whitespace-insensitive.")] + [TestCase("{{value}}", "value")] + [TestCase("{{VaLuE}}", "vAlUe")] + [TestCase("{{VaLuE }}", " vAlUe")] + public void Translation_Tokens_KeysAreNormalized(string text, string key) + { + // arrange + string value = Guid.NewGuid().ToString("N"); - /// Get sample raw translations to input. - private IDictionary> GetSampleData() + // act + Translation translation = new Translation("pt-BR", "key", text).Tokens(new Dictionary { [key] = value }); + + // assert + translation.ToString().Should().Be(value); + } + + + /********* + ** Private methods + *********/ + /// Set a translation helper's locale and assert that it was set correctly. + /// The translation helper to change. + /// The expected locale. + /// The expected game language code. + private void AssertSetLocale(TranslationHelper helper, string locale, LocalizedContentManager.LanguageCode localeEnum) + { + helper.SetLocale(locale, localeEnum); + helper.Locale.Should().Be(locale); + helper.LocaleEnum.Should().Be(localeEnum); + } + + /// Get sample raw translations to input. + private IDictionary> GetSampleData() + { + return new Dictionary> { - return new Dictionary> + ["default"] = new Dictionary { - ["default"] = new Dictionary - { - ["key A"] = "default A", - ["key C"] = "default C" - }, - ["en"] = new Dictionary - { - ["key A"] = "en A", - ["key B"] = "en B" - }, - ["en-US"] = new Dictionary(), - ["zzz"] = new Dictionary - { - ["key A"] = "zzz A" - } - }; - } + ["key A"] = "default A", + ["key C"] = "default C" + }, + ["en"] = new Dictionary + { + ["key A"] = "en A", + ["key B"] = "en B" + }, + ["en-US"] = new Dictionary(), + ["zzz"] = new Dictionary + { + ["key A"] = "zzz A" + } + }; + } - /// Get the expected translation output given , based on the expected locale fallback. - private IDictionary GetExpectedTranslations() + /// Get the expected translation output given , based on the expected locale fallback. + private IDictionary GetExpectedTranslations() + { + var expected = new Dictionary { - var expected = new Dictionary + ["default"] = new[] { - ["default"] = new[] - { - new Translation("default", "key A", "default A"), - new Translation("default", "key C", "default C") - }, - ["en"] = new[] - { - new Translation("en", "key A", "en A"), - new Translation("en", "key B", "en B"), - new Translation("en", "key C", "default C") - }, - ["zzz"] = new[] - { - new Translation("zzz", "key A", "zzz A"), - new Translation("zzz", "key C", "default C") - } - }; - expected["en-us"] = expected["en"].ToArray(); - return expected; - } + new Translation("default", "key A", "default A"), + new Translation("default", "key C", "default C") + }, + ["en"] = new[] + { + new Translation("en", "key A", "en A"), + new Translation("en", "key B", "en B"), + new Translation("en", "key C", "default C") + }, + ["zzz"] = new[] + { + new Translation("zzz", "key A", "zzz A"), + new Translation("zzz", "key C", "default C") + } + }; + expected["en-us"] = expected["en"].ToArray(); + return expected; + } - /// Get whether two translations have the same public values. - /// The first translation to compare. - /// The second translation to compare. - private bool CompareEquality(Translation a, Translation b) - { - return a.Key == b.Key && a.ToString() == b.ToString(); - } + /// Get the default placeholder text when a translation is missing. + /// The translation key. + private string GetPlaceholderText(string key) + { + return string.Format(Translation.PlaceholderText, key); + } - /// Get the default placeholder text when a translation is missing. - /// The translation key. - private string GetPlaceholderText(string key) - { - return string.Format(Translation.PlaceholderText, key); - } + /// Create a fake mod manifest. + private IModMetadata CreateModMetadata() + { + string id = $"smapi.unit-tests.fake-mod-{Guid.NewGuid():N}"; + + string tempPath = Path.Combine(Path.GetTempPath(), id); + return new ModMetadata( + displayName: "Mod Display Name", + directoryPath: tempPath, + rootPath: tempPath, + manifest: new Manifest( + uniqueID: id, + name: "Mod Name", + author: "Mod Author", + description: "Mod Description", + version: new SemanticVersion(1, 0, 0) + ), + dataRecord: null, + isIgnored: false + ); + } - /// Create a fake mod manifest. - private IModMetadata CreateModMetadata() - { - string id = $"smapi.unit-tests.fake-mod-{Guid.NewGuid():N}"; - - string tempPath = Path.Combine(Path.GetTempPath(), id); - return new ModMetadata( - displayName: "Mod Display Name", - directoryPath: tempPath, - rootPath: tempPath, - manifest: new Manifest( - uniqueID: id, - name: "Mod Name", - author: "Mod Author", - description: "Mod Description", - version: new SemanticVersion(1, 0, 0) - ), - dataRecord: null, - isIgnored: false - ); - } + + /********* + ** Test models + *********/ + /// A model used to test token support. + [SuppressMessage("ReSharper", "NotAccessedField.Local", Justification = "Used dynamically via translation helper.")] + [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Local", Justification = "Used dynamically via translation helper.")] + private class TokenModel + { + /********* + ** Accessors + *********/ + /// A sample token property. + public string Start { get; } + + /// A sample token property. + public string Middle { get; } + + /// A sample token field. + public string End; /********* - ** Test models + ** public methods *********/ - /// A model used to test token support. - [SuppressMessage("ReSharper", "NotAccessedField.Local", Justification = "Used dynamically via translation helper.")] - [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Local", Justification = "Used dynamically via translation helper.")] - private class TokenModel + /// Construct an instance. + /// A sample token property. + /// A sample token field. + /// A sample token property. + public TokenModel(string start, string middle, string end) { - /********* - ** Accessors - *********/ - /// A sample token property. - public string Start { get; } - - /// A sample token property. - public string Middle { get; } - - /// A sample token field. - public string End; - - - /********* - ** public methods - *********/ - /// Construct an instance. - /// A sample token property. - /// A sample token field. - /// A sample token property. - public TokenModel(string start, string middle, string end) - { - this.Start = start; - this.Middle = middle; - this.End = end; - } + this.Start = start; + this.Middle = middle; + this.End = end; } } } diff --git a/src/SMAPI.Tests/SMAPI.Tests.csproj b/src/SMAPI.Tests/SMAPI.Tests.csproj index 2e56ad59f..9ca9f41b0 100644 --- a/src/SMAPI.Tests/SMAPI.Tests.csproj +++ b/src/SMAPI.Tests/SMAPI.Tests.csproj @@ -14,12 +14,12 @@ - - - - - - + + + + + + diff --git a/src/SMAPI.Tests/Sample.cs b/src/SMAPI.Tests/Sample.cs index 9587a100f..65bc9059a 100644 --- a/src/SMAPI.Tests/Sample.cs +++ b/src/SMAPI.Tests/Sample.cs @@ -1,30 +1,29 @@ using System; -namespace SMAPI.Tests +namespace SMAPI.Tests; + +/// Provides sample values for unit testing. +internal static class Sample { - /// Provides sample values for unit testing. - internal static class Sample - { - /********* - ** Fields - *********/ - /// A random number generator. - private static readonly Random Random = new(); + /********* + ** Fields + *********/ + /// A random number generator. + private static readonly Random Random = new(); - /********* - ** Accessors - *********/ - /// Get a sample string. - public static string String() - { - return Guid.NewGuid().ToString("N"); - } + /********* + ** Accessors + *********/ + /// Get a sample string. + public static string String() + { + return Guid.NewGuid().ToString("N"); + } - /// Get a sample integer. - public static int Int() - { - return Sample.Random.Next(); - } + /// Get a sample integer. + public static int Int() + { + return Sample.Random.Next(); } } diff --git a/src/SMAPI.Tests/Utilities/KeybindListTests.cs b/src/SMAPI.Tests/Utilities/KeybindListTests.cs index c5fd5daf3..888a549de 100644 --- a/src/SMAPI.Tests/Utilities/KeybindListTests.cs +++ b/src/SMAPI.Tests/Utilities/KeybindListTests.cs @@ -1,154 +1,152 @@ using System; using System.Collections.Generic; +using FluentAssertions; using NUnit.Framework; using StardewModdingAPI; using StardewModdingAPI.Utilities; -namespace SMAPI.Tests.Utilities +namespace SMAPI.Tests.Utilities; + +/// Unit tests for . +[TestFixture] +internal class KeybindListTests { - /// Unit tests for . - [TestFixture] - internal class KeybindListTests + /********* + ** Unit tests + *********/ + /**** + ** TryParse + ****/ + /// Assert the parsed fields when constructed from a simple single-key string. + [TestCaseSource(nameof(KeybindListTests.GetAllButtons))] + public void TryParse_SimpleValue(SButton button) { - /********* - ** Unit tests - *********/ - /**** - ** TryParse - ****/ - /// Assert the parsed fields when constructed from a simple single-key string. - [TestCaseSource(nameof(KeybindListTests.GetAllButtons))] - public void TryParse_SimpleValue(SButton button) - { - // act - bool success = KeybindList.TryParse($"{button}", out KeybindList? parsed, out string[] errors); - - // assert - Assert.IsTrue(success, "Parsing unexpectedly failed."); - Assert.IsNotNull(parsed, "The parsed result should not be null."); - Assert.AreEqual(parsed!.ToString(), $"{button}"); - Assert.IsNotNull(errors, message: "The errors should never be null."); - Assert.IsEmpty(errors, message: "The input bindings incorrectly reported errors."); - } + // act + bool success = KeybindList.TryParse($"{button}", out KeybindList? parsed, out string[] errors); + + // assert + success.Should().BeTrue(); + parsed.Should().NotBeNull(); + parsed!.ToString().Should().Be(button.ToString()); + errors.Should().NotBeNull().And.BeEmpty(); + } - /// Assert the parsed fields when constructed from multi-key values. - [TestCase("", ExpectedResult = "None")] - [TestCase(" ", ExpectedResult = "None")] - [TestCase(null, ExpectedResult = "None")] - [TestCase("A + B", ExpectedResult = "A + B")] - [TestCase("A+B", ExpectedResult = "A + B")] - [TestCase(" A+ B ", ExpectedResult = "A + B")] - [TestCase("a +b", ExpectedResult = "A + B")] - [TestCase("a +b, LEFTcontrol + leftALT + LeftSHifT + delete", ExpectedResult = "A + B, LeftControl + LeftAlt + LeftShift + Delete")] - - [TestCase(",", ExpectedResult = "None")] - [TestCase("A,", ExpectedResult = "A")] - [TestCase(",A", ExpectedResult = "A")] - public string TryParse_MultiValues(string? input) - { - // act - bool success = KeybindList.TryParse(input, out KeybindList? parsed, out string[] errors); - - // assert - Assert.IsTrue(success, "Parsing unexpectedly failed."); - Assert.IsNotNull(parsed, "The parsed result should not be null."); - Assert.IsNotNull(errors, message: "The errors should never be null."); - Assert.IsEmpty(errors, message: "The input bindings incorrectly reported errors."); - return parsed!.ToString(); - } + /// Assert the parsed fields when constructed from multi-key values. + [TestCase("", ExpectedResult = "None")] + [TestCase(" ", ExpectedResult = "None")] + [TestCase(null, ExpectedResult = "None")] + [TestCase("A + B", ExpectedResult = "A + B")] + [TestCase("A+B", ExpectedResult = "A + B")] + [TestCase(" A+ B ", ExpectedResult = "A + B")] + [TestCase("a +b", ExpectedResult = "A + B")] + [TestCase("a +b, LEFTcontrol + leftALT + LeftSHifT + delete", ExpectedResult = "A + B, LeftControl + LeftAlt + LeftShift + Delete")] + + [TestCase(",", ExpectedResult = "None")] + [TestCase("A,", ExpectedResult = "A")] + [TestCase(",A", ExpectedResult = "A")] + public string TryParse_MultiValues(string? input) + { + // act + bool success = KeybindList.TryParse(input, out KeybindList? parsed, out string[] errors); - /// Assert invalid values are rejected. - [TestCase("+", "Invalid empty button value")] - [TestCase("A+", "Invalid empty button value")] - [TestCase("+C", "Invalid empty button value")] - [TestCase("A + B +, C", "Invalid empty button value")] - [TestCase("A, TotallyInvalid", "Invalid button value 'TotallyInvalid'")] - [TestCase("A + TotallyInvalid", "Invalid button value 'TotallyInvalid'")] - public void TryParse_InvalidValues(string input, string expectedError) - { - // act - bool success = KeybindList.TryParse(input, out KeybindList? parsed, out string[] errors); - - // assert - Assert.IsFalse(success, "Parsing unexpectedly succeeded."); - Assert.IsNull(parsed, "The parsed result should be null."); - Assert.IsNotNull(errors, message: "The errors should never be null."); - Assert.AreEqual(expectedError, string.Join("; ", errors), "The errors don't match the expected ones."); - } + // assert + success.Should().BeTrue(); + parsed.Should().NotBeNull(); + errors.Should().NotBeNull().And.BeEmpty(); + + return parsed!.ToString(); + } + + /// Assert invalid values are rejected. + [TestCase("+", "Invalid empty button value")] + [TestCase("A+", "Invalid empty button value")] + [TestCase("+C", "Invalid empty button value")] + [TestCase("A + B +, C", "Invalid empty button value")] + [TestCase("A, TotallyInvalid", "Invalid button value 'TotallyInvalid'")] + [TestCase("A + TotallyInvalid", "Invalid button value 'TotallyInvalid'")] + public void TryParse_InvalidValues(string input, string expectedError) + { + // act + bool success = KeybindList.TryParse(input, out KeybindList? parsed, out string[] errors); + + // assert + success.Should().BeFalse(); + parsed.Should().BeNull(); + errors.Should().BeEquivalentTo(errors); + } - /**** - ** GetState - ****/ - /// Assert that returns the expected result for a given input state. - // single value - [TestCase("A", "A:Held", ExpectedResult = SButtonState.Held)] - [TestCase("A", "A:Pressed", ExpectedResult = SButtonState.Pressed)] - [TestCase("A", "A:Released", ExpectedResult = SButtonState.Released)] - [TestCase("A", "A:None", ExpectedResult = SButtonState.None)] - - // multiple values - [TestCase("A + B + C, D", "A:Released, B:None, C:None, D:Pressed", ExpectedResult = SButtonState.Pressed)] // right pressed => pressed - [TestCase("A + B + C, D", "A:Pressed, B:Held, C:Pressed, D:None", ExpectedResult = SButtonState.Pressed)] // left pressed => pressed - [TestCase("A + B + C, D", "A:Pressed, B:Pressed, C:Released, D:None", ExpectedResult = SButtonState.None)] // one key released but other keys weren't down last tick => none - [TestCase("A + B + C, D", "A:Held, B:Held, C:Released, D:None", ExpectedResult = SButtonState.Released)] // all three keys were down last tick and now one is released => released - - // transitive - [TestCase("A, B", "A: Released, B: Pressed", ExpectedResult = SButtonState.Held)] - public SButtonState GetState(string input, string stateMap) + /**** + ** GetState + ****/ + /// Assert that returns the expected result for a given input state. + // single value + [TestCase("A", "A:Held", ExpectedResult = SButtonState.Held)] + [TestCase("A", "A:Pressed", ExpectedResult = SButtonState.Pressed)] + [TestCase("A", "A:Released", ExpectedResult = SButtonState.Released)] + [TestCase("A", "A:None", ExpectedResult = SButtonState.None)] + + // multiple values + [TestCase("A + B + C, D", "A:Released, B:None, C:None, D:Pressed", ExpectedResult = SButtonState.Pressed)] // right pressed => pressed + [TestCase("A + B + C, D", "A:Pressed, B:Held, C:Pressed, D:None", ExpectedResult = SButtonState.Pressed)] // left pressed => pressed + [TestCase("A + B + C, D", "A:Pressed, B:Pressed, C:Released, D:None", ExpectedResult = SButtonState.None)] // one key released but other keys weren't down last tick => none + [TestCase("A + B + C, D", "A:Held, B:Held, C:Released, D:None", ExpectedResult = SButtonState.Released)] // all three keys were down last tick and now one is released => released + + // transitive + [TestCase("A, B", "A: Released, B: Pressed", ExpectedResult = SButtonState.Held)] + public SButtonState GetState(string input, string stateMap) + { + // act + bool success = KeybindList.TryParse(input, out KeybindList? parsed, out string[] errors); + if (success && parsed?.Keybinds != null) { - // act - bool success = KeybindList.TryParse(input, out KeybindList? parsed, out string[] errors); - if (success && parsed?.Keybinds != null) + foreach (Keybind? keybind in parsed.Keybinds) { - foreach (Keybind? keybind in parsed.Keybinds) - { #pragma warning disable 618 // method is marked obsolete because it should only be used in unit tests - keybind.GetButtonState = key => this.GetStateFromMap(key, stateMap); + keybind.GetButtonState = key => this.GetStateFromMap(key, stateMap); #pragma warning restore 618 - } } - - // assert - Assert.IsTrue(success, "Parsing unexpected failed"); - Assert.IsNotNull(parsed, "The parsed result should not be null."); - Assert.IsNotNull(errors, message: "The errors should never be null."); - Assert.IsEmpty(errors, message: "The input bindings incorrectly reported errors."); - return parsed!.GetState(); } + // assert + success.Should().BeTrue(); + parsed.Should().NotBeNull(); + errors.Should().NotBeNull().And.BeEmpty(); - /********* - ** Private methods - *********/ - /// Get all defined buttons. - private static IEnumerable GetAllButtons() - { - foreach (SButton button in Enum.GetValues(typeof(SButton))) - yield return button; - } + return parsed!.GetState(); + } - /// Get the button state defined by a mapping string. - /// The button to check. - /// The state map. - private SButtonState GetStateFromMap(SButton button, string stateMap) - { - foreach (string rawPair in stateMap.Split(',')) - { - // parse values - string[] parts = rawPair.Split(':', 2, StringSplitOptions.TrimEntries); - if (!Enum.TryParse(parts[0], ignoreCase: true, out SButton curButton)) - Assert.Fail($"The state map is invalid: unknown button value '{parts[0]}'"); - if (!Enum.TryParse(parts[1], ignoreCase: true, out SButtonState state)) - Assert.Fail($"The state map is invalid: unknown state value '{parts[1]}'"); - - // get state - if (curButton == button) - return state; - } - Assert.Fail($"The state map doesn't define button value '{button}'."); - return SButtonState.None; + /********* + ** Private methods + *********/ + /// Get all defined buttons. + private static IEnumerable GetAllButtons() + { + foreach (SButton button in Enum.GetValues(typeof(SButton))) + yield return button; + } + + /// Get the button state defined by a mapping string. + /// The button to check. + /// The state map. + private SButtonState GetStateFromMap(SButton button, string stateMap) + { + foreach (string rawPair in stateMap.Split(',')) + { + // parse values + string[] parts = rawPair.Split(':', 2, StringSplitOptions.TrimEntries); + if (!Enum.TryParse(parts[0], ignoreCase: true, out SButton curButton)) + Assert.Fail($"The state map is invalid: unknown button value '{parts[0]}'"); + if (!Enum.TryParse(parts[1], ignoreCase: true, out SButtonState state)) + Assert.Fail($"The state map is invalid: unknown state value '{parts[1]}'"); + + // get state + if (curButton == button) + return state; } + + Assert.Fail($"The state map doesn't define button value '{button}'."); + return SButtonState.None; } } diff --git a/src/SMAPI.Tests/Utilities/PathUtilitiesTests.cs b/src/SMAPI.Tests/Utilities/PathUtilitiesTests.cs index 3219d89dd..c6d323be4 100644 --- a/src/SMAPI.Tests/Utilities/PathUtilitiesTests.cs +++ b/src/SMAPI.Tests/Utilities/PathUtilitiesTests.cs @@ -1,289 +1,295 @@ using System.Diagnostics.CodeAnalysis; using System.IO; +using FluentAssertions; using NUnit.Framework; using StardewModdingAPI.Toolkit.Utilities; -namespace SMAPI.Tests.Utilities +namespace SMAPI.Tests.Utilities; + +/// Unit tests for . +[TestFixture] +[SuppressMessage("ReSharper", "StringLiteralTypo", Justification = "These are standard game install paths.")] +internal class PathUtilitiesTests { - /// Unit tests for . - [TestFixture] - [SuppressMessage("ReSharper", "StringLiteralTypo", Justification = "These are standard game install paths.")] - internal class PathUtilitiesTests - { - /********* - ** Sample data - *********/ - /// Sample paths used in unit tests. - public static readonly SamplePath[] SamplePaths = { - // Windows absolute path - new( - OriginalPath: @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley", + /********* + ** Sample data + *********/ + /// Sample paths used in unit tests. + public static readonly SamplePath[] SamplePaths = { + // Windows absolute path + new( + OriginalPath: @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley", - Segments: new[] { "C:", "Program Files (x86)", "Steam", "steamapps", "common", "Stardew Valley" }, - SegmentsLimit3: new [] { "C:", "Program Files (x86)", @"Steam\steamapps\common\Stardew Valley" }, + Segments: new[] { "C:", "Program Files (x86)", "Steam", "steamapps", "common", "Stardew Valley" }, + SegmentsLimit3: new [] { "C:", "Program Files (x86)", @"Steam\steamapps\common\Stardew Valley" }, - NormalizedOnWindows: @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley", - NormalizedOnUnix: @"C:/Program Files (x86)/Steam/steamapps/common/Stardew Valley" - ), + NormalizedOnWindows: @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley", + NormalizedOnUnix: @"C:/Program Files (x86)/Steam/steamapps/common/Stardew Valley" + ), - // Windows absolute path (with trailing slash) - new( - OriginalPath: @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley\", + // Windows absolute path (with trailing slash) + new( + OriginalPath: @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley\", - Segments: new[] { "C:", "Program Files (x86)", "Steam", "steamapps", "common", "Stardew Valley" }, - SegmentsLimit3: new [] { "C:", "Program Files (x86)", @"Steam\steamapps\common\Stardew Valley\" }, + Segments: new[] { "C:", "Program Files (x86)", "Steam", "steamapps", "common", "Stardew Valley" }, + SegmentsLimit3: new [] { "C:", "Program Files (x86)", @"Steam\steamapps\common\Stardew Valley\" }, - NormalizedOnWindows: @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley\", - NormalizedOnUnix: @"C:/Program Files (x86)/Steam/steamapps/common/Stardew Valley/" - ), + NormalizedOnWindows: @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley\", + NormalizedOnUnix: @"C:/Program Files (x86)/Steam/steamapps/common/Stardew Valley/" + ), - // Windows relative path - new( - OriginalPath: @"Content\Characters\Dialogue\Abigail", + // Windows relative path + new( + OriginalPath: @"Content\Characters\Dialogue\Abigail", - Segments: new [] { "Content", "Characters", "Dialogue", "Abigail" }, - SegmentsLimit3: new [] { "Content", "Characters", @"Dialogue\Abigail" }, + Segments: new [] { "Content", "Characters", "Dialogue", "Abigail" }, + SegmentsLimit3: new [] { "Content", "Characters", @"Dialogue\Abigail" }, - NormalizedOnWindows: @"Content\Characters\Dialogue\Abigail", - NormalizedOnUnix: @"Content/Characters/Dialogue/Abigail" - ), + NormalizedOnWindows: @"Content\Characters\Dialogue\Abigail", + NormalizedOnUnix: @"Content/Characters/Dialogue/Abigail" + ), - // Windows relative path (with directory climbing) - new( - OriginalPath: @"..\..\Content", + // Windows relative path (with directory climbing) + new( + OriginalPath: @"..\..\Content", - Segments: new [] { "..", "..", "Content" }, - SegmentsLimit3: new [] { "..", "..", "Content" }, + Segments: new [] { "..", "..", "Content" }, + SegmentsLimit3: new [] { "..", "..", "Content" }, - NormalizedOnWindows: @"..\..\Content", - NormalizedOnUnix: @"../../Content" - ), + NormalizedOnWindows: @"..\..\Content", + NormalizedOnUnix: @"../../Content" + ), - // Windows UNC path - new( - OriginalPath: @"\\unc\path", + // Windows UNC path + new( + OriginalPath: @"\\unc\path", - Segments: new [] { "unc", "path" }, - SegmentsLimit3: new [] { "unc", "path" }, + Segments: new [] { "unc", "path" }, + SegmentsLimit3: new [] { "unc", "path" }, - NormalizedOnWindows: @"\\unc\path", - NormalizedOnUnix: "/unc/path" // there's no good way to normalize this on Unix since UNC paths aren't supported; path normalization is meant for asset names anyway, so this test only ensures it returns some sort of sane value - ), + NormalizedOnWindows: @"\\unc\path", + NormalizedOnUnix: "/unc/path" // there's no good way to normalize this on Unix since UNC paths aren't supported; path normalization is meant for asset names anyway, so this test only ensures it returns some sort of sane value + ), - // Linux absolute path - new( - OriginalPath: @"/home/.steam/steam/steamapps/common/Stardew Valley", + // Linux absolute path + new( + OriginalPath: @"/home/.steam/steam/steamapps/common/Stardew Valley", - Segments: new [] { "home", ".steam", "steam", "steamapps", "common", "Stardew Valley" }, - SegmentsLimit3: new [] { "home", ".steam", "steam/steamapps/common/Stardew Valley" }, + Segments: new [] { "home", ".steam", "steam", "steamapps", "common", "Stardew Valley" }, + SegmentsLimit3: new [] { "home", ".steam", "steam/steamapps/common/Stardew Valley" }, - NormalizedOnWindows: @"\home\.steam\steam\steamapps\common\Stardew Valley", - NormalizedOnUnix: @"/home/.steam/steam/steamapps/common/Stardew Valley" - ), + NormalizedOnWindows: @"\home\.steam\steam\steamapps\common\Stardew Valley", + NormalizedOnUnix: @"/home/.steam/steam/steamapps/common/Stardew Valley" + ), - // Linux absolute path (with trailing slash) - new( - OriginalPath: @"/home/.steam/steam/steamapps/common/Stardew Valley/", + // Linux absolute path (with trailing slash) + new( + OriginalPath: @"/home/.steam/steam/steamapps/common/Stardew Valley/", - Segments: new [] { "home", ".steam", "steam", "steamapps", "common", "Stardew Valley" }, - SegmentsLimit3: new [] { "home", ".steam", "steam/steamapps/common/Stardew Valley/" }, + Segments: new [] { "home", ".steam", "steam", "steamapps", "common", "Stardew Valley" }, + SegmentsLimit3: new [] { "home", ".steam", "steam/steamapps/common/Stardew Valley/" }, - NormalizedOnWindows: @"\home\.steam\steam\steamapps\common\Stardew Valley\", - NormalizedOnUnix: @"/home/.steam/steam/steamapps/common/Stardew Valley/" - ), + NormalizedOnWindows: @"\home\.steam\steam\steamapps\common\Stardew Valley\", + NormalizedOnUnix: @"/home/.steam/steam/steamapps/common/Stardew Valley/" + ), - // Linux absolute path (with ~) - new( - OriginalPath: @"~/.steam/steam/steamapps/common/Stardew Valley", + // Linux absolute path (with ~) + new( + OriginalPath: @"~/.steam/steam/steamapps/common/Stardew Valley", - Segments: new [] { "~", ".steam", "steam", "steamapps", "common", "Stardew Valley" }, - SegmentsLimit3: new [] { "~", ".steam", "steam/steamapps/common/Stardew Valley" }, + Segments: new [] { "~", ".steam", "steam", "steamapps", "common", "Stardew Valley" }, + SegmentsLimit3: new [] { "~", ".steam", "steam/steamapps/common/Stardew Valley" }, - NormalizedOnWindows: @"~\.steam\steam\steamapps\common\Stardew Valley", - NormalizedOnUnix: @"~/.steam/steam/steamapps/common/Stardew Valley" - ), + NormalizedOnWindows: @"~\.steam\steam\steamapps\common\Stardew Valley", + NormalizedOnUnix: @"~/.steam/steam/steamapps/common/Stardew Valley" + ), - // Linux relative path - new( - OriginalPath: @"Content/Characters/Dialogue/Abigail", + // Linux relative path + new( + OriginalPath: @"Content/Characters/Dialogue/Abigail", - Segments: new [] { "Content", "Characters", "Dialogue", "Abigail" }, - SegmentsLimit3: new [] { "Content", "Characters", "Dialogue/Abigail" }, + Segments: new [] { "Content", "Characters", "Dialogue", "Abigail" }, + SegmentsLimit3: new [] { "Content", "Characters", "Dialogue/Abigail" }, - NormalizedOnWindows: @"Content\Characters\Dialogue\Abigail", - NormalizedOnUnix: @"Content/Characters/Dialogue/Abigail" - ), + NormalizedOnWindows: @"Content\Characters\Dialogue\Abigail", + NormalizedOnUnix: @"Content/Characters/Dialogue/Abigail" + ), - // Linux relative path (with directory climbing) - new( - OriginalPath: @"../../Content", + // Linux relative path (with directory climbing) + new( + OriginalPath: @"../../Content", - Segments: new [] { "..", "..", "Content" }, - SegmentsLimit3: new [] { "..", "..", "Content" }, + Segments: new [] { "..", "..", "Content" }, + SegmentsLimit3: new [] { "..", "..", "Content" }, - NormalizedOnWindows: @"..\..\Content", - NormalizedOnUnix: @"../../Content" - ), + NormalizedOnWindows: @"..\..\Content", + NormalizedOnUnix: @"../../Content" + ), - // Mixed directory separators - new( - OriginalPath: @"C:\some/mixed\path/separators", + // Mixed directory separators + new( + OriginalPath: @"C:\some/mixed\path/separators", - Segments: new [] { "C:", "some", "mixed", "path", "separators" }, - SegmentsLimit3: new [] { "C:", "some", @"mixed\path/separators" }, + Segments: new [] { "C:", "some", "mixed", "path", "separators" }, + SegmentsLimit3: new [] { "C:", "some", @"mixed\path/separators" }, - NormalizedOnWindows: @"C:\some\mixed\path\separators", - NormalizedOnUnix: @"C:/some/mixed/path/separators" - ) - }; + NormalizedOnWindows: @"C:\some\mixed\path\separators", + NormalizedOnUnix: @"C:/some/mixed/path/separators" + ) + }; - /********* - ** Unit tests - *********/ - /**** - ** GetSegments - ****/ - [Test(Description = "Assert that PathUtilities.GetSegments splits paths correctly.")] - [TestCaseSource(nameof(PathUtilitiesTests.SamplePaths))] - public void GetSegments(SamplePath path) - { - // act - string[] segments = PathUtilities.GetSegments(path.OriginalPath); + /********* + ** Unit tests + *********/ + /**** + ** GetSegments + ****/ + [Test(Description = "Assert that PathUtilities.GetSegments splits paths correctly.")] + [TestCaseSource(nameof(PathUtilitiesTests.SamplePaths))] + public void GetSegments(SamplePath path) + { + // act + string[] segments = PathUtilities.GetSegments(path.OriginalPath); - // assert - Assert.AreEqual(path.Segments, segments); - } + // assert + path.Segments.Should() + .HaveCount(segments.Length) + .And.ContainInOrder(segments); + } - [Test(Description = "Assert that PathUtilities.GetSegments splits paths correctly when given a limit.")] - [TestCaseSource(nameof(PathUtilitiesTests.SamplePaths))] - public void GetSegments_WithLimit(SamplePath path) - { - // act - string[] segments = PathUtilities.GetSegments(path.OriginalPath, 3); + [Test(Description = "Assert that PathUtilities.GetSegments splits paths correctly when given a limit.")] + [TestCaseSource(nameof(PathUtilitiesTests.SamplePaths))] + public void GetSegments_WithLimit(SamplePath path) + { + // act + string[] segments = PathUtilities.GetSegments(path.OriginalPath, 3); - // assert - Assert.AreEqual(path.SegmentsLimit3, segments); - } + // assert + path.SegmentsLimit3.Should() + .HaveCount(segments.Length) + .And.ContainInOrder(segments); + } - /**** - ** NormalizeAssetName - ****/ - [Test(Description = "Assert that PathUtilities.NormalizeAssetName normalizes paths correctly.")] - [TestCaseSource(nameof(PathUtilitiesTests.SamplePaths))] - public void NormalizeAssetName(SamplePath path) - { - if (Path.IsPathRooted(path.OriginalPath) || path.OriginalPath.StartsWith('/') || path.OriginalPath.StartsWith('\\')) - Assert.Ignore("Absolute paths can't be used as asset names."); + /**** + ** NormalizeAssetName + ****/ + [Test(Description = "Assert that PathUtilities.NormalizeAssetName normalizes paths correctly.")] + [TestCaseSource(nameof(PathUtilitiesTests.SamplePaths))] + public void NormalizeAssetName(SamplePath path) + { + if (Path.IsPathRooted(path.OriginalPath) || path.OriginalPath.StartsWith('/') || path.OriginalPath.StartsWith('\\')) + Assert.Ignore("Absolute paths can't be used as asset names."); - // act - string normalized = PathUtilities.NormalizeAssetName(path.OriginalPath); + // act + string normalized = PathUtilities.NormalizeAssetName(path.OriginalPath); - // assert - Assert.AreEqual(path.NormalizedOnUnix, normalized); // MonoGame uses the Linux format - } + // assert + normalized.Should().Be(path.NormalizedOnUnix); // MonoGame uses the Linux format + } - /**** - ** NormalizePath - ****/ - [Test(Description = "Assert that PathUtilities.NormalizePath normalizes paths correctly.")] - [TestCaseSource(nameof(PathUtilitiesTests.SamplePaths))] - public void NormalizePath(SamplePath path) - { - // act - string normalized = PathUtilities.NormalizePath(path.OriginalPath); + /**** + ** NormalizePath + ****/ + [Test(Description = "Assert that PathUtilities.NormalizePath normalizes paths correctly.")] + [TestCaseSource(nameof(PathUtilitiesTests.SamplePaths))] + public void NormalizePath(SamplePath path) + { + // act + string normalized = PathUtilities.NormalizePath(path.OriginalPath); - // assert + // assert + normalized.Should().Be( #if SMAPI_FOR_WINDOWS - Assert.AreEqual(path.NormalizedOnWindows, normalized); + path.NormalizedOnWindows #else - Assert.AreEqual(path.NormalizedOnUnix, normalized); + path.NormalizedOnUnix #endif - } + ); + } - /**** - ** GetRelativePath - ****/ - [Test(Description = "Assert that PathUtilities.GetRelativePath returns the expected values.")] + /**** + ** GetRelativePath + ****/ + [Test(Description = "Assert that PathUtilities.GetRelativePath returns the expected values.")] #if SMAPI_FOR_WINDOWS - [TestCase( - @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley", - @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley\Mods\Automate", - ExpectedResult = @"Mods\Automate" - )] - [TestCase( - @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley\Mods\Automate", - @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley\Content", - ExpectedResult = @"..\..\Content" - )] - [TestCase( - @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley\Mods\Automate", - @"D:\another-drive", - ExpectedResult = @"D:\another-drive" - )] - [TestCase( - @"\\parent\unc", - @"\\parent\unc\path\to\child", - ExpectedResult = @"path\to\child" - )] - [TestCase( - @"C:\same\path", - @"C:\same\path", - ExpectedResult = @"." - )] - [TestCase( - @"C:\parent", - @"C:\PARENT\child", - ExpectedResult = @"child" - )] + [TestCase( + @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley", + @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley\Mods\Automate", + ExpectedResult = @"Mods\Automate" + )] + [TestCase( + @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley\Mods\Automate", + @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley\Content", + ExpectedResult = @"..\..\Content" + )] + [TestCase( + @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley\Mods\Automate", + @"D:\another-drive", + ExpectedResult = @"D:\another-drive" + )] + [TestCase( + @"\\parent\unc", + @"\\parent\unc\path\to\child", + ExpectedResult = @"path\to\child" + )] + [TestCase( + @"C:\same\path", + @"C:\same\path", + ExpectedResult = @"." + )] + [TestCase( + @"C:\parent", + @"C:\PARENT\child", + ExpectedResult = @"child" + )] #else - [TestCase( - @"~/.steam/steam/steamapps/common/Stardew Valley", - @"~/.steam/steam/steamapps/common/Stardew Valley/Mods/Automate", - ExpectedResult = @"Mods/Automate" - )] - [TestCase( - @"~/.steam/steam/steamapps/common/Stardew Valley/Mods/Automate", - @"~/.steam/steam/steamapps/common/Stardew Valley/Content", - ExpectedResult = @"../../Content" - )] - [TestCase( - @"~/.steam/steam/steamapps/common/Stardew Valley/Mods/Automate", - @"/mnt/another-drive", - ExpectedResult = @"/mnt/another-drive" - )] - [TestCase( - @"~/same/path", - @"~/same/path", - ExpectedResult = @"." - )] - [TestCase( - @"~/parent", - @"~/PARENT/child", - ExpectedResult = @"child" // note: incorrect on Linux and sometimes macOS, but not worth the complexity of detecting whether the filesystem is case-sensitive for SMAPI's purposes - )] + [TestCase( + @"~/.steam/steam/steamapps/common/Stardew Valley", + @"~/.steam/steam/steamapps/common/Stardew Valley/Mods/Automate", + ExpectedResult = @"Mods/Automate" + )] + [TestCase( + @"~/.steam/steam/steamapps/common/Stardew Valley/Mods/Automate", + @"~/.steam/steam/steamapps/common/Stardew Valley/Content", + ExpectedResult = @"../../Content" + )] + [TestCase( + @"~/.steam/steam/steamapps/common/Stardew Valley/Mods/Automate", + @"/mnt/another-drive", + ExpectedResult = @"/mnt/another-drive" + )] + [TestCase( + @"~/same/path", + @"~/same/path", + ExpectedResult = @"." + )] + [TestCase( + @"~/parent", + @"~/PARENT/child", + ExpectedResult = @"child" // note: incorrect on Linux and sometimes macOS, but not worth the complexity of detecting whether the filesystem is case-sensitive for SMAPI's purposes + )] #endif - public string GetRelativePath(string sourceDir, string targetPath) - { - return PathUtilities.GetRelativePath(sourceDir, targetPath); - } + public string GetRelativePath(string sourceDir, string targetPath) + { + return PathUtilities.GetRelativePath(sourceDir, targetPath); + } - /********* - ** Private classes - *********/ - /// A sample path in multiple formats. - /// The original path to pass to the . - /// The normalized path segments. - /// The normalized path segments, if we stop segmenting after the second one. - /// The normalized form on Windows. - /// The normalized form on Linux or macOS. - public record SamplePath(string OriginalPath, string[] Segments, string[] SegmentsLimit3, string NormalizedOnWindows, string NormalizedOnUnix) + /********* + ** Private classes + *********/ + /// A sample path in multiple formats. + /// The original path to pass to the . + /// The normalized path segments. + /// The normalized path segments, if we stop segmenting after the second one. + /// The normalized form on Windows. + /// The normalized form on Linux or macOS. + public record SamplePath(string OriginalPath, string[] Segments, string[] SegmentsLimit3, string NormalizedOnWindows, string NormalizedOnUnix) + { + public override string ToString() { - public override string ToString() - { - return this.OriginalPath; - } + return this.OriginalPath; } } } diff --git a/src/SMAPI.Tests/Utilities/SDateTests.cs b/src/SMAPI.Tests/Utilities/SDateTests.cs index e2ee6238b..3cf63be77 100644 --- a/src/SMAPI.Tests/Utilities/SDateTests.cs +++ b/src/SMAPI.Tests/Utilities/SDateTests.cs @@ -3,407 +3,413 @@ using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text.RegularExpressions; +using FluentAssertions; using NUnit.Framework; using StardewModdingAPI.Utilities; using StardewValley; -namespace SMAPI.Tests.Utilities +namespace SMAPI.Tests.Utilities; + +/// Unit tests for . +[TestFixture] +internal class SDateTests { - /// Unit tests for . - [TestFixture] - internal class SDateTests - { - /********* - ** Fields - *********/ - /// The valid seasons. - private static readonly string[] ValidSeasons = { "spring", "summer", "fall", "winter" }; + /********* + ** Fields + *********/ + /// The valid seasons. + private static readonly string[] ValidSeasons = { "spring", "summer", "fall", "winter" }; - /// Sample user inputs for season names. - private static readonly string[] SampleSeasonValues = SDateTests.ValidSeasons.Concat(new[] { " WIntEr " }).ToArray(); + /// Sample user inputs for season names. + private static readonly string[] SampleSeasonValues = SDateTests.ValidSeasons.Concat(new[] { " WIntEr " }).ToArray(); - /// All valid days of a month. - private static readonly int[] ValidDays = Enumerable.Range(1, 28).ToArray(); + /// All valid days of a month. + private static readonly int[] ValidDays = Enumerable.Range(1, 28).ToArray(); - /// Sample relative dates for test cases. - private static class Dates - { - /// The base date to which other dates are relative. - public const string Now = "02 summer Y2"; + /// Sample relative dates for test cases. + private static class Dates + { + /// The base date to which other dates are relative. + public const string Now = "02 summer Y2"; - /// The day before . - public const string PrevDay = "01 summer Y2"; + /// The day before . + public const string PrevDay = "01 summer Y2"; - /// The month before . - public const string PrevMonth = "02 spring Y2"; + /// The month before . + public const string PrevMonth = "02 spring Y2"; - /// The year before . - public const string PrevYear = "02 summer Y1"; + /// The year before . + public const string PrevYear = "02 summer Y1"; - /// The day after . - public const string NextDay = "03 summer Y2"; + /// The day after . + public const string NextDay = "03 summer Y2"; - /// The month after . - public const string NextMonth = "02 fall Y2"; + /// The month after . + public const string NextMonth = "02 fall Y2"; - /// The year after . - public const string NextYear = "02 summer Y3"; - } + /// The year after . + public const string NextYear = "02 summer Y3"; + } - /********* - ** Unit tests - *********/ - /**** - ** Constructor - ****/ - [Test(Description = "Assert that the constructor sets the expected values for all valid dates.")] - public void Constructor_SetsExpectedValues([ValueSource(nameof(SDateTests.SampleSeasonValues))] string season, [ValueSource(nameof(SDateTests.ValidDays))] int day, [Values(1, 2, 100)] int year) - { - // arrange - Season expectedSeason = Enum.Parse(season, ignoreCase: true); - - // act - SDate date = new(day, season, year); - - // assert - Assert.AreEqual(day, date.Day); - Assert.AreEqual(expectedSeason, date.Season); - Assert.AreEqual((int)expectedSeason, date.SeasonIndex); - Assert.AreEqual(Utility.getSeasonKey(expectedSeason), date.SeasonKey); - Assert.AreEqual(year, date.Year); - } + /********* + ** Unit tests + *********/ + /**** + ** Constructor + ****/ + [Test(Description = "Assert that the constructor sets the expected values for all valid dates.")] + public void Constructor_SetsExpectedValues([ValueSource(nameof(SDateTests.SampleSeasonValues))] string season, [ValueSource(nameof(SDateTests.ValidDays))] int day, [Values(1, 2, 100)] int year) + { + // arrange + Season expectedSeason = Enum.Parse(season, ignoreCase: true); + + // act + SDate date = new(day, season, year); + + // assert + date.Day.Should().Be(day); + date.Season.Should().Be(expectedSeason); + date.SeasonIndex.Should().Be((int)expectedSeason); + date.SeasonKey.Should().Be(Utility.getSeasonKey(expectedSeason)); + date.Year.Should().Be(year); + } - [Test(Description = "Assert that the constructor throws an exception if the values are invalid.")] - [TestCase(01, "springs", 1)] // invalid season name - [TestCase(-1, "spring", 1)] // day < 0 - [TestCase(0, "spring", 1)] // day zero - [TestCase(0, "spring", 2)] // day zero - [TestCase(29, "spring", 1)] // day > 28 - [TestCase(01, "spring", -1)] // year < 1 - [TestCase(01, "spring", 0)] // year < 1 - [SuppressMessage("ReSharper", "AssignmentIsFullyDiscarded", Justification = "Deliberate for unit test.")] - public void Constructor_RejectsInvalidValues(int day, string season, int year) - { - // act & assert - Assert.Throws(() => _ = new SDate(day, season, year), "Constructing the invalid date didn't throw the expected exception."); - } + [Test(Description = "Assert that the constructor throws an exception if the values are invalid.")] + [TestCase(01, "springs", 1)] // invalid season name + [TestCase(-1, "spring", 1)] // day < 0 + [TestCase(0, "spring", 1)] // day zero + [TestCase(0, "spring", 2)] // day zero + [TestCase(29, "spring", 1)] // day > 28 + [TestCase(01, "spring", -1)] // year < 1 + [TestCase(01, "spring", 0)] // year < 1 + [SuppressMessage("ReSharper", "AssignmentIsFullyDiscarded", Justification = "Deliberate for unit test.")] + public void Constructor_RejectsInvalidValues(int day, string season, int year) + { + // act & assert + FluentActions + .Invoking(() => _ = new SDate(day, season, year)) + .Should().Throw(); + } - /**** - ** FromDaysSinceStart - ****/ - [Test(Description = "Assert that FromDaysSinceStart returns the expected date.")] - [TestCase(1, ExpectedResult = "01 spring Y1")] - [TestCase(2, ExpectedResult = "02 spring Y1")] - [TestCase(28, ExpectedResult = "28 spring Y1")] - [TestCase(29, ExpectedResult = "01 summer Y1")] - [TestCase(141, ExpectedResult = "01 summer Y2")] - public string FromDaysSinceStart(int daysSinceStart) - { - // act - return SDate.FromDaysSinceStart(daysSinceStart).ToString(); - } + /**** + ** FromDaysSinceStart + ****/ + [Test(Description = "Assert that FromDaysSinceStart returns the expected date.")] + [TestCase(1, ExpectedResult = "01 spring Y1")] + [TestCase(2, ExpectedResult = "02 spring Y1")] + [TestCase(28, ExpectedResult = "28 spring Y1")] + [TestCase(29, ExpectedResult = "01 summer Y1")] + [TestCase(141, ExpectedResult = "01 summer Y2")] + public string FromDaysSinceStart(int daysSinceStart) + { + // act + return SDate.FromDaysSinceStart(daysSinceStart).ToString(); + } - [Test(Description = "Assert that FromDaysSinceStart throws an exception if the number of days is invalid.")] - [TestCase(-1)] // day < 0 - [TestCase(0)] // day == 0 - [SuppressMessage("ReSharper", "AssignmentIsFullyDiscarded", Justification = "Deliberate for unit test.")] - public void FromDaysSinceStart_RejectsInvalidValues(int daysSinceStart) - { - // act & assert - Assert.Throws(() => _ = SDate.FromDaysSinceStart(daysSinceStart), "Passing the invalid number of days didn't throw the expected exception."); - } + [Test(Description = "Assert that FromDaysSinceStart throws an exception if the number of days is invalid.")] + [TestCase(-1)] // day < 0 + [TestCase(0)] // day == 0 + [SuppressMessage("ReSharper", "AssignmentIsFullyDiscarded", Justification = "Deliberate for unit test.")] + public void FromDaysSinceStart_RejectsInvalidValues(int daysSinceStart) + { + // act & assert + FluentActions + .Invoking(() => _ = SDate.FromDaysSinceStart(daysSinceStart)) + .Should().Throw(); + } - /**** - ** From - ****/ - [Test(Description = "Assert that SDate.From constructs the correct instance for a given date.")] - [TestCase(0, ExpectedResult = "01 spring Y1")] - [TestCase(1, ExpectedResult = "02 spring Y1")] - [TestCase(27, ExpectedResult = "28 spring Y1")] - [TestCase(28, ExpectedResult = "01 summer Y1")] - [TestCase(140, ExpectedResult = "01 summer Y2")] - public string From_WorldDate(int totalDays) - { - return SDate.From(new WorldDate { TotalDays = totalDays }).ToString(); - } + /**** + ** From + ****/ + [Test(Description = "Assert that SDate.From constructs the correct instance for a given date.")] + [TestCase(0, ExpectedResult = "01 spring Y1")] + [TestCase(1, ExpectedResult = "02 spring Y1")] + [TestCase(27, ExpectedResult = "28 spring Y1")] + [TestCase(28, ExpectedResult = "01 summer Y1")] + [TestCase(140, ExpectedResult = "01 summer Y2")] + public string From_WorldDate(int totalDays) + { + return SDate.From(new WorldDate { TotalDays = totalDays }).ToString(); + } - /**** - ** SeasonIndex - ****/ - [Test(Description = "Assert the numeric index of the season.")] - [TestCase("01 spring Y1", ExpectedResult = 0)] - [TestCase("02 summer Y1", ExpectedResult = 1)] - [TestCase("28 fall Y1", ExpectedResult = 2)] - [TestCase("01 winter Y1", ExpectedResult = 3)] - [TestCase("01 winter Y2", ExpectedResult = 3)] - public int SeasonIndex(string dateStr) - { - // act - return this.GetDate(dateStr).SeasonIndex; - } + /**** + ** SeasonIndex + ****/ + [Test(Description = "Assert the numeric index of the season.")] + [TestCase("01 spring Y1", ExpectedResult = 0)] + [TestCase("02 summer Y1", ExpectedResult = 1)] + [TestCase("28 fall Y1", ExpectedResult = 2)] + [TestCase("01 winter Y1", ExpectedResult = 3)] + [TestCase("01 winter Y2", ExpectedResult = 3)] + public int SeasonIndex(string dateStr) + { + // act + return this.GetDate(dateStr).SeasonIndex; + } - /**** - ** DayOfWeek - ****/ - [Test(Description = "Assert the day of week.")] - [TestCase("01 spring Y1", ExpectedResult = System.DayOfWeek.Monday)] - [TestCase("02 spring Y2", ExpectedResult = System.DayOfWeek.Tuesday)] - [TestCase("03 spring Y3", ExpectedResult = System.DayOfWeek.Wednesday)] - [TestCase("04 spring Y4", ExpectedResult = System.DayOfWeek.Thursday)] - [TestCase("05 spring Y5", ExpectedResult = System.DayOfWeek.Friday)] - [TestCase("06 spring Y6", ExpectedResult = System.DayOfWeek.Saturday)] - [TestCase("07 spring Y7", ExpectedResult = System.DayOfWeek.Sunday)] - [TestCase("08 summer Y8", ExpectedResult = System.DayOfWeek.Monday)] - [TestCase("09 summer Y9", ExpectedResult = System.DayOfWeek.Tuesday)] - [TestCase("10 summer Y10", ExpectedResult = System.DayOfWeek.Wednesday)] - [TestCase("11 summer Y11", ExpectedResult = System.DayOfWeek.Thursday)] - [TestCase("12 summer Y12", ExpectedResult = System.DayOfWeek.Friday)] - [TestCase("13 summer Y13", ExpectedResult = System.DayOfWeek.Saturday)] - [TestCase("14 summer Y14", ExpectedResult = System.DayOfWeek.Sunday)] - [TestCase("15 fall Y15", ExpectedResult = System.DayOfWeek.Monday)] - [TestCase("16 fall Y16", ExpectedResult = System.DayOfWeek.Tuesday)] - [TestCase("17 fall Y17", ExpectedResult = System.DayOfWeek.Wednesday)] - [TestCase("18 fall Y18", ExpectedResult = System.DayOfWeek.Thursday)] - [TestCase("19 fall Y19", ExpectedResult = System.DayOfWeek.Friday)] - [TestCase("20 fall Y20", ExpectedResult = System.DayOfWeek.Saturday)] - [TestCase("21 fall Y21", ExpectedResult = System.DayOfWeek.Sunday)] - [TestCase("22 winter Y22", ExpectedResult = System.DayOfWeek.Monday)] - [TestCase("23 winter Y23", ExpectedResult = System.DayOfWeek.Tuesday)] - [TestCase("24 winter Y24", ExpectedResult = System.DayOfWeek.Wednesday)] - [TestCase("25 winter Y25", ExpectedResult = System.DayOfWeek.Thursday)] - [TestCase("26 winter Y26", ExpectedResult = System.DayOfWeek.Friday)] - [TestCase("27 winter Y27", ExpectedResult = System.DayOfWeek.Saturday)] - [TestCase("28 winter Y28" + "", ExpectedResult = System.DayOfWeek.Sunday)] - public DayOfWeek DayOfWeek(string dateStr) - { - // act - return this.GetDate(dateStr).DayOfWeek; - } + /**** + ** DayOfWeek + ****/ + [Test(Description = "Assert the day of week.")] + [TestCase("01 spring Y1", ExpectedResult = System.DayOfWeek.Monday)] + [TestCase("02 spring Y2", ExpectedResult = System.DayOfWeek.Tuesday)] + [TestCase("03 spring Y3", ExpectedResult = System.DayOfWeek.Wednesday)] + [TestCase("04 spring Y4", ExpectedResult = System.DayOfWeek.Thursday)] + [TestCase("05 spring Y5", ExpectedResult = System.DayOfWeek.Friday)] + [TestCase("06 spring Y6", ExpectedResult = System.DayOfWeek.Saturday)] + [TestCase("07 spring Y7", ExpectedResult = System.DayOfWeek.Sunday)] + [TestCase("08 summer Y8", ExpectedResult = System.DayOfWeek.Monday)] + [TestCase("09 summer Y9", ExpectedResult = System.DayOfWeek.Tuesday)] + [TestCase("10 summer Y10", ExpectedResult = System.DayOfWeek.Wednesday)] + [TestCase("11 summer Y11", ExpectedResult = System.DayOfWeek.Thursday)] + [TestCase("12 summer Y12", ExpectedResult = System.DayOfWeek.Friday)] + [TestCase("13 summer Y13", ExpectedResult = System.DayOfWeek.Saturday)] + [TestCase("14 summer Y14", ExpectedResult = System.DayOfWeek.Sunday)] + [TestCase("15 fall Y15", ExpectedResult = System.DayOfWeek.Monday)] + [TestCase("16 fall Y16", ExpectedResult = System.DayOfWeek.Tuesday)] + [TestCase("17 fall Y17", ExpectedResult = System.DayOfWeek.Wednesday)] + [TestCase("18 fall Y18", ExpectedResult = System.DayOfWeek.Thursday)] + [TestCase("19 fall Y19", ExpectedResult = System.DayOfWeek.Friday)] + [TestCase("20 fall Y20", ExpectedResult = System.DayOfWeek.Saturday)] + [TestCase("21 fall Y21", ExpectedResult = System.DayOfWeek.Sunday)] + [TestCase("22 winter Y22", ExpectedResult = System.DayOfWeek.Monday)] + [TestCase("23 winter Y23", ExpectedResult = System.DayOfWeek.Tuesday)] + [TestCase("24 winter Y24", ExpectedResult = System.DayOfWeek.Wednesday)] + [TestCase("25 winter Y25", ExpectedResult = System.DayOfWeek.Thursday)] + [TestCase("26 winter Y26", ExpectedResult = System.DayOfWeek.Friday)] + [TestCase("27 winter Y27", ExpectedResult = System.DayOfWeek.Saturday)] + [TestCase("28 winter Y28" + "", ExpectedResult = System.DayOfWeek.Sunday)] + public DayOfWeek DayOfWeek(string dateStr) + { + // act + return this.GetDate(dateStr).DayOfWeek; + } - /**** - ** DaysSinceStart - ****/ - [Test(Description = "Assert the number of days since 01 spring Y1 (inclusive).")] - [TestCase("01 spring Y1", ExpectedResult = 1)] - [TestCase("02 spring Y1", ExpectedResult = 2)] - [TestCase("28 spring Y1", ExpectedResult = 28)] - [TestCase("01 summer Y1", ExpectedResult = 29)] - [TestCase("01 summer Y2", ExpectedResult = 141)] - public int DaysSinceStart(string dateStr) - { - // act - return this.GetDate(dateStr).DaysSinceStart; - } + /**** + ** DaysSinceStart + ****/ + [Test(Description = "Assert the number of days since 01 spring Y1 (inclusive).")] + [TestCase("01 spring Y1", ExpectedResult = 1)] + [TestCase("02 spring Y1", ExpectedResult = 2)] + [TestCase("28 spring Y1", ExpectedResult = 28)] + [TestCase("01 summer Y1", ExpectedResult = 29)] + [TestCase("01 summer Y2", ExpectedResult = 141)] + public int DaysSinceStart(string dateStr) + { + // act + return this.GetDate(dateStr).DaysSinceStart; + } - /**** - ** ToString - ****/ - [Test(Description = "Assert that ToString returns the expected string.")] - [TestCase("14 spring Y1", ExpectedResult = "14 spring Y1")] - [TestCase("01 summer Y16", ExpectedResult = "01 summer Y16")] - [TestCase("28 fall Y10", ExpectedResult = "28 fall Y10")] - [TestCase("01 winter Y1", ExpectedResult = "01 winter Y1")] - public string ToString(string dateStr) - { - return this.GetDate(dateStr).ToString(); - } + /**** + ** ToString + ****/ + [Test(Description = "Assert that ToString returns the expected string.")] + [TestCase("14 spring Y1", ExpectedResult = "14 spring Y1")] + [TestCase("01 summer Y16", ExpectedResult = "01 summer Y16")] + [TestCase("28 fall Y10", ExpectedResult = "28 fall Y10")] + [TestCase("01 winter Y1", ExpectedResult = "01 winter Y1")] + public string ToString(string dateStr) + { + return this.GetDate(dateStr).ToString(); + } - /**** - ** AddDays - ****/ - [Test(Description = "Assert that AddDays returns the expected date.")] - [TestCase("01 spring Y1", 15, ExpectedResult = "16 spring Y1")] // day transition - [TestCase("01 spring Y1", 28, ExpectedResult = "01 summer Y1")] // season transition - [TestCase("01 spring Y1", 28 * 4, ExpectedResult = "01 spring Y2")] // year transition - [TestCase("01 spring Y1", 28 * 7 + 17, ExpectedResult = "18 winter Y2")] // year transition - [TestCase("15 spring Y1", -14, ExpectedResult = "01 spring Y1")] // negative day transition - [TestCase("15 summer Y1", -28, ExpectedResult = "15 spring Y1")] // negative season transition - [TestCase("15 summer Y2", -28 * 4, ExpectedResult = "15 summer Y1")] // negative year transition - [TestCase("01 spring Y3", -(28 * 7 + 17), ExpectedResult = "12 spring Y1")] // negative year transition - [TestCase("06 fall Y2", 50, ExpectedResult = "28 winter Y2")] // test for zero-index errors - [TestCase("06 fall Y2", 51, ExpectedResult = "01 spring Y3")] // test for zero-index errors - public string AddDays(string dateStr, int addDays) - { - return this.GetDate(dateStr).AddDays(addDays).ToString(); - } + /**** + ** AddDays + ****/ + [Test(Description = "Assert that AddDays returns the expected date.")] + [TestCase("01 spring Y1", 15, ExpectedResult = "16 spring Y1")] // day transition + [TestCase("01 spring Y1", 28, ExpectedResult = "01 summer Y1")] // season transition + [TestCase("01 spring Y1", 28 * 4, ExpectedResult = "01 spring Y2")] // year transition + [TestCase("01 spring Y1", 28 * 7 + 17, ExpectedResult = "18 winter Y2")] // year transition + [TestCase("15 spring Y1", -14, ExpectedResult = "01 spring Y1")] // negative day transition + [TestCase("15 summer Y1", -28, ExpectedResult = "15 spring Y1")] // negative season transition + [TestCase("15 summer Y2", -28 * 4, ExpectedResult = "15 summer Y1")] // negative year transition + [TestCase("01 spring Y3", -(28 * 7 + 17), ExpectedResult = "12 spring Y1")] // negative year transition + [TestCase("06 fall Y2", 50, ExpectedResult = "28 winter Y2")] // test for zero-index errors + [TestCase("06 fall Y2", 51, ExpectedResult = "01 spring Y3")] // test for zero-index errors + public string AddDays(string dateStr, int addDays) + { + return this.GetDate(dateStr).AddDays(addDays).ToString(); + } - [Test(Description = "Assert that AddDays throws an exception if the number of days is invalid.")] - [TestCase("01 spring Y1", -1)] - [TestCase("01 summer Y1", -29)] - [TestCase("01 spring Y2", -113)] - [SuppressMessage("ReSharper", "AssignmentIsFullyDiscarded", Justification = "Deliberate for unit test.")] - public void AddDays_RejectsInvalidValues(string dateStr, int addDays) - { - // act & assert - Assert.Throws(() => _ = this.GetDate(dateStr).AddDays(addDays), "Passing the invalid number of days didn't throw the expected exception."); - } + [Test(Description = "Assert that AddDays throws an exception if the number of days is invalid.")] + [TestCase("01 spring Y1", -1)] + [TestCase("01 summer Y1", -29)] + [TestCase("01 spring Y2", -113)] + [SuppressMessage("ReSharper", "AssignmentIsFullyDiscarded", Justification = "Deliberate for unit test.")] + public void AddDays_RejectsInvalidValues(string dateStr, int addDays) + { + // act & assert + FluentActions + .Invoking(() => _ = this.GetDate(dateStr).AddDays(addDays)) + .Should().Throw(); + } - /**** - ** GetHashCode - ****/ - [Test(Description = "Assert that GetHashCode returns a unique ordered value for every date.")] - public void GetHashCode_ReturnsUniqueOrderedValue() + /**** + ** GetHashCode + ****/ + [Test(Description = "Assert that GetHashCode returns a unique ordered value for every date.")] + public void GetHashCode_ReturnsUniqueOrderedValue() + { + IDictionary hashes = new Dictionary(); + int lastHash = int.MinValue; + for (int year = 1; year <= 4; year++) { - IDictionary hashes = new Dictionary(); - int lastHash = int.MinValue; - for (int year = 1; year <= 4; year++) + foreach (string season in SDateTests.ValidSeasons) { - foreach (string season in SDateTests.ValidSeasons) + foreach (int day in SDateTests.ValidDays) { - foreach (int day in SDateTests.ValidDays) - { - SDate date = new(day, season, year); - int hash = date.GetHashCode(); - if (hashes.TryGetValue(hash, out SDate? otherDate)) - Assert.Fail($"Received identical hash code {hash} for dates {otherDate} and {date}."); - if (hash < lastHash) - Assert.Fail($"Received smaller hash code for date {date} ({hash}) relative to {hashes[lastHash]} ({lastHash})."); - - lastHash = hash; - hashes[hash] = date; - } + SDate date = new(day, season, year); + int hash = date.GetHashCode(); + if (hashes.TryGetValue(hash, out SDate? otherDate)) + Assert.Fail($"Received identical hash code {hash} for dates {otherDate} and {date}."); + if (hash < lastHash) + Assert.Fail($"Received smaller hash code for date {date} ({hash}) relative to {hashes[lastHash]} ({lastHash})."); + + lastHash = hash; + hashes[hash] = date; } } } + } - /**** - ** ToWorldDate - ****/ - [Test(Description = "Assert that the WorldDate operator returns the corresponding WorldDate.")] - [TestCase("01 spring Y1", ExpectedResult = 0)] - [TestCase("02 spring Y1", ExpectedResult = 1)] - [TestCase("28 spring Y1", ExpectedResult = 27)] - [TestCase("01 summer Y1", ExpectedResult = 28)] - [TestCase("01 summer Y2", ExpectedResult = 140)] - public int ToWorldDate(string dateStr) - { - return this.GetDate(dateStr).ToWorldDate().TotalDays; - } + /**** + ** ToWorldDate + ****/ + [Test(Description = "Assert that the WorldDate operator returns the corresponding WorldDate.")] + [TestCase("01 spring Y1", ExpectedResult = 0)] + [TestCase("02 spring Y1", ExpectedResult = 1)] + [TestCase("28 spring Y1", ExpectedResult = 27)] + [TestCase("01 summer Y1", ExpectedResult = 28)] + [TestCase("01 summer Y2", ExpectedResult = 140)] + public int ToWorldDate(string dateStr) + { + return this.GetDate(dateStr).ToWorldDate().TotalDays; + } - /**** - ** Operators - ****/ - [Test(Description = "Assert that the == operator returns the expected values. We only need a few test cases, since it's based on GetHashCode which is tested more thoroughly.")] - [TestCase(Dates.Now, null, ExpectedResult = false)] - [TestCase(Dates.Now, Dates.PrevDay, ExpectedResult = false)] - [TestCase(Dates.Now, Dates.PrevMonth, ExpectedResult = false)] - [TestCase(Dates.Now, Dates.PrevYear, ExpectedResult = false)] - [TestCase(Dates.Now, Dates.Now, ExpectedResult = true)] - [TestCase(Dates.Now, Dates.NextDay, ExpectedResult = false)] - [TestCase(Dates.Now, Dates.NextMonth, ExpectedResult = false)] - [TestCase(Dates.Now, Dates.NextYear, ExpectedResult = false)] - public bool Operators_Equals(string? now, string other) - { - return this.GetDate(now) == this.GetDate(other); - } + /**** + ** Operators + ****/ + [Test(Description = "Assert that the == operator returns the expected values. We only need a few test cases, since it's based on GetHashCode which is tested more thoroughly.")] + [TestCase(Dates.Now, null, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.PrevDay, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.PrevMonth, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.PrevYear, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.Now, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.NextDay, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.NextMonth, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.NextYear, ExpectedResult = false)] + public bool Operators_Equals(string? now, string other) + { + return this.GetDate(now) == this.GetDate(other); + } - [Test(Description = "Assert that the != operator returns the expected values. We only need a few test cases, since it's based on GetHashCode which is tested more thoroughly.")] - [TestCase(Dates.Now, null, ExpectedResult = true)] - [TestCase(Dates.Now, Dates.PrevDay, ExpectedResult = true)] - [TestCase(Dates.Now, Dates.PrevMonth, ExpectedResult = true)] - [TestCase(Dates.Now, Dates.PrevYear, ExpectedResult = true)] - [TestCase(Dates.Now, Dates.Now, ExpectedResult = false)] - [TestCase(Dates.Now, Dates.NextDay, ExpectedResult = true)] - [TestCase(Dates.Now, Dates.NextMonth, ExpectedResult = true)] - [TestCase(Dates.Now, Dates.NextYear, ExpectedResult = true)] - public bool Operators_NotEquals(string? now, string other) - { - return this.GetDate(now) != this.GetDate(other); - } + [Test(Description = "Assert that the != operator returns the expected values. We only need a few test cases, since it's based on GetHashCode which is tested more thoroughly.")] + [TestCase(Dates.Now, null, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.PrevDay, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.PrevMonth, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.PrevYear, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.Now, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.NextDay, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.NextMonth, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.NextYear, ExpectedResult = true)] + public bool Operators_NotEquals(string? now, string other) + { + return this.GetDate(now) != this.GetDate(other); + } - [Test(Description = "Assert that the < operator returns the expected values. We only need a few test cases, since it's based on GetHashCode which is tested more thoroughly.")] - [TestCase(Dates.Now, null, ExpectedResult = false)] - [TestCase(Dates.Now, Dates.PrevDay, ExpectedResult = false)] - [TestCase(Dates.Now, Dates.PrevMonth, ExpectedResult = false)] - [TestCase(Dates.Now, Dates.PrevYear, ExpectedResult = false)] - [TestCase(Dates.Now, Dates.Now, ExpectedResult = false)] - [TestCase(Dates.Now, Dates.NextDay, ExpectedResult = true)] - [TestCase(Dates.Now, Dates.NextMonth, ExpectedResult = true)] - [TestCase(Dates.Now, Dates.NextYear, ExpectedResult = true)] - public bool Operators_LessThan(string? now, string other) - { - return this.GetDate(now) < this.GetDate(other); - } + [Test(Description = "Assert that the < operator returns the expected values. We only need a few test cases, since it's based on GetHashCode which is tested more thoroughly.")] + [TestCase(Dates.Now, null, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.PrevDay, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.PrevMonth, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.PrevYear, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.Now, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.NextDay, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.NextMonth, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.NextYear, ExpectedResult = true)] + public bool Operators_LessThan(string? now, string other) + { + return this.GetDate(now) < this.GetDate(other); + } - [Test(Description = "Assert that the <= operator returns the expected values. We only need a few test cases, since it's based on GetHashCode which is tested more thoroughly.")] - [TestCase(Dates.Now, null, ExpectedResult = false)] - [TestCase(Dates.Now, Dates.PrevDay, ExpectedResult = false)] - [TestCase(Dates.Now, Dates.PrevMonth, ExpectedResult = false)] - [TestCase(Dates.Now, Dates.PrevYear, ExpectedResult = false)] - [TestCase(Dates.Now, Dates.Now, ExpectedResult = true)] - [TestCase(Dates.Now, Dates.NextDay, ExpectedResult = true)] - [TestCase(Dates.Now, Dates.NextMonth, ExpectedResult = true)] - [TestCase(Dates.Now, Dates.NextYear, ExpectedResult = true)] - public bool Operators_LessThanOrEqual(string? now, string other) - { - return this.GetDate(now) <= this.GetDate(other); - } + [Test(Description = "Assert that the <= operator returns the expected values. We only need a few test cases, since it's based on GetHashCode which is tested more thoroughly.")] + [TestCase(Dates.Now, null, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.PrevDay, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.PrevMonth, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.PrevYear, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.Now, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.NextDay, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.NextMonth, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.NextYear, ExpectedResult = true)] + public bool Operators_LessThanOrEqual(string? now, string other) + { + return this.GetDate(now) <= this.GetDate(other); + } - [Test(Description = "Assert that the > operator returns the expected values. We only need a few test cases, since it's based on GetHashCode which is tested more thoroughly.")] - [TestCase(Dates.Now, null, ExpectedResult = false)] - [TestCase(Dates.Now, Dates.PrevDay, ExpectedResult = true)] - [TestCase(Dates.Now, Dates.PrevMonth, ExpectedResult = true)] - [TestCase(Dates.Now, Dates.PrevYear, ExpectedResult = true)] - [TestCase(Dates.Now, Dates.Now, ExpectedResult = false)] - [TestCase(Dates.Now, Dates.NextDay, ExpectedResult = false)] - [TestCase(Dates.Now, Dates.NextMonth, ExpectedResult = false)] - [TestCase(Dates.Now, Dates.NextYear, ExpectedResult = false)] - public bool Operators_MoreThan(string? now, string other) - { - return this.GetDate(now) > this.GetDate(other); - } + [Test(Description = "Assert that the > operator returns the expected values. We only need a few test cases, since it's based on GetHashCode which is tested more thoroughly.")] + [TestCase(Dates.Now, null, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.PrevDay, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.PrevMonth, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.PrevYear, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.Now, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.NextDay, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.NextMonth, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.NextYear, ExpectedResult = false)] + public bool Operators_MoreThan(string? now, string other) + { + return this.GetDate(now) > this.GetDate(other); + } - [Test(Description = "Assert that the > operator returns the expected values. We only need a few test cases, since it's based on GetHashCode which is tested more thoroughly.")] - [TestCase(Dates.Now, null, ExpectedResult = false)] - [TestCase(Dates.Now, Dates.PrevDay, ExpectedResult = true)] - [TestCase(Dates.Now, Dates.PrevMonth, ExpectedResult = true)] - [TestCase(Dates.Now, Dates.PrevYear, ExpectedResult = true)] - [TestCase(Dates.Now, Dates.Now, ExpectedResult = false)] - [TestCase(Dates.Now, Dates.NextDay, ExpectedResult = false)] - [TestCase(Dates.Now, Dates.NextMonth, ExpectedResult = false)] - [TestCase(Dates.Now, Dates.NextYear, ExpectedResult = false)] - public bool Operators_MoreThanOrEqual(string? now, string other) - { - return this.GetDate(now) > this.GetDate(other); - } + [Test(Description = "Assert that the > operator returns the expected values. We only need a few test cases, since it's based on GetHashCode which is tested more thoroughly.")] + [TestCase(Dates.Now, null, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.PrevDay, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.PrevMonth, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.PrevYear, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.Now, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.NextDay, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.NextMonth, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.NextYear, ExpectedResult = false)] + public bool Operators_MoreThanOrEqual(string? now, string other) + { + return this.GetDate(now) > this.GetDate(other); + } - /********* - ** Private methods - *********/ - /// Convert a string date into a game date, to make unit tests easier to read. - /// The date string like "dd MMMM yy". - [return: NotNullIfNotNull("dateStr")] - private SDate? GetDate(string? dateStr) - { - if (dateStr == null) - return null; + /********* + ** Private methods + *********/ + /// Convert a string date into a game date, to make unit tests easier to read. + /// The date string like "dd MMMM yy". + [return: NotNullIfNotNull("dateStr")] + private SDate? GetDate(string? dateStr) + { + if (dateStr == null) + return null; - void Fail(string reason) => throw new AssertionException($"Couldn't parse date '{dateStr}' because {reason}."); + void Fail(string reason) => throw new AssertionException($"Couldn't parse date '{dateStr}' because {reason}."); - // parse - Match match = Regex.Match(dateStr, @"^(?\d+) (?\w+) Y(?\d+)$"); - if (!match.Success) - Fail("it doesn't match expected pattern (should be like 28 spring Y1)"); + // parse + Match match = Regex.Match(dateStr, @"^(?\d+) (?\w+) Y(?\d+)$"); + if (!match.Success) + Fail("it doesn't match expected pattern (should be like 28 spring Y1)"); - // extract parts - string season = match.Groups["season"].Value; - if (!int.TryParse(match.Groups["day"].Value, out int day)) - Fail($"'{match.Groups["day"].Value}' couldn't be parsed as a day."); - if (!int.TryParse(match.Groups["year"].Value, out int year)) - Fail($"'{match.Groups["year"].Value}' couldn't be parsed as a year."); + // extract parts + string season = match.Groups["season"].Value; + if (!int.TryParse(match.Groups["day"].Value, out int day)) + Fail($"'{match.Groups["day"].Value}' couldn't be parsed as a day."); + if (!int.TryParse(match.Groups["year"].Value, out int year)) + Fail($"'{match.Groups["year"].Value}' couldn't be parsed as a year."); - // build date - return new SDate(day, season, year); - } + // build date + return new SDate(day, season, year); } } diff --git a/src/SMAPI.Tests/Utilities/SemanticVersionTests.cs b/src/SMAPI.Tests/Utilities/SemanticVersionTests.cs index 77c0da5f6..1b47a9d8d 100644 --- a/src/SMAPI.Tests/Utilities/SemanticVersionTests.cs +++ b/src/SMAPI.Tests/Utilities/SemanticVersionTests.cs @@ -1,488 +1,508 @@ using System; using System.Diagnostics.CodeAnalysis; +using FluentAssertions; using Newtonsoft.Json; using NUnit.Framework; using StardewModdingAPI; using StardewModdingAPI.Framework; -namespace SMAPI.Tests.Utilities +namespace SMAPI.Tests.Utilities; + +/// Unit tests for . +[TestFixture] +internal class SemanticVersionTests { - /// Unit tests for . - [TestFixture] - internal class SemanticVersionTests + /********* + ** Unit tests + *********/ + /**** + ** Constructor + ****/ + /// Assert the parsed version when constructed from a standard string. + /// The version string to parse. + [TestCase("1.0", ExpectedResult = "1.0.0")] + [TestCase("1.0.0", ExpectedResult = "1.0.0")] + [TestCase("3000.4000.5000", ExpectedResult = "3000.4000.5000")] + [TestCase("1.2-some-tag.4", ExpectedResult = "1.2.0-some-tag.4")] + [TestCase("1.2.3-some-tag.4", ExpectedResult = "1.2.3-some-tag.4")] + [TestCase("1.2.3-SoME-tAg.4", ExpectedResult = "1.2.3-SoME-tAg.4")] + [TestCase("1.2.3-some-tag.4 ", ExpectedResult = "1.2.3-some-tag.4")] + [TestCase("1.2.3-some-tag.4+build.004", ExpectedResult = "1.2.3-some-tag.4+build.004")] + [TestCase("1.2+3.4.5-build.004", ExpectedResult = "1.2.0+3.4.5-build.004")] + public string Constructor_FromString(string input) { - /********* - ** Unit tests - *********/ - /**** - ** Constructor - ****/ - /// Assert the parsed version when constructed from a standard string. - /// The version string to parse. - [TestCase("1.0", ExpectedResult = "1.0.0")] - [TestCase("1.0.0", ExpectedResult = "1.0.0")] - [TestCase("3000.4000.5000", ExpectedResult = "3000.4000.5000")] - [TestCase("1.2-some-tag.4", ExpectedResult = "1.2.0-some-tag.4")] - [TestCase("1.2.3-some-tag.4", ExpectedResult = "1.2.3-some-tag.4")] - [TestCase("1.2.3-SoME-tAg.4", ExpectedResult = "1.2.3-SoME-tAg.4")] - [TestCase("1.2.3-some-tag.4 ", ExpectedResult = "1.2.3-some-tag.4")] - [TestCase("1.2.3-some-tag.4+build.004", ExpectedResult = "1.2.3-some-tag.4+build.004")] - [TestCase("1.2+3.4.5-build.004", ExpectedResult = "1.2.0+3.4.5-build.004")] - public string Constructor_FromString(string input) - { - // act - ISemanticVersion version = new SemanticVersion(input); + // act + ISemanticVersion version = new SemanticVersion(input); - // assert - return version.ToString(); - } + // assert + return version.ToString(); + } - /// Assert that the constructor rejects invalid values when constructed from a string. - /// The version string to parse. - [Test(Description = "Assert that the constructor throws the expected exception for invalid versions.")] - [TestCase(null)] - [TestCase("")] - [TestCase(" ")] - [TestCase("1")] - [TestCase("01.0")] - [TestCase("1.05")] - [TestCase("1.5.06")] // leading zeros specifically prohibited by spec - [TestCase("1.2.3.4")] - [TestCase("1.apple")] - [TestCase("1.2.apple")] - [TestCase("1.2.3.apple")] - [TestCase("1..2..3")] - [TestCase("1.2.3-")] - [TestCase("1.2.3--some-tag")] - [TestCase("1.2.3-some-tag...")] - [TestCase("1.2.3-some-tag...4")] - [TestCase("1.2.3-some-tag.4+build...4")] - [TestCase("apple")] - [TestCase("-apple")] - [TestCase("-5")] - public void Constructor_FromString_WithInvalidValues(string? input) - { - if (input == null) - this.AssertAndLogException(() => new SemanticVersion(input!)); - else - this.AssertAndLogException(() => new SemanticVersion(input)); - } + /// Assert that the constructor rejects invalid values when constructed from a string. + /// The version string to parse. + [Test(Description = "Assert that the constructor throws the expected exception for invalid versions.")] + [TestCase(null)] + [TestCase("")] + [TestCase(" ")] + [TestCase("1")] + [TestCase("01.0")] + [TestCase("1.05")] + [TestCase("1.5.06")] // leading zeros specifically prohibited by spec + [TestCase("1.2.3.4")] + [TestCase("1.apple")] + [TestCase("1.2.apple")] + [TestCase("1.2.3.apple")] + [TestCase("1..2..3")] + [TestCase("1.2.3-")] + [TestCase("1.2.3--some-tag")] + [TestCase("1.2.3-some-tag...")] + [TestCase("1.2.3-some-tag...4")] + [TestCase("1.2.3-some-tag.4+build...4")] + [TestCase("apple")] + [TestCase("-apple")] + [TestCase("-5")] + public void Constructor_FromString_WithInvalidValues(string? input) + { + if (input == null) + this.AssertAndLogException(() => new SemanticVersion(input!)); + else + this.AssertAndLogException(() => new SemanticVersion(input)); + } - /// Assert the parsed version when constructed from a non-standard string. - /// The version string to parse. - [TestCase("1.2.3", ExpectedResult = "1.2.3")] - [TestCase("1.0.0.0", ExpectedResult = "1.0.0")] - [TestCase("1.0.0.5", ExpectedResult = "1.0.0.5")] - [TestCase("1.2.3.4-some-tag.4 ", ExpectedResult = "1.2.3.4-some-tag.4")] - public string Constructor_FromString_NonStandard(string input) - { - // act - ISemanticVersion version = new SemanticVersion(input, allowNonStandard: true); + /// Assert the parsed version when constructed from a non-standard string. + /// The version string to parse. + [TestCase("1.2.3", ExpectedResult = "1.2.3")] + [TestCase("1.0.0.0", ExpectedResult = "1.0.0")] + [TestCase("1.0.0.5", ExpectedResult = "1.0.0.5")] + [TestCase("1.2.3.4-some-tag.4 ", ExpectedResult = "1.2.3.4-some-tag.4")] + public string Constructor_FromString_NonStandard(string input) + { + // act + ISemanticVersion version = new SemanticVersion(input, allowNonStandard: true); - // assert - return version.ToString(); - } + // assert + return version.ToString(); + } - /// Assert that the constructor rejects a non-standard string when the non-standard flag isn't set. - /// The version string to parse. - [TestCase("1.0.0.0")] - [TestCase("1.0.0.5")] - [TestCase("1.2.3.4-some-tag.4 ")] - public void Constructor_FromString_Standard_DisallowsNonStandardVersion(string input) - { - Assert.Throws(() => _ = new SemanticVersion(input)); - } + /// Assert that the constructor rejects a non-standard string when the non-standard flag isn't set. + /// The version string to parse. + [TestCase("1.0.0.0")] + [TestCase("1.0.0.5")] + [TestCase("1.2.3.4-some-tag.4 ")] + public void Constructor_FromString_Standard_DisallowsNonStandardVersion(string input) + { + FluentActions + .Invoking(() => _ = new SemanticVersion(input)) + .Should().Throw(); + } - /// Assert the parsed version when constructed from standard parts. - /// The major number. - /// The minor number. - /// The patch number. - /// The prerelease tag. - /// The build metadata. - [TestCase(1, 0, 0, null, null, ExpectedResult = "1.0.0")] - [TestCase(3000, 4000, 5000, null, null, ExpectedResult = "3000.4000.5000")] - [TestCase(1, 2, 3, "", null, ExpectedResult = "1.2.3")] - [TestCase(1, 2, 3, " ", null, ExpectedResult = "1.2.3")] - [TestCase(1, 2, 3, "0", null, ExpectedResult = "1.2.3-0")] - [TestCase(1, 2, 3, "some-tag.4", null, ExpectedResult = "1.2.3-some-tag.4")] - [TestCase(1, 2, 3, "sOMe-TaG.4", null, ExpectedResult = "1.2.3-sOMe-TaG.4")] - [TestCase(1, 2, 3, "some-tag.4 ", null, ExpectedResult = "1.2.3-some-tag.4")] - [TestCase(1, 2, 3, "some-tag.4 ", "build.004", ExpectedResult = "1.2.3-some-tag.4+build.004")] - [TestCase(1, 2, 0, null, "3.4.5-build.004", ExpectedResult = "1.2.0+3.4.5-build.004")] - public string Constructor_FromParts(int major, int minor, int patch, string? prerelease, string? build) - { - // act - ISemanticVersion version = new SemanticVersion(major, minor, patch, prerelease, build); + /// Assert the parsed version when constructed from standard parts. + /// The major number. + /// The minor number. + /// The patch number. + /// The prerelease tag. + /// The build metadata. + [TestCase(1, 0, 0, null, null, ExpectedResult = "1.0.0")] + [TestCase(3000, 4000, 5000, null, null, ExpectedResult = "3000.4000.5000")] + [TestCase(1, 2, 3, "", null, ExpectedResult = "1.2.3")] + [TestCase(1, 2, 3, " ", null, ExpectedResult = "1.2.3")] + [TestCase(1, 2, 3, "0", null, ExpectedResult = "1.2.3-0")] + [TestCase(1, 2, 3, "some-tag.4", null, ExpectedResult = "1.2.3-some-tag.4")] + [TestCase(1, 2, 3, "sOMe-TaG.4", null, ExpectedResult = "1.2.3-sOMe-TaG.4")] + [TestCase(1, 2, 3, "some-tag.4 ", null, ExpectedResult = "1.2.3-some-tag.4")] + [TestCase(1, 2, 3, "some-tag.4 ", "build.004", ExpectedResult = "1.2.3-some-tag.4+build.004")] + [TestCase(1, 2, 0, null, "3.4.5-build.004", ExpectedResult = "1.2.0+3.4.5-build.004")] + public string Constructor_FromParts(int major, int minor, int patch, string? prerelease, string? build) + { + // act + ISemanticVersion version = new SemanticVersion(major, minor, patch, prerelease, build); - // assert - this.AssertParts(version, major, minor, patch, prerelease, build, nonStandard: false); - return version.ToString(); - } + // assert + this.AssertParts(version, major, minor, patch, prerelease, build, nonStandard: false); + return version.ToString(); + } - /// Assert the parsed version when constructed from parts including non-standard fields. - /// The major number. - /// The minor number. - /// The patch number. - /// The non-standard platform release number. - /// The prerelease tag. - /// The build metadata. - [TestCase(1, 0, 0, 0, null, null, ExpectedResult = "1.0.0")] - [TestCase(3000, 4000, 5000, 6000, null, null, ExpectedResult = "3000.4000.5000.6000")] - [TestCase(1, 2, 3, 4, "", null, ExpectedResult = "1.2.3.4")] - [TestCase(1, 2, 3, 4, " ", null, ExpectedResult = "1.2.3.4")] - [TestCase(1, 2, 3, 4, "0", null, ExpectedResult = "1.2.3.4-0")] - [TestCase(1, 2, 3, 4, "some-tag.4", null, ExpectedResult = "1.2.3.4-some-tag.4")] - [TestCase(1, 2, 3, 4, "sOMe-TaG.4", null, ExpectedResult = "1.2.3.4-sOMe-TaG.4")] - [TestCase(1, 2, 3, 4, "some-tag.4 ", null, ExpectedResult = "1.2.3.4-some-tag.4")] - [TestCase(1, 2, 3, 4, "some-tag.4 ", "build.004", ExpectedResult = "1.2.3.4-some-tag.4+build.004")] - [TestCase(1, 2, 0, 4, null, "3.4.5-build.004", ExpectedResult = "1.2.0.4+3.4.5-build.004")] - public string Constructor_FromParts_NonStandard(int major, int minor, int patch, int platformRelease, string prerelease, string build) - { - // act - ISemanticVersion version = new SemanticVersion(major, minor, patch, platformRelease, prerelease, build); + /// Assert the parsed version when constructed from parts including non-standard fields. + /// The major number. + /// The minor number. + /// The patch number. + /// The non-standard platform release number. + /// The prerelease tag. + /// The build metadata. + [TestCase(1, 0, 0, 0, null, null, ExpectedResult = "1.0.0")] + [TestCase(3000, 4000, 5000, 6000, null, null, ExpectedResult = "3000.4000.5000.6000")] + [TestCase(1, 2, 3, 4, "", null, ExpectedResult = "1.2.3.4")] + [TestCase(1, 2, 3, 4, " ", null, ExpectedResult = "1.2.3.4")] + [TestCase(1, 2, 3, 4, "0", null, ExpectedResult = "1.2.3.4-0")] + [TestCase(1, 2, 3, 4, "some-tag.4", null, ExpectedResult = "1.2.3.4-some-tag.4")] + [TestCase(1, 2, 3, 4, "sOMe-TaG.4", null, ExpectedResult = "1.2.3.4-sOMe-TaG.4")] + [TestCase(1, 2, 3, 4, "some-tag.4 ", null, ExpectedResult = "1.2.3.4-some-tag.4")] + [TestCase(1, 2, 3, 4, "some-tag.4 ", "build.004", ExpectedResult = "1.2.3.4-some-tag.4+build.004")] + [TestCase(1, 2, 0, 4, null, "3.4.5-build.004", ExpectedResult = "1.2.0.4+3.4.5-build.004")] + public string Constructor_FromParts_NonStandard(int major, int minor, int patch, int platformRelease, string prerelease, string build) + { + // act + ISemanticVersion version = new SemanticVersion(major, minor, patch, platformRelease, prerelease, build); - // assert - this.AssertParts(version, major, minor, patch, prerelease, build, nonStandard: platformRelease != 0); - return version.ToString(); - } + // assert + this.AssertParts(version, major, minor, patch, prerelease, build, nonStandard: platformRelease != 0); + return version.ToString(); + } - /// Assert that the constructor rejects invalid values when constructed from the individual numbers. - /// The major number. - /// The minor number. - /// The patch number. - /// The prerelease tag. - /// The build metadata. - [TestCase(0, 0, 0, null, null)] - [TestCase(-1, 0, 0, null, null)] - [TestCase(0, -1, 0, null, null)] - [TestCase(0, 0, -1, null, null)] - [TestCase(1, 0, 0, "-tag", null)] - [TestCase(1, 0, 0, "tag spaces", null)] - [TestCase(1, 0, 0, "tag~", null)] - [TestCase(1, 0, 0, null, "build~")] - public void Constructor_FromParts_WithInvalidValues(int major, int minor, int patch, string prerelease, string build) - { - this.AssertAndLogException(() => new SemanticVersion(major, minor, patch, prerelease, build)); - } + /// Assert that the constructor rejects invalid values when constructed from the individual numbers. + /// The major number. + /// The minor number. + /// The patch number. + /// The prerelease tag. + /// The build metadata. + [TestCase(0, 0, 0, null, null)] + [TestCase(-1, 0, 0, null, null)] + [TestCase(0, -1, 0, null, null)] + [TestCase(0, 0, -1, null, null)] + [TestCase(1, 0, 0, "-tag", null)] + [TestCase(1, 0, 0, "tag spaces", null)] + [TestCase(1, 0, 0, "tag~", null)] + [TestCase(1, 0, 0, null, "build~")] + public void Constructor_FromParts_WithInvalidValues(int major, int minor, int patch, string prerelease, string build) + { + this.AssertAndLogException(() => new SemanticVersion(major, minor, patch, prerelease, build)); + } - /// Assert the parsed version when constructed from an assembly version. - /// The major number. - /// The minor number. - /// The patch number. - [Test(Description = "Assert that the constructor sets the expected values for all valid versions when constructed from an assembly version.")] - [TestCase(1, 0, 0, ExpectedResult = "1.0.0")] - [TestCase(1, 2, 3, ExpectedResult = "1.2.3")] - [TestCase(3000, 4000, 5000, ExpectedResult = "3000.4000.5000")] - public string Constructor_FromAssemblyVersion(int major, int minor, int patch) - { - // act - ISemanticVersion version = new SemanticVersion(new Version(major, minor, patch)); + /// Assert the parsed version when constructed from an assembly version. + /// The major number. + /// The minor number. + /// The patch number. + [Test(Description = "Assert that the constructor sets the expected values for all valid versions when constructed from an assembly version.")] + [TestCase(1, 0, 0, ExpectedResult = "1.0.0")] + [TestCase(1, 2, 3, ExpectedResult = "1.2.3")] + [TestCase(3000, 4000, 5000, ExpectedResult = "3000.4000.5000")] + public string Constructor_FromAssemblyVersion(int major, int minor, int patch) + { + // act + ISemanticVersion version = new SemanticVersion(new Version(major, minor, patch)); - // assert - this.AssertParts(version, major, minor, patch, null, null, nonStandard: false); - return version.ToString(); - } + // assert + this.AssertParts(version, major, minor, patch, null, null, nonStandard: false); + return version.ToString(); + } - /**** - ** CompareTo - ****/ - /// Assert that returns the expected value. - /// The left version. - /// The right version. - // equal - [TestCase("0.5.7", "0.5.7", ExpectedResult = 0)] - [TestCase("1.0", "1.0", ExpectedResult = 0)] - [TestCase("1.0-beta", "1.0-beta", ExpectedResult = 0)] - [TestCase("1.0-beta.10", "1.0-beta.10", ExpectedResult = 0)] - [TestCase("1.0-beta", "1.0-beta ", ExpectedResult = 0)] - [TestCase("1.0-beta+build.001", "1.0-beta+build.001", ExpectedResult = 0)] - [TestCase("1.0-beta+build.001", "1.0-beta+build.006", ExpectedResult = 0)] // build metadata must not affect precedence - - // less than - [TestCase("0.5.7", "0.5.8", ExpectedResult = -1)] - [TestCase("1.0", "1.1", ExpectedResult = -1)] - [TestCase("1.0-beta", "1.0", ExpectedResult = -1)] - [TestCase("1.0-beta", "1.0-beta.2", ExpectedResult = -1)] - [TestCase("1.0-beta.1", "1.0-beta.2", ExpectedResult = -1)] - [TestCase("1.0-beta.2", "1.0-beta.10", ExpectedResult = -1)] - [TestCase("1.0-beta-2", "1.0-beta-10", ExpectedResult = -1)] - [TestCase("1.0-unofficial.1", "1.0-beta.1", ExpectedResult = -1)] // special case: 'unofficial' has lower priority than official releases - - // more than - [TestCase("0.5.8", "0.5.7", ExpectedResult = 1)] - [TestCase("1.1", "1.0", ExpectedResult = 1)] - [TestCase("1.0", "1.0-beta", ExpectedResult = 1)] - [TestCase("1.0-beta.2", "1.0-beta", ExpectedResult = 1)] - [TestCase("1.0-beta.2", "1.0-beta.1", ExpectedResult = 1)] - [TestCase("1.0-beta.10", "1.0-beta.2", ExpectedResult = 1)] - [TestCase("1.0-beta-10", "1.0-beta-2", ExpectedResult = 1)] - - // null - [TestCase("1.0.0", null, ExpectedResult = 1)] // null is always less than any value per CompareTo remarks - public int CompareTo(string versionStrA, string? versionStrB) - { - // arrange - ISemanticVersion versionA = new SemanticVersion(versionStrA); - ISemanticVersion? versionB = versionStrB != null - ? new SemanticVersion(versionStrB) - : null; - - // assert - return versionA.CompareTo(versionB); - } + /**** + ** CompareTo + ****/ + /// Assert that returns the expected value. + /// The left version. + /// The right version. + // equal + [TestCase("0.5.7", "0.5.7", ExpectedResult = 0)] + [TestCase("1.0", "1.0", ExpectedResult = 0)] + [TestCase("1.0-beta", "1.0-beta", ExpectedResult = 0)] + [TestCase("1.0-beta.10", "1.0-beta.10", ExpectedResult = 0)] + [TestCase("1.0-beta", "1.0-beta ", ExpectedResult = 0)] + [TestCase("1.0-beta+build.001", "1.0-beta+build.001", ExpectedResult = 0)] + [TestCase("1.0-beta+build.001", "1.0-beta+build.006", ExpectedResult = 0)] // build metadata must not affect precedence + + // less than + [TestCase("0.5.7", "0.5.8", ExpectedResult = -1)] + [TestCase("1.0", "1.1", ExpectedResult = -1)] + [TestCase("1.0-beta", "1.0", ExpectedResult = -1)] + [TestCase("1.0-beta", "1.0-beta.2", ExpectedResult = -1)] + [TestCase("1.0-beta.1", "1.0-beta.2", ExpectedResult = -1)] + [TestCase("1.0-beta.2", "1.0-beta.10", ExpectedResult = -1)] + [TestCase("1.0-beta-2", "1.0-beta-10", ExpectedResult = -1)] + [TestCase("1.0-unofficial.1", "1.0-beta.1", ExpectedResult = -1)] // special case: 'unofficial' has lower priority than official releases + + // more than + [TestCase("0.5.8", "0.5.7", ExpectedResult = 1)] + [TestCase("1.1", "1.0", ExpectedResult = 1)] + [TestCase("1.0", "1.0-beta", ExpectedResult = 1)] + [TestCase("1.0-beta.2", "1.0-beta", ExpectedResult = 1)] + [TestCase("1.0-beta.2", "1.0-beta.1", ExpectedResult = 1)] + [TestCase("1.0-beta.10", "1.0-beta.2", ExpectedResult = 1)] + [TestCase("1.0-beta-10", "1.0-beta-2", ExpectedResult = 1)] + + // null + [TestCase("1.0.0", null, ExpectedResult = 1)] // null is always less than any value per CompareTo remarks + public int CompareTo(string versionStrA, string? versionStrB) + { + // arrange + ISemanticVersion versionA = new SemanticVersion(versionStrA); + ISemanticVersion? versionB = versionStrB != null + ? new SemanticVersion(versionStrB) + : null; + + // assert + return versionA.CompareTo(versionB); + } - /**** - ** IsOlderThan - ****/ - /// Assert that and return the expected value. - /// The left version. - /// The right version. - // keep test cases in sync with CompareTo for simplicity. - // equal - [TestCase("0.5.7", "0.5.7", ExpectedResult = false)] - [TestCase("1.0", "1.0", ExpectedResult = false)] - [TestCase("1.0-beta", "1.0-beta", ExpectedResult = false)] - [TestCase("1.0-beta.10", "1.0-beta.10", ExpectedResult = false)] - [TestCase("1.0-beta", "1.0-beta ", ExpectedResult = false)] - [TestCase("1.0-beta+build.001", "1.0-beta+build.001", ExpectedResult = false)] // build metadata must not affect precedence - [TestCase("1.0-beta+build.001", "1.0-beta+build.006", ExpectedResult = false)] // build metadata must not affect precedence - - // less than - [TestCase("0.5.7", "0.5.8", ExpectedResult = true)] - [TestCase("1.0", "1.1", ExpectedResult = true)] - [TestCase("1.0-beta", "1.0", ExpectedResult = true)] - [TestCase("1.0-beta", "1.0-beta.2", ExpectedResult = true)] - [TestCase("1.0-beta.1", "1.0-beta.2", ExpectedResult = true)] - [TestCase("1.0-beta.2", "1.0-beta.10", ExpectedResult = true)] - [TestCase("1.0-beta-2", "1.0-beta-10", ExpectedResult = true)] - - // more than - [TestCase("0.5.8", "0.5.7", ExpectedResult = false)] - [TestCase("1.1", "1.0", ExpectedResult = false)] - [TestCase("1.0", "1.0-beta", ExpectedResult = false)] - [TestCase("1.0-beta.2", "1.0-beta", ExpectedResult = false)] - [TestCase("1.0-beta.2", "1.0-beta.1", ExpectedResult = false)] - [TestCase("1.0-beta.10", "1.0-beta.2", ExpectedResult = false)] - [TestCase("1.0-beta-10", "1.0-beta-2", ExpectedResult = false)] - - // null - [TestCase("1.0.0", null, ExpectedResult = false)] // null is always less than any value per CompareTo remarks - public bool IsOlderThan(string versionStrA, string? versionStrB) - { - // arrange - ISemanticVersion versionA = new SemanticVersion(versionStrA); - ISemanticVersion? versionB = versionStrB != null - ? new SemanticVersion(versionStrB) - : null; - - // assert - Assert.AreEqual(versionA.IsOlderThan(versionB), versionA.IsOlderThan(versionB?.ToString()), "The two signatures returned different results."); - return versionA.IsOlderThan(versionB); - } + /**** + ** IsOlderThan + ****/ + /// Assert that and return the expected value. + /// The left version. + /// The right version. + // keep test cases in sync with CompareTo for simplicity. + // equal + [TestCase("0.5.7", "0.5.7", ExpectedResult = false)] + [TestCase("1.0", "1.0", ExpectedResult = false)] + [TestCase("1.0-beta", "1.0-beta", ExpectedResult = false)] + [TestCase("1.0-beta.10", "1.0-beta.10", ExpectedResult = false)] + [TestCase("1.0-beta", "1.0-beta ", ExpectedResult = false)] + [TestCase("1.0-beta+build.001", "1.0-beta+build.001", ExpectedResult = false)] // build metadata must not affect precedence + [TestCase("1.0-beta+build.001", "1.0-beta+build.006", ExpectedResult = false)] // build metadata must not affect precedence + + // less than + [TestCase("0.5.7", "0.5.8", ExpectedResult = true)] + [TestCase("1.0", "1.1", ExpectedResult = true)] + [TestCase("1.0-beta", "1.0", ExpectedResult = true)] + [TestCase("1.0-beta", "1.0-beta.2", ExpectedResult = true)] + [TestCase("1.0-beta.1", "1.0-beta.2", ExpectedResult = true)] + [TestCase("1.0-beta.2", "1.0-beta.10", ExpectedResult = true)] + [TestCase("1.0-beta-2", "1.0-beta-10", ExpectedResult = true)] + + // more than + [TestCase("0.5.8", "0.5.7", ExpectedResult = false)] + [TestCase("1.1", "1.0", ExpectedResult = false)] + [TestCase("1.0", "1.0-beta", ExpectedResult = false)] + [TestCase("1.0-beta.2", "1.0-beta", ExpectedResult = false)] + [TestCase("1.0-beta.2", "1.0-beta.1", ExpectedResult = false)] + [TestCase("1.0-beta.10", "1.0-beta.2", ExpectedResult = false)] + [TestCase("1.0-beta-10", "1.0-beta-2", ExpectedResult = false)] + + // null + [TestCase("1.0.0", null, ExpectedResult = false)] // null is always less than any value per CompareTo remarks + public bool IsOlderThan(string versionStrA, string? versionStrB) + { + // arrange + ISemanticVersion versionA = new SemanticVersion(versionStrA); + ISemanticVersion? versionB = versionStrB != null + ? new SemanticVersion(versionStrB) + : null; + + // assert + bool olderThanVersion = versionA.IsOlderThan(versionB); + bool olderThanString = versionA.IsOlderThan(versionB?.ToString()); + olderThanVersion.Should().Be(olderThanString, "comparing to a version or string should return the same result"); + + return versionA.IsOlderThan(versionB); + } - /**** - ** IsNewerThan - ****/ - /// Assert that and return the expected value. - /// The left version. - /// The right version. - // keep test cases in sync with CompareTo for simplicity. - // equal - [TestCase("0.5.7", "0.5.7", ExpectedResult = false)] - [TestCase("1.0", "1.0", ExpectedResult = false)] - [TestCase("1.0-beta", "1.0-beta", ExpectedResult = false)] - [TestCase("1.0-beta.10", "1.0-beta.10", ExpectedResult = false)] - [TestCase("1.0-beta", "1.0-beta ", ExpectedResult = false)] - [TestCase("1.0-beta+build.001", "1.0-beta+build.001", ExpectedResult = false)] // build metadata must not affect precedence - [TestCase("1.0-beta+build.001", "1.0-beta+build.006", ExpectedResult = false)] // build metadata must not affect precedence - - // less than - [TestCase("0.5.7", "0.5.8", ExpectedResult = false)] - [TestCase("1.0", "1.1", ExpectedResult = false)] - [TestCase("1.0-beta", "1.0", ExpectedResult = false)] - [TestCase("1.0-beta", "1.0-beta.2", ExpectedResult = false)] - [TestCase("1.0-beta.1", "1.0-beta.2", ExpectedResult = false)] - [TestCase("1.0-beta.2", "1.0-beta.10", ExpectedResult = false)] - [TestCase("1.0-beta-2", "1.0-beta-10", ExpectedResult = false)] - - // more than - [TestCase("0.5.8", "0.5.7", ExpectedResult = true)] - [TestCase("1.1", "1.0", ExpectedResult = true)] - [TestCase("1.0", "1.0-beta", ExpectedResult = true)] - [TestCase("1.0-beta.2", "1.0-beta", ExpectedResult = true)] - [TestCase("1.0-beta.2", "1.0-beta.1", ExpectedResult = true)] - [TestCase("1.0-beta.10", "1.0-beta.2", ExpectedResult = true)] - [TestCase("1.0-beta-10", "1.0-beta-2", ExpectedResult = true)] - - // null - [TestCase("1.0.0", null, ExpectedResult = true)] // null is always less than any value per CompareTo remarks - public bool IsNewerThan(string versionStrA, string? versionStrB) - { - // arrange - ISemanticVersion versionA = new SemanticVersion(versionStrA); - ISemanticVersion? versionB = versionStrB != null - ? new SemanticVersion(versionStrB) - : null; - - // assert - Assert.AreEqual(versionA.IsNewerThan(versionB), versionA.IsNewerThan(versionB?.ToString()), "The two signatures returned different results."); - return versionA.IsNewerThan(versionB); - } + /**** + ** IsNewerThan + ****/ + /// Assert that and return the expected value. + /// The left version. + /// The right version. + // keep test cases in sync with CompareTo for simplicity. + // equal + [TestCase("0.5.7", "0.5.7", ExpectedResult = false)] + [TestCase("1.0", "1.0", ExpectedResult = false)] + [TestCase("1.0-beta", "1.0-beta", ExpectedResult = false)] + [TestCase("1.0-beta.10", "1.0-beta.10", ExpectedResult = false)] + [TestCase("1.0-beta", "1.0-beta ", ExpectedResult = false)] + [TestCase("1.0-beta+build.001", "1.0-beta+build.001", ExpectedResult = false)] // build metadata must not affect precedence + [TestCase("1.0-beta+build.001", "1.0-beta+build.006", ExpectedResult = false)] // build metadata must not affect precedence + + // less than + [TestCase("0.5.7", "0.5.8", ExpectedResult = false)] + [TestCase("1.0", "1.1", ExpectedResult = false)] + [TestCase("1.0-beta", "1.0", ExpectedResult = false)] + [TestCase("1.0-beta", "1.0-beta.2", ExpectedResult = false)] + [TestCase("1.0-beta.1", "1.0-beta.2", ExpectedResult = false)] + [TestCase("1.0-beta.2", "1.0-beta.10", ExpectedResult = false)] + [TestCase("1.0-beta-2", "1.0-beta-10", ExpectedResult = false)] + + // more than + [TestCase("0.5.8", "0.5.7", ExpectedResult = true)] + [TestCase("1.1", "1.0", ExpectedResult = true)] + [TestCase("1.0", "1.0-beta", ExpectedResult = true)] + [TestCase("1.0-beta.2", "1.0-beta", ExpectedResult = true)] + [TestCase("1.0-beta.2", "1.0-beta.1", ExpectedResult = true)] + [TestCase("1.0-beta.10", "1.0-beta.2", ExpectedResult = true)] + [TestCase("1.0-beta-10", "1.0-beta-2", ExpectedResult = true)] + + // null + [TestCase("1.0.0", null, ExpectedResult = true)] // null is always less than any value per CompareTo remarks + public bool IsNewerThan(string versionStrA, string? versionStrB) + { + // arrange + ISemanticVersion versionA = new SemanticVersion(versionStrA); + ISemanticVersion? versionB = versionStrB != null + ? new SemanticVersion(versionStrB) + : null; + + // assert + bool newerThanVersion = versionA.IsNewerThan(versionB); + bool newerThanString = versionA.IsNewerThan(versionB?.ToString()); + newerThanVersion.Should().Be(newerThanString, "comparing to a version or string should return the same result"); + + return versionA.IsNewerThan(versionB); + } - /**** - ** IsBetween - ****/ - /// Assert that and return the expected value. - /// The main version. - /// The lower version number. - /// The upper version number. - [Test(Description = "Assert that version.IsBetween returns the expected value.")] - // is between - [TestCase("0.5.7-beta.3", "0.5.7-beta.3", "0.5.7-beta.3", ExpectedResult = true)] - [TestCase("1.0", "1.0", "1.1", ExpectedResult = true)] - [TestCase("1.0", "1.0-beta", "1.1", ExpectedResult = true)] - [TestCase("1.0", "0.5", "1.1", ExpectedResult = true)] - [TestCase("1.0-beta.2", "1.0-beta.1", "1.0-beta.3", ExpectedResult = true)] - [TestCase("1.0-beta-2", "1.0-beta-1", "1.0-beta-3", ExpectedResult = true)] - [TestCase("1.0.0", null, "1.0.0", ExpectedResult = true)] // null is always less than any value per CompareTo remarks - - // is not between - [TestCase("1.0-beta", "1.0", "1.1", ExpectedResult = false)] - [TestCase("1.0", "1.1", "1.0", ExpectedResult = false)] - [TestCase("1.0-beta.2", "1.1", "1.0", ExpectedResult = false)] - [TestCase("1.0-beta.2", "1.0-beta.10", "1.0-beta.3", ExpectedResult = false)] - [TestCase("1.0-beta-2", "1.0-beta-10", "1.0-beta-3", ExpectedResult = false)] - [TestCase("1.0.0", "1.0.0", null, ExpectedResult = false)] // null is always less than any value per CompareTo remarks - public bool IsBetween(string versionStr, string? lowerStr, string? upperStr) - { - // arrange - ISemanticVersion? lower = lowerStr != null - ? new SemanticVersion(lowerStr) - : null; - ISemanticVersion? upper = upperStr != null - ? new SemanticVersion(upperStr) - : null; - ISemanticVersion version = new SemanticVersion(versionStr); - - // assert - Assert.AreEqual(version.IsBetween(lower, upper), version.IsBetween(lower?.ToString(), upper?.ToString()), "The two signatures returned different results."); - return version.IsBetween(lower, upper); - } + /**** + ** IsBetween + ****/ + /// Assert that and return the expected value. + /// The main version. + /// The lower version number. + /// The upper version number. + [Test(Description = "Assert that version.IsBetween returns the expected value.")] + // is between + [TestCase("0.5.7-beta.3", "0.5.7-beta.3", "0.5.7-beta.3", ExpectedResult = true)] + [TestCase("1.0", "1.0", "1.1", ExpectedResult = true)] + [TestCase("1.0", "1.0-beta", "1.1", ExpectedResult = true)] + [TestCase("1.0", "0.5", "1.1", ExpectedResult = true)] + [TestCase("1.0-beta.2", "1.0-beta.1", "1.0-beta.3", ExpectedResult = true)] + [TestCase("1.0-beta-2", "1.0-beta-1", "1.0-beta-3", ExpectedResult = true)] + [TestCase("1.0.0", null, "1.0.0", ExpectedResult = true)] // null is always less than any value per CompareTo remarks + + // is not between + [TestCase("1.0-beta", "1.0", "1.1", ExpectedResult = false)] + [TestCase("1.0", "1.1", "1.0", ExpectedResult = false)] + [TestCase("1.0-beta.2", "1.1", "1.0", ExpectedResult = false)] + [TestCase("1.0-beta.2", "1.0-beta.10", "1.0-beta.3", ExpectedResult = false)] + [TestCase("1.0-beta-2", "1.0-beta-10", "1.0-beta-3", ExpectedResult = false)] + [TestCase("1.0.0", "1.0.0", null, ExpectedResult = false)] // null is always less than any value per CompareTo remarks + public bool IsBetween(string versionStr, string? lowerStr, string? upperStr) + { + // arrange + ISemanticVersion? lower = lowerStr != null + ? new SemanticVersion(lowerStr) + : null; + ISemanticVersion? upper = upperStr != null + ? new SemanticVersion(upperStr) + : null; + ISemanticVersion version = new SemanticVersion(versionStr); + + // assert + bool betweenVersions = version.IsBetween(lower, upper); + bool betweenStrings = version.IsBetween(lower?.ToString(), upper?.ToString()); + betweenVersions.Should().Be(betweenStrings, "comparing to a version or string should return the same result"); + + return version.IsBetween(lower, upper); + } - /**** - ** Serializable - ****/ - /// Assert that the version can be round-tripped through JSON with no special configuration. - /// The semantic version. - [TestCase("1.0.0")] - [TestCase("1.0.0-beta.400")] - [TestCase("1.0.0-beta.400+build")] - public void Serializable(string versionStr) - { - // act - string json = JsonConvert.SerializeObject(new SemanticVersion(versionStr)); - SemanticVersion? after = JsonConvert.DeserializeObject(json); + /**** + ** Serializable + ****/ + /// Assert that the version can be round-tripped through JSON with no special configuration. + /// The semantic version. + [TestCase("1.0.0")] + [TestCase("1.0.0-beta.400")] + [TestCase("1.0.0-beta.400+build")] + public void Serializable(string versionStr) + { + // act + string json = JsonConvert.SerializeObject(new SemanticVersion(versionStr)); + SemanticVersion? after = JsonConvert.DeserializeObject(json); - // assert - Assert.IsNotNull(after, "The semantic version after deserialization is unexpectedly null."); - Assert.AreEqual(versionStr, after!.ToString(), "The semantic version after deserialization doesn't match the input version."); - } + // assert + after.Should().NotBeNull(); + after!.ToString().Should().Be(versionStr); + } - /**** - ** GameVersion - ****/ - /// Assert that the GameVersion subclass correctly parses non-standard game versions. - /// The raw version. - [TestCase("1.0")] - [TestCase("1.01")] - [TestCase("1.02")] - [TestCase("1.03")] - [TestCase("1.04")] - [TestCase("1.05")] - [TestCase("1.051")] - [TestCase("1.051b")] - [TestCase("1.06")] - [TestCase("1.07")] - [TestCase("1.07a")] - [TestCase("1.08")] - [TestCase("1.1")] - [TestCase("1.11")] - [TestCase("1.2")] - [TestCase("1.2.15")] - [TestCase("1.4.0.1")] - [TestCase("1.4.0.6")] - public void GameVersion(string versionStr) - { - // act - GameVersion version = new(versionStr); + /**** + ** GameVersion + ****/ + /// Assert that the GameVersion subclass correctly parses non-standard game versions. + /// The raw version. + [TestCase("1.0")] + [TestCase("1.01")] + [TestCase("1.02")] + [TestCase("1.03")] + [TestCase("1.04")] + [TestCase("1.05")] + [TestCase("1.051")] + [TestCase("1.051b")] + [TestCase("1.06")] + [TestCase("1.07")] + [TestCase("1.07a")] + [TestCase("1.08")] + [TestCase("1.1")] + [TestCase("1.11")] + [TestCase("1.2")] + [TestCase("1.2.15")] + [TestCase("1.4.0.1")] + [TestCase("1.4.0.6")] + public void GameVersion(string versionStr) + { + // act + GameVersion version = new(versionStr); - // assert - Assert.AreEqual(versionStr, version.ToString(), "The game version did not round-trip to the same value."); - } + // assert + version.ToString().Should().Be(versionStr, "the game version should round-trip to the same value"); + } - /********* - ** Private methods - *********/ - /// Assert that the version matches the expected parts. - /// The version number. - /// The major number. - /// The minor number. - /// The patch number. - /// The prerelease tag. - /// The build metadata. - /// Whether the version should be marked as non-standard. - private void AssertParts(ISemanticVersion version, int major, int minor, int patch, string? prerelease, string? build, bool nonStandard) + /********* + ** Private methods + *********/ + /// Assert that the version matches the expected parts. + /// The version number. + /// The major number. + /// The minor number. + /// The patch number. + /// The prerelease tag. + /// The build metadata. + /// Whether the version should be marked as non-standard. + private void AssertParts(ISemanticVersion version, int major, int minor, int patch, string? prerelease, string? build, bool nonStandard) + { + version.MajorVersion.Should().Be(major); + version.MinorVersion.Should().Be(minor); + version.PatchVersion.Should().Be(patch); + + if (string.IsNullOrWhiteSpace(prerelease)) + version.PrereleaseTag.Should().BeNull(); + else + version.PrereleaseTag.Should().Be(prerelease.Trim()); + + if (string.IsNullOrWhiteSpace(build)) + version.BuildMetadata.Should().BeNull(); + else + version.BuildMetadata.Should().Be(build.Trim()); + + version.IsNonStandard().Should().Be(nonStandard); + } + + /// Assert that the expected exception type is thrown, and log the action output and thrown exception. + /// The expected exception type. + /// The action which may throw the exception. + [SuppressMessage("ReSharper", "UnusedParameter.Local", Justification = "The message argument is deliberately only used in precondition checks since this is an assertion method.")] + private void AssertAndLogException(Func action) + where T : Exception + { + this.AssertAndLogException(() => { - Assert.AreEqual(major, version.MajorVersion, "The major version doesn't match."); - Assert.AreEqual(minor, version.MinorVersion, "The minor version doesn't match."); - Assert.AreEqual(patch, version.PatchVersion, "The patch version doesn't match."); - Assert.AreEqual(string.IsNullOrWhiteSpace(prerelease) ? null : prerelease.Trim(), version.PrereleaseTag, "The prerelease tag doesn't match."); - Assert.AreEqual(string.IsNullOrWhiteSpace(build) ? null : build.Trim(), version.BuildMetadata, "The build metadata doesn't match."); - Assert.AreEqual(nonStandard, version.IsNonStandard(), $"The version is incorrectly marked {(nonStandard ? "standard" : "non-standard")}."); - } + object result = action(); + TestContext.WriteLine($"Func result: {result}"); + }); + } - /// Assert that the expected exception type is thrown, and log the action output and thrown exception. - /// The expected exception type. - /// The action which may throw the exception. - [SuppressMessage("ReSharper", "UnusedParameter.Local", Justification = "The message argument is deliberately only used in precondition checks since this is an assertion method.")] - private void AssertAndLogException(Func action) - where T : Exception + /// Assert that the expected exception type is thrown, and log the thrown exception. + /// The expected exception type. + /// The action which may throw the exception. + /// The message to log if the expected exception isn't thrown. + [SuppressMessage("ReSharper", "UnusedParameter.Local", Justification = "The message argument is deliberately only used in precondition checks since this is an assertion method.")] + private void AssertAndLogException(Action action, string? message = null) + where T : Exception + { + try { - this.AssertAndLogException(() => - { - object result = action(); - TestContext.WriteLine($"Func result: {result}"); - }); + action(); } - - /// Assert that the expected exception type is thrown, and log the thrown exception. - /// The expected exception type. - /// The action which may throw the exception. - /// The message to log if the expected exception isn't thrown. - [SuppressMessage("ReSharper", "UnusedParameter.Local", Justification = "The message argument is deliberately only used in precondition checks since this is an assertion method.")] - private void AssertAndLogException(Action action, string? message = null) - where T : Exception + catch (T ex) + { + TestContext.WriteLine($"Exception thrown:\n{ex}"); + return; + } + catch (Exception ex) when (ex is not AssertionException) { - try - { - action(); - } - catch (T ex) - { - TestContext.WriteLine($"Exception thrown:\n{ex}"); - return; - } - catch (Exception ex) when (ex is not AssertionException) - { - TestContext.WriteLine($"Exception thrown:\n{ex}"); - Assert.Fail(message ?? $"Didn't throw the expected exception; expected {typeof(T).FullName}, got {ex.GetType().FullName}."); - } - - // no exception thrown - Assert.Fail(message ?? "Didn't throw an exception."); + TestContext.WriteLine($"Exception thrown:\n{ex}"); + Assert.Fail(message ?? $"Didn't throw the expected exception; expected {typeof(T).FullName}, got {ex.GetType().FullName}."); } + + // no exception thrown + Assert.Fail(message ?? "Didn't throw an exception."); } } diff --git a/src/SMAPI.Tests/WikiClient/ChangeDescriptorTests.cs b/src/SMAPI.Tests/WikiClient/ChangeDescriptorTests.cs index 8e7e1fb88..fb6c0ea15 100644 --- a/src/SMAPI.Tests/WikiClient/ChangeDescriptorTests.cs +++ b/src/SMAPI.Tests/WikiClient/ChangeDescriptorTests.cs @@ -1,136 +1,136 @@ using System.Collections.Generic; +using FluentAssertions; using NUnit.Framework; using StardewModdingAPI; using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; -namespace SMAPI.Tests.WikiClient +namespace SMAPI.Tests.WikiClient; + +/// Unit tests for . +[TestFixture] +internal class ChangeDescriptorTests { - /// Unit tests for . - [TestFixture] - internal class ChangeDescriptorTests + /********* + ** Unit tests + *********/ + /**** + ** Constructor + ****/ + [Test(Description = "Assert that Parse sets the expected values for valid and invalid descriptors.")] + public void Parse_SetsExpectedValues_Raw() { - /********* - ** Unit tests - *********/ - /**** - ** Constructor - ****/ - [Test(Description = "Assert that Parse sets the expected values for valid and invalid descriptors.")] - public void Parse_SetsExpectedValues_Raw() - { - // arrange - string rawDescriptor = "-Nexus:2400, -B, XX → YY, Nexus:451,+A, XXX → YYY, invalidA →, → invalidB"; - string[] expectedAdd = { "Nexus:451", "A" }; - string[] expectedRemove = { "Nexus:2400", "B" }; - IDictionary expectedReplace = new Dictionary - { - ["XX"] = "YY", - ["XXX"] = "YYY" - }; - string[] expectedErrors = { - "Failed parsing ' invalidA →': can't map to a blank value. Use the '-value' format to remove a value.", - "Failed parsing ' → invalidB': can't map from a blank old value. Use the '+value' format to add a value." - }; - - // act - ChangeDescriptor parsed = ChangeDescriptor.Parse(rawDescriptor, out string[] errors); - - // assert - Assert.That(parsed.Add, Is.EquivalentTo(expectedAdd), $"{nameof(parsed.Add)} doesn't match the expected value."); - Assert.That(parsed.Remove, Is.EquivalentTo(expectedRemove), $"{nameof(parsed.Replace)} doesn't match the expected value."); - Assert.That(parsed.Replace, Is.EquivalentTo(expectedReplace), $"{nameof(parsed.Replace)} doesn't match the expected value."); - Assert.That(errors, Is.EquivalentTo(expectedErrors), $"{nameof(errors)} doesn't match the expected value."); - } - - [Test(Description = "Assert that Parse sets the expected values for descriptors when a format callback is specified.")] - public void Parse_SetsExpectedValues_Formatted() + // arrange + string rawDescriptor = "-Nexus:2400, -B, XX → YY, Nexus:451,+A, XXX → YYY, invalidA →, → invalidB"; + string[] expectedAdd = { "Nexus:451", "A" }; + string[] expectedRemove = { "Nexus:2400", "B" }; + IDictionary expectedReplace = new Dictionary { - // arrange - string rawDescriptor = "-1.0.1, -2.0-beta, 1.00 → 1.0, 1.0.0,+2.0-beta.15, 2.0 → 2.0-beta, invalidA →, → invalidB"; - string[] expectedAdd = { "1.0.0", "2.0.0-beta.15" }; - string[] expectedRemove = { "1.0.1", "2.0.0-beta" }; - IDictionary expectedReplace = new Dictionary - { - ["1.00"] = "1.0.0", - ["2.0.0"] = "2.0.0-beta" - }; - string[] expectedErrors = { - "Failed parsing ' invalidA →': can't map to a blank value. Use the '-value' format to remove a value.", - "Failed parsing ' → invalidB': can't map from a blank old value. Use the '+value' format to add a value." - }; - - // act - ChangeDescriptor parsed = ChangeDescriptor.Parse( - rawDescriptor, - out string[] errors, - formatValue: raw => SemanticVersion.TryParse(raw, out ISemanticVersion? version) - ? version.ToString() - : raw - ); - - // assert - Assert.That(parsed.Add, Is.EquivalentTo(expectedAdd), $"{nameof(parsed.Add)} doesn't match the expected value."); - Assert.That(parsed.Remove, Is.EquivalentTo(expectedRemove), $"{nameof(parsed.Replace)} doesn't match the expected value."); - Assert.That(parsed.Replace, Is.EquivalentTo(expectedReplace), $"{nameof(parsed.Replace)} doesn't match the expected value."); - Assert.That(errors, Is.EquivalentTo(expectedErrors), $"{nameof(errors)} doesn't match the expected value."); - } - - [Test(Description = "Assert that Apply returns the expected value for the given descriptor.")] - - // null input - [TestCase(null, "", ExpectedResult = null)] - [TestCase(null, "+Nexus:2400", ExpectedResult = "Nexus:2400")] - [TestCase(null, "-Nexus:2400", ExpectedResult = null)] - - // blank input - [TestCase("", null, ExpectedResult = "")] - [TestCase("", "", ExpectedResult = "")] - - // add value - [TestCase("", "+Nexus:2400", ExpectedResult = "Nexus:2400")] - [TestCase("Nexus:2400", "+Nexus:2400", ExpectedResult = "Nexus:2400")] - [TestCase("Nexus:2400", "Nexus:2400", ExpectedResult = "Nexus:2400")] - [TestCase("Nexus:2400", "+Nexus:2401", ExpectedResult = "Nexus:2400, Nexus:2401")] - [TestCase("Nexus:2400", "Nexus:2401", ExpectedResult = "Nexus:2400, Nexus:2401")] - - // remove value - [TestCase("", "-Nexus:2400", ExpectedResult = "")] - [TestCase("Nexus:2400", "-Nexus:2400", ExpectedResult = "")] - [TestCase("Nexus:2400", "-Nexus:2401", ExpectedResult = "Nexus:2400")] - - // replace value - [TestCase("", "Nexus:2400 → Nexus:2401", ExpectedResult = "")] - [TestCase("Nexus:2400", "Nexus:2400 → Nexus:2401", ExpectedResult = "Nexus:2401")] - [TestCase("Nexus:1", "Nexus: 2400 → Nexus: 2401", ExpectedResult = "Nexus:1")] - - // complex strings - [TestCase("", "+Nexus:A, Nexus:B, -Chucklefish:14, Nexus:2400 → Nexus:2401, Nexus:A→Nexus:B", ExpectedResult = "Nexus:A, Nexus:B")] - [TestCase("Nexus:2400", "+Nexus:A, Nexus:B, -Chucklefish:14, Nexus:2400 → Nexus:2401, Nexus:A→Nexus:B", ExpectedResult = "Nexus:2401, Nexus:A, Nexus:B")] - [TestCase("Nexus:2400, Nexus:2401, Nexus:B,Chucklefish:14", "+Nexus:A, Nexus:B, -Chucklefish:14, Nexus:2400 → Nexus:2401, Nexus:A→Nexus:B", ExpectedResult = "Nexus:2401, Nexus:2401, Nexus:B, Nexus:A")] - public string Apply_Raw(string input, string? descriptor) - { - ChangeDescriptor parsed = ChangeDescriptor.Parse(descriptor, out string[] errors); - - Assert.IsEmpty(errors, "Parsing the descriptor failed."); - - return parsed.ApplyToCopy(input); - } - - [Test(Description = "Assert that ToString returns the expected normalized descriptors.")] - [TestCase(null, ExpectedResult = "")] - [TestCase("", ExpectedResult = "")] - [TestCase("+ Nexus:2400", ExpectedResult = "+Nexus:2400")] - [TestCase(" Nexus:2400 ", ExpectedResult = "+Nexus:2400")] - [TestCase("-Nexus:2400", ExpectedResult = "-Nexus:2400")] - [TestCase(" Nexus:2400 →Nexus:2401 ", ExpectedResult = "Nexus:2400 → Nexus:2401")] - [TestCase("+Nexus:A, Nexus:B, -Chucklefish:14, Nexus:2400 → Nexus:2401, Nexus:A→Nexus:B", ExpectedResult = "+Nexus:A, +Nexus:B, -Chucklefish:14, Nexus:2400 → Nexus:2401, Nexus:A → Nexus:B")] - public string ToString(string? descriptor) + ["XX"] = "YY", + ["XXX"] = "YYY" + }; + string[] expectedErrors = { + "Failed parsing ' invalidA →': can't map to a blank value. Use the '-value' format to remove a value.", + "Failed parsing ' → invalidB': can't map from a blank old value. Use the '+value' format to add a value." + }; + + // act + ChangeDescriptor parsed = ChangeDescriptor.Parse(rawDescriptor, out string[] errors); + + // assert + parsed.Add.Should().BeEquivalentTo(expectedAdd); + parsed.Remove.Should().BeEquivalentTo(expectedRemove); + parsed.Replace.Should().BeEquivalentTo(expectedReplace); + errors.Should().BeEquivalentTo(expectedErrors); + } + + [Test(Description = "Assert that Parse sets the expected values for descriptors when a format callback is specified.")] + public void Parse_SetsExpectedValues_Formatted() + { + // arrange + string rawDescriptor = "-1.0.1, -2.0-beta, 1.00 → 1.0, 1.0.0,+2.0-beta.15, 2.0 → 2.0-beta, invalidA →, → invalidB"; + string[] expectedAdd = { "1.0.0", "2.0.0-beta.15" }; + string[] expectedRemove = { "1.0.1", "2.0.0-beta" }; + IDictionary expectedReplace = new Dictionary { - var parsed = ChangeDescriptor.Parse(descriptor, out string[] errors); + ["1.00"] = "1.0.0", + ["2.0.0"] = "2.0.0-beta" + }; + string[] expectedErrors = { + "Failed parsing ' invalidA →': can't map to a blank value. Use the '-value' format to remove a value.", + "Failed parsing ' → invalidB': can't map from a blank old value. Use the '+value' format to add a value." + }; + + // act + ChangeDescriptor parsed = ChangeDescriptor.Parse( + rawDescriptor, + out string[] errors, + formatValue: raw => SemanticVersion.TryParse(raw, out ISemanticVersion? version) + ? version.ToString() + : raw + ); + + // assert + parsed.Add.Should().BeEquivalentTo(expectedAdd); + parsed.Remove.Should().BeEquivalentTo(expectedRemove); + parsed.Replace.Should().BeEquivalentTo(expectedReplace); + errors.Should().BeEquivalentTo(expectedErrors); + } + + [Test(Description = "Assert that Apply returns the expected value for the given descriptor.")] + + // null input + [TestCase(null, "", ExpectedResult = null)] + [TestCase(null, "+Nexus:2400", ExpectedResult = "Nexus:2400")] + [TestCase(null, "-Nexus:2400", ExpectedResult = null)] + + // blank input + [TestCase("", null, ExpectedResult = "")] + [TestCase("", "", ExpectedResult = "")] + + // add value + [TestCase("", "+Nexus:2400", ExpectedResult = "Nexus:2400")] + [TestCase("Nexus:2400", "+Nexus:2400", ExpectedResult = "Nexus:2400")] + [TestCase("Nexus:2400", "Nexus:2400", ExpectedResult = "Nexus:2400")] + [TestCase("Nexus:2400", "+Nexus:2401", ExpectedResult = "Nexus:2400, Nexus:2401")] + [TestCase("Nexus:2400", "Nexus:2401", ExpectedResult = "Nexus:2400, Nexus:2401")] + + // remove value + [TestCase("", "-Nexus:2400", ExpectedResult = "")] + [TestCase("Nexus:2400", "-Nexus:2400", ExpectedResult = "")] + [TestCase("Nexus:2400", "-Nexus:2401", ExpectedResult = "Nexus:2400")] + + // replace value + [TestCase("", "Nexus:2400 → Nexus:2401", ExpectedResult = "")] + [TestCase("Nexus:2400", "Nexus:2400 → Nexus:2401", ExpectedResult = "Nexus:2401")] + [TestCase("Nexus:1", "Nexus: 2400 → Nexus: 2401", ExpectedResult = "Nexus:1")] + + // complex strings + [TestCase("", "+Nexus:A, Nexus:B, -Chucklefish:14, Nexus:2400 → Nexus:2401, Nexus:A→Nexus:B", ExpectedResult = "Nexus:A, Nexus:B")] + [TestCase("Nexus:2400", "+Nexus:A, Nexus:B, -Chucklefish:14, Nexus:2400 → Nexus:2401, Nexus:A→Nexus:B", ExpectedResult = "Nexus:2401, Nexus:A, Nexus:B")] + [TestCase("Nexus:2400, Nexus:2401, Nexus:B,Chucklefish:14", "+Nexus:A, Nexus:B, -Chucklefish:14, Nexus:2400 → Nexus:2401, Nexus:A→Nexus:B", ExpectedResult = "Nexus:2401, Nexus:2401, Nexus:B, Nexus:A")] + public string Apply_Raw(string input, string? descriptor) + { + ChangeDescriptor parsed = ChangeDescriptor.Parse(descriptor, out string[] errors); + + errors.Should().BeEmpty(); + + return parsed.ApplyToCopy(input); + } + + [Test(Description = "Assert that ToString returns the expected normalized descriptors.")] + [TestCase(null, ExpectedResult = "")] + [TestCase("", ExpectedResult = "")] + [TestCase("+ Nexus:2400", ExpectedResult = "+Nexus:2400")] + [TestCase(" Nexus:2400 ", ExpectedResult = "+Nexus:2400")] + [TestCase("-Nexus:2400", ExpectedResult = "-Nexus:2400")] + [TestCase(" Nexus:2400 →Nexus:2401 ", ExpectedResult = "Nexus:2400 → Nexus:2401")] + [TestCase("+Nexus:A, Nexus:B, -Chucklefish:14, Nexus:2400 → Nexus:2401, Nexus:A→Nexus:B", ExpectedResult = "+Nexus:A, +Nexus:B, -Chucklefish:14, Nexus:2400 → Nexus:2401, Nexus:A → Nexus:B")] + public string ToString(string? descriptor) + { + var parsed = ChangeDescriptor.Parse(descriptor, out string[] errors); - Assert.IsEmpty(errors, "Parsing the descriptor failed."); + errors.Should().BeEmpty(); - return parsed.ToString(); - } + return parsed.ToString(); } } diff --git a/src/SMAPI.Toolkit.CoreInterfaces/IManifest.cs b/src/SMAPI.Toolkit.CoreInterfaces/IManifest.cs index a0bb747db..227446f13 100644 --- a/src/SMAPI.Toolkit.CoreInterfaces/IManifest.cs +++ b/src/SMAPI.Toolkit.CoreInterfaces/IManifest.cs @@ -1,47 +1,49 @@ using System.Collections.Generic; -namespace StardewModdingAPI +namespace StardewModdingAPI; + +/// A manifest which describes a mod for SMAPI. +public interface IManifest { - /// A manifest which describes a mod for SMAPI. - public interface IManifest - { - /********* - ** Accessors - *********/ - /// The mod name. - string Name { get; } + /********* + ** Accessors + *********/ + /// The mod name. + string Name { get; } + + /// A brief description of the mod. + string Description { get; } - /// A brief description of the mod. - string Description { get; } + /// The mod author's name. + string Author { get; } - /// The mod author's name. - string Author { get; } + /// The mod version. + ISemanticVersion Version { get; } - /// The mod version. - ISemanticVersion Version { get; } + /// The minimum SMAPI version required by this mod, if any. + ISemanticVersion? MinimumApiVersion { get; } - /// The minimum SMAPI version required by this mod, if any. - ISemanticVersion? MinimumApiVersion { get; } + /// The minimum Stardew Valley version required by this mod, if any. + ISemanticVersion? MinimumGameVersion { get; } - /// The minimum Stardew Valley version required by this mod, if any. - ISemanticVersion? MinimumGameVersion { get; } + /// The unique mod ID. + string UniqueID { get; } - /// The unique mod ID. - string UniqueID { get; } + /// The name of the DLL in the directory that has the Entry method. Mutually exclusive with . + string? EntryDll { get; } - /// The name of the DLL in the directory that has the Entry method. Mutually exclusive with . - string? EntryDll { get; } + /// The mod which will read this as a content pack. Mutually exclusive with . + IManifestContentPackFor? ContentPackFor { get; } - /// The mod which will read this as a content pack. Mutually exclusive with . - IManifestContentPackFor? ContentPackFor { get; } + /// The other mods that must be loaded before this mod. + IManifestDependency[] Dependencies { get; } - /// The other mods that must be loaded before this mod. - IManifestDependency[] Dependencies { get; } + /// The assemblies in the mod folder which should only be referenced by this mod. These will be ignored when another mod tries to use assemblies with the same names. + IManifestPrivateAssembly[] PrivateAssemblies { get; } - /// The namespaced mod IDs to query for updates (like Nexus:541). - string[] UpdateKeys { get; } + /// The namespaced mod IDs to query for updates (like Nexus:541). + string[] UpdateKeys { get; } - /// Any manifest fields which didn't match a valid field. - IDictionary ExtraFields { get; } - } + /// Any manifest fields which didn't match a valid field. + IDictionary ExtraFields { get; } } diff --git a/src/SMAPI.Toolkit.CoreInterfaces/IManifestContentPackFor.cs b/src/SMAPI.Toolkit.CoreInterfaces/IManifestContentPackFor.cs index 52ac8f1cc..12568afe4 100644 --- a/src/SMAPI.Toolkit.CoreInterfaces/IManifestContentPackFor.cs +++ b/src/SMAPI.Toolkit.CoreInterfaces/IManifestContentPackFor.cs @@ -1,12 +1,11 @@ -namespace StardewModdingAPI +namespace StardewModdingAPI; + +/// Indicates which mod can read the content pack represented by the containing manifest. +public interface IManifestContentPackFor { - /// Indicates which mod can read the content pack represented by the containing manifest. - public interface IManifestContentPackFor - { - /// The unique ID of the mod which can read this content pack. - string UniqueID { get; } + /// The unique ID of the mod which can read this content pack. + string UniqueID { get; } - /// The minimum required version (if any). - ISemanticVersion? MinimumVersion { get; } - } + /// The minimum required version (if any). + ISemanticVersion? MinimumVersion { get; } } diff --git a/src/SMAPI.Toolkit.CoreInterfaces/IManifestDependency.cs b/src/SMAPI.Toolkit.CoreInterfaces/IManifestDependency.cs index 58425eb2c..2ff722b75 100644 --- a/src/SMAPI.Toolkit.CoreInterfaces/IManifestDependency.cs +++ b/src/SMAPI.Toolkit.CoreInterfaces/IManifestDependency.cs @@ -1,18 +1,17 @@ -namespace StardewModdingAPI +namespace StardewModdingAPI; + +/// A mod dependency listed in a mod manifest. +public interface IManifestDependency { - /// A mod dependency listed in a mod manifest. - public interface IManifestDependency - { - /********* - ** Accessors - *********/ - /// The unique mod ID to require. - string UniqueID { get; } + /********* + ** Accessors + *********/ + /// The unique mod ID to require. + string UniqueID { get; } - /// The minimum required version (if any). - ISemanticVersion? MinimumVersion { get; } + /// The minimum required version (if any). + ISemanticVersion? MinimumVersion { get; } - /// Whether the dependency must be installed to use the mod. - bool IsRequired { get; } - } + /// Whether the dependency must be installed to use the mod. + bool IsRequired { get; } } diff --git a/src/SMAPI.Toolkit.CoreInterfaces/IManifestPrivateAssembly.cs b/src/SMAPI.Toolkit.CoreInterfaces/IManifestPrivateAssembly.cs new file mode 100644 index 000000000..a3b08ebd4 --- /dev/null +++ b/src/SMAPI.Toolkit.CoreInterfaces/IManifestPrivateAssembly.cs @@ -0,0 +1,11 @@ +namespace StardewModdingAPI; + +/// An assembly which should only be referenced by the current mod. It will be ignored when another mod tries to use an assembly with the same name. +public interface IManifestPrivateAssembly +{ + /// The assembly name without metadata, like 'Newtonsoft.Json'. + public string Name { get; } + + /// Whether to disable warnings that an assembly seems to be unused, e.g. because it's accessed via reflection. + public bool UsedDynamically { get; } +} diff --git a/src/SMAPI.Toolkit.CoreInterfaces/ISemanticVersion.cs b/src/SMAPI.Toolkit.CoreInterfaces/ISemanticVersion.cs index 555806c6b..ee57d5eb4 100644 --- a/src/SMAPI.Toolkit.CoreInterfaces/ISemanticVersion.cs +++ b/src/SMAPI.Toolkit.CoreInterfaces/ISemanticVersion.cs @@ -1,78 +1,77 @@ using System; using System.Diagnostics.CodeAnalysis; -namespace StardewModdingAPI +namespace StardewModdingAPI; + +/// A semantic version with an optional release tag. +public interface ISemanticVersion : IComparable, IEquatable { - /// A semantic version with an optional release tag. - public interface ISemanticVersion : IComparable, IEquatable - { - /********* - ** Accessors - *********/ - /// The major version incremented for major API changes. - int MajorVersion { get; } + /********* + ** Accessors + *********/ + /// The major version incremented for major API changes. + int MajorVersion { get; } - /// The minor version incremented for backwards-compatible changes. - int MinorVersion { get; } + /// The minor version incremented for backwards-compatible changes. + int MinorVersion { get; } - /// The patch version for backwards-compatible bug fixes. - int PatchVersion { get; } + /// The patch version for backwards-compatible bug fixes. + int PatchVersion { get; } - /// An optional prerelease tag. - string? PrereleaseTag { get; } + /// An optional prerelease tag. + string? PrereleaseTag { get; } - /// Optional build metadata. This is ignored when determining version precedence. - string? BuildMetadata { get; } + /// Optional build metadata. This is ignored when determining version precedence. + string? BuildMetadata { get; } - /********* - ** Accessors - *********/ - /// Whether this is a prerelease version. + /********* + ** Accessors + *********/ + /// Whether this is a prerelease version. #if NET6_0_OR_GREATER - [MemberNotNullWhen(true, nameof(ISemanticVersion.PrereleaseTag))] + [MemberNotNullWhen(true, nameof(ISemanticVersion.PrereleaseTag))] #endif - bool IsPrerelease(); - - /// Get whether this version is older than the specified version. - /// The version to compare with this instance. - /// Although the parameter is nullable, it isn't optional. A null version is considered earlier than every possible valid version, so passing null to will always return false. - bool IsOlderThan(ISemanticVersion? other); - - /// Get whether this version is older than the specified version. - /// The version to compare with this instance. A null value is never older. - /// The specified version is not a valid semantic version. - /// Although the parameter is nullable, it isn't optional. A null version is considered earlier than every possible valid version, so passing null to will always return false. - bool IsOlderThan(string? other); - - /// Get whether this version is newer than the specified version. - /// The version to compare with this instance. A null value is always older. - /// Although the parameter is nullable, it isn't optional. A null version is considered earlier than every possible valid version, so passing null to will always return true. - bool IsNewerThan(ISemanticVersion? other); - - /// Get whether this version is newer than the specified version. - /// The version to compare with this instance. A null value is always older. - /// The specified version is not a valid semantic version. - /// Although the parameter is nullable, it isn't optional. A null version is considered earlier than every possible valid version, so passing null to will always return true. - bool IsNewerThan(string? other); - - /// Get whether this version is between two specified versions (inclusively). - /// The minimum version. A null value is always older. - /// The maximum version. A null value is never newer. - /// Although the and parameters are nullable, they are not optional. A null version is considered earlier than every possible valid version. For example, passing null to will always return false, since no valid version can be earlier than null. - bool IsBetween(ISemanticVersion? min, ISemanticVersion? max); - - /// Get whether this version is between two specified versions (inclusively). - /// The minimum version. A null value is always older. - /// The maximum version. A null value is never newer. - /// One of the specified versions is not a valid semantic version. - /// Although the and parameters are nullable, they are not optional. A null version is considered earlier than every possible valid version. For example, passing null to will always return false, since no valid version can be earlier than null. - bool IsBetween(string? min, string? max); - - /// Get a string representation of the version. - string ToString(); - - /// Whether the version uses non-standard extensions, like four-part game versions on some platforms. - bool IsNonStandard(); - } + bool IsPrerelease(); + + /// Get whether this version is older than the specified version. + /// The version to compare with this instance. + /// Although the parameter is nullable, it isn't optional. A null version is considered earlier than every possible valid version, so passing null to will always return false. + bool IsOlderThan(ISemanticVersion? other); + + /// Get whether this version is older than the specified version. + /// The version to compare with this instance. A null value is never older. + /// The specified version is not a valid semantic version. + /// Although the parameter is nullable, it isn't optional. A null version is considered earlier than every possible valid version, so passing null to will always return false. + bool IsOlderThan(string? other); + + /// Get whether this version is newer than the specified version. + /// The version to compare with this instance. A null value is always older. + /// Although the parameter is nullable, it isn't optional. A null version is considered earlier than every possible valid version, so passing null to will always return true. + bool IsNewerThan(ISemanticVersion? other); + + /// Get whether this version is newer than the specified version. + /// The version to compare with this instance. A null value is always older. + /// The specified version is not a valid semantic version. + /// Although the parameter is nullable, it isn't optional. A null version is considered earlier than every possible valid version, so passing null to will always return true. + bool IsNewerThan(string? other); + + /// Get whether this version is between two specified versions (inclusively). + /// The minimum version. A null value is always older. + /// The maximum version. A null value is never newer. + /// Although the and parameters are nullable, they are not optional. A null version is considered earlier than every possible valid version. For example, passing null to will always return false, since no valid version can be earlier than null. + bool IsBetween(ISemanticVersion? min, ISemanticVersion? max); + + /// Get whether this version is between two specified versions (inclusively). + /// The minimum version. A null value is always older. + /// The maximum version. A null value is never newer. + /// One of the specified versions is not a valid semantic version. + /// Although the and parameters are nullable, they are not optional. A null version is considered earlier than every possible valid version. For example, passing null to will always return false, since no valid version can be earlier than null. + bool IsBetween(string? min, string? max); + + /// Get a string representation of the version. + string ToString(); + + /// Whether the version uses non-standard extensions, like four-part game versions on some platforms. + bool IsNonStandard(); } diff --git a/src/SMAPI.Toolkit.CoreInterfaces/SMAPI.Toolkit.CoreInterfaces.csproj b/src/SMAPI.Toolkit.CoreInterfaces/SMAPI.Toolkit.CoreInterfaces.csproj index 327f8ed96..ef533c921 100644 --- a/src/SMAPI.Toolkit.CoreInterfaces/SMAPI.Toolkit.CoreInterfaces.csproj +++ b/src/SMAPI.Toolkit.CoreInterfaces/SMAPI.Toolkit.CoreInterfaces.csproj @@ -4,6 +4,8 @@ Provides toolkit interfaces which are available to SMAPI mods. net6.0; netstandard2.0 true + + true diff --git a/src/SMAPI.Toolkit/Framework/Clients/ApiCacheHeaders.cs b/src/SMAPI.Toolkit/Framework/Clients/ApiCacheHeaders.cs new file mode 100644 index 000000000..38bec4dca --- /dev/null +++ b/src/SMAPI.Toolkit/Framework/Clients/ApiCacheHeaders.cs @@ -0,0 +1,47 @@ +using System; +using Pathoschild.Http.Client; + +namespace StardewModdingAPI.Toolkit.Framework.Clients; + +/// The HTTP cache headers set by a remote server. +public record ApiCacheHeaders +{ + /********* + ** Fields + *********/ + /// When the server's data was last updated. + public readonly DateTimeOffset LastModified; + + /// The entity tag which represents the current version of the server's data. + public readonly string? EntityTag; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// When the server's data was last updated. + /// The entity tag which represents the current version of the server's data. + public ApiCacheHeaders(DateTimeOffset lastModified, string? entityTag) + { + this.LastModified = lastModified; + this.EntityTag = entityTag; + } + + /// Read the required cache headers from an API response. + /// The API response whose headers to read. + /// Whether to ignore a missing ETag header instead of throwing an exception. + /// The response is missing one or more of the required HTTP headers (ETag and Last-Modified). + public static ApiCacheHeaders FromResponse(IResponse response, bool allowNullEntityTag = false) + { + return new ApiCacheHeaders( + lastModified: response.Message.Content.Headers.LastModified ?? throw new InvalidOperationException("The API response doesn't include the required Last-Modified header."), + entityTag: + response.Message.Headers.ETag?.Tag + ?? (allowNullEntityTag + ? null + : throw new InvalidOperationException("The API response doesn't include the required ETag header.") + ) + ); + } +} diff --git a/src/SMAPI.Toolkit/Framework/Clients/CurseForgeExport/CurseForgeExportApiClient.cs b/src/SMAPI.Toolkit/Framework/Clients/CurseForgeExport/CurseForgeExportApiClient.cs new file mode 100644 index 000000000..fe5a664f3 --- /dev/null +++ b/src/SMAPI.Toolkit/Framework/Clients/CurseForgeExport/CurseForgeExportApiClient.cs @@ -0,0 +1,52 @@ +using System.Net.Http; +using System.Threading.Tasks; +using Pathoschild.Http.Client; +using StardewModdingAPI.Toolkit.Framework.Clients.CurseForgeExport.ResponseModels; + +namespace StardewModdingAPI.Toolkit.Framework.Clients.CurseForgeExport; + +/// +public class CurseForgeExportApiClient : ICurseForgeExportApiClient +{ + /********* + ** Fields + *********/ + /// The underlying HTTP client. + private readonly IClient Client; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The user agent for the CurseForge export API. + /// The base URL for the CurseForge export API. + public CurseForgeExportApiClient(string userAgent, string baseUrl) + { + this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent); + } + + /// + public async Task FetchCacheHeadersAsync() + { + IResponse response = await this.Client.SendAsync(HttpMethod.Head, ""); + return ApiCacheHeaders.FromResponse(response); + } + + /// + public async Task FetchExportAsync() + { + IResponse response = await this.Client.GetAsync(""); + + CurseForgeFullExport export = await response.As(); + export.CacheHeaders = ApiCacheHeaders.FromResponse(response); + + return export; + } + + /// + public void Dispose() + { + this.Client.Dispose(); + } +} diff --git a/src/SMAPI.Toolkit/Framework/Clients/CurseForgeExport/ICurseForgeExportApiClient.cs b/src/SMAPI.Toolkit/Framework/Clients/CurseForgeExport/ICurseForgeExportApiClient.cs new file mode 100644 index 000000000..e5857e4ad --- /dev/null +++ b/src/SMAPI.Toolkit/Framework/Clients/CurseForgeExport/ICurseForgeExportApiClient.cs @@ -0,0 +1,15 @@ +using System; +using System.Threading.Tasks; +using StardewModdingAPI.Toolkit.Framework.Clients.CurseForgeExport.ResponseModels; + +namespace StardewModdingAPI.Toolkit.Framework.Clients.CurseForgeExport; + +/// An HTTP client for fetching the mod export from the CurseForge export API. +public interface ICurseForgeExportApiClient : IDisposable +{ + /// Fetch the cache headers for the export data on the server. + Task FetchCacheHeadersAsync(); + + /// Fetch the latest export file from the CurseForge export API. + Task FetchExportAsync(); +} diff --git a/src/SMAPI.Toolkit/Framework/Clients/CurseForgeExport/ResponseModels/CurseForgeAuthorExport.cs b/src/SMAPI.Toolkit/Framework/Clients/CurseForgeExport/ResponseModels/CurseForgeAuthorExport.cs new file mode 100644 index 000000000..de9201ce9 --- /dev/null +++ b/src/SMAPI.Toolkit/Framework/Clients/CurseForgeExport/ResponseModels/CurseForgeAuthorExport.cs @@ -0,0 +1,11 @@ +namespace StardewModdingAPI.Toolkit.Framework.Clients.CurseForgeExport.ResponseModels; + +/// The metadata for a user who manages a mod from the CurseForge export API. +public class CurseForgeAuthorExport +{ + /// The author's user ID. + public uint Id { get; set; } + + /// The author's display name. + public string? Name { get; set; } +} diff --git a/src/SMAPI.Toolkit/Framework/Clients/CurseForgeExport/ResponseModels/CurseForgeFileExport.cs b/src/SMAPI.Toolkit/Framework/Clients/CurseForgeExport/ResponseModels/CurseForgeFileExport.cs new file mode 100644 index 000000000..5a7829e07 --- /dev/null +++ b/src/SMAPI.Toolkit/Framework/Clients/CurseForgeExport/ResponseModels/CurseForgeFileExport.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Newtonsoft.Json; + +namespace StardewModdingAPI.Toolkit.Framework.Clients.CurseForgeExport.ResponseModels; + +/// The metadata for an uploaded file for a mod from the CurseForge export API. +public class CurseForgeFileExport +{ + /// The file identifier. + public long Id { get; set; } + + /// The file's display name. + public string? DisplayName { get; set; } + + /// The file internal name. + public string? FileName { get; set; } + + /// The game version for which it was uploaded. + public string? GameVersion { get; set; } + + /// The file release type. + public int ReleaseType { get; set; } + + /// The group the file is listed under, or null if the file predates file groups. + public CurseForgeFileGroupType? FileGroupType { get; set; } + + /// The file version type (e.g. release or beta). + public int VersionTypeId { get; set; } + + /// When the file was uploaded. + public DateTimeOffset FileDate { get; set; } + + /// The extra fields returned by the export API, if any. + [JsonExtensionData] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Used to track any new data provided by the API.")] + public Dictionary? OtherFields; +} diff --git a/src/SMAPI.Toolkit/Framework/Clients/CurseForgeExport/ResponseModels/CurseForgeFileGroupType.cs b/src/SMAPI.Toolkit/Framework/Clients/CurseForgeExport/ResponseModels/CurseForgeFileGroupType.cs new file mode 100644 index 000000000..93a7b64ce --- /dev/null +++ b/src/SMAPI.Toolkit/Framework/Clients/CurseForgeExport/ResponseModels/CurseForgeFileGroupType.cs @@ -0,0 +1,20 @@ +namespace StardewModdingAPI.Toolkit.Framework.Clients.CurseForgeExport.ResponseModels; + +/// The group a file is listed under. +public enum CurseForgeFileGroupType +{ + /// Unknown or invalid group. This is usually a file which predates file group types. + None = 0, + + /// A primary download for the mod (e.g. the version most players should install). + Main = 1, + + /// An optional secondary download. + Optional = 2, + + /// An old version of the mod that was originally in the category. + OldMain = 3, + + /// An old version of the mod that was originally in the category. + OldOptional = 4 +} diff --git a/src/SMAPI.Toolkit/Framework/Clients/CurseForgeExport/ResponseModels/CurseForgeFullExport.cs b/src/SMAPI.Toolkit/Framework/Clients/CurseForgeExport/ResponseModels/CurseForgeFullExport.cs new file mode 100644 index 000000000..a60d2e2d5 --- /dev/null +++ b/src/SMAPI.Toolkit/Framework/Clients/CurseForgeExport/ResponseModels/CurseForgeFullExport.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace StardewModdingAPI.Toolkit.Framework.Clients.CurseForgeExport.ResponseModels; + +/// The metadata for all Stardew Valley from the CurseForge export API. +public class CurseForgeFullExport +{ + /// The mod data indexed by public mod ID. + public Dictionary Mods { get; set; } = new(); + + /// The HTTP cache headers set by a remote server. + [JsonIgnore] + public ApiCacheHeaders CacheHeaders = null!; // set in API client +} diff --git a/src/SMAPI.Toolkit/Framework/Clients/CurseForgeExport/ResponseModels/CurseForgeModExport.cs b/src/SMAPI.Toolkit/Framework/Clients/CurseForgeExport/ResponseModels/CurseForgeModExport.cs new file mode 100644 index 000000000..7513864d3 --- /dev/null +++ b/src/SMAPI.Toolkit/Framework/Clients/CurseForgeExport/ResponseModels/CurseForgeModExport.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Newtonsoft.Json; + +namespace StardewModdingAPI.Toolkit.Framework.Clients.CurseForgeExport.ResponseModels; + +/// The metadata for a mod from the CurseForge export API. +public class CurseForgeModExport +{ + /// The mod ID. + public long Id { get; set; } + + /// The mod's display name. + public string? Name { get; set; } + + /// The URL to the mod's web page on CurseForge. + public string? ModPageUrl { get; set; } + + /// The authors of the mod. + public CurseForgeAuthorExport[] Authors { get; set; } = Array.Empty(); + + /// When the mod was created. + public DateTimeOffset DateCreated { get; set; } + + /// When the mod became public. + public DateTimeOffset DateReleased { get; set; } + + /// When the mod was last modified. + public DateTimeOffset DateModified { get; set; } + + /// The files uploaded for the mod. + public CurseForgeFileExport[] Files { get; set; } = Array.Empty(); + + /// The extra fields returned by the export API, if any. + [JsonExtensionData] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Used to track any new data provided by the API.")] + public Dictionary? OtherFields; +} diff --git a/src/SMAPI.Toolkit/Framework/Clients/ModDropExport/IModDropExportApiClient.cs b/src/SMAPI.Toolkit/Framework/Clients/ModDropExport/IModDropExportApiClient.cs new file mode 100644 index 000000000..e7c8d36ed --- /dev/null +++ b/src/SMAPI.Toolkit/Framework/Clients/ModDropExport/IModDropExportApiClient.cs @@ -0,0 +1,15 @@ +using System; +using System.Threading.Tasks; +using StardewModdingAPI.Toolkit.Framework.Clients.ModDropExport.ResponseModels; + +namespace StardewModdingAPI.Toolkit.Framework.Clients.ModDropExport; + +/// An HTTP client for fetching the mod export from the ModDrop export API. +public interface IModDropExportApiClient : IDisposable +{ + /// Fetch the cache headers for the export data on the server. + Task FetchCacheHeadersAsync(); + + /// Fetch the latest export file from the ModDrop export API. + Task FetchExportAsync(); +} diff --git a/src/SMAPI.Toolkit/Framework/Clients/ModDropExport/ModDropExportApiClient.cs b/src/SMAPI.Toolkit/Framework/Clients/ModDropExport/ModDropExportApiClient.cs new file mode 100644 index 000000000..2b9985c9b --- /dev/null +++ b/src/SMAPI.Toolkit/Framework/Clients/ModDropExport/ModDropExportApiClient.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Pathoschild.Http.Client; +using StardewModdingAPI.Toolkit.Framework.Clients.ModDropExport.ResponseModels; + +namespace StardewModdingAPI.Toolkit.Framework.Clients.ModDropExport; + +/// +public class ModDropExportApiClient : IModDropExportApiClient +{ + /********* + ** Fields + *********/ + /// The underlying HTTP client. + private readonly IClient Client; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The user agent for the ModDrop export API. + /// The base URL for the ModDrop export API. + public ModDropExportApiClient(string userAgent, string baseUrl) + { + this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent); + } + + /// + public async Task FetchCacheHeadersAsync() + { + IResponse response = await this.Client.SendAsync(HttpMethod.Head, ""); + return this.ReadCacheHeaders(response); + } + + /// + public async Task FetchExportAsync() + { + // fetch response + IResponse response = await this.Client.GetAsync(""); + + // read compressed stream + // ModDrop uses pre-compressed data, it doesn't set the HTTP compression headers + IEnumerable rawMods; + using (Stream responseStream = await response.Message.Content.ReadAsStreamAsync()) + using (DeflateStream decompressorStream = new(responseStream, CompressionMode.Decompress)) + using (StreamReader streamReader = new(decompressorStream)) + using (JsonTextReader jsonReader = new(streamReader)) + { + JObject rootData = await JObject.LoadAsync(jsonReader); + JObject data = rootData.Property("data")?.Value.Value() ?? throw new InvalidOperationException("Can't parse ModDrop response: required element 'data' not found"); + rawMods = data.Property("mods")?.Value.Values().ToArray() ?? throw new InvalidOperationException("Can't parse ModDrop response: required element 'data' > 'mods' not found"); + } + + // parse mods + Dictionary mods = new(); + foreach (JObject? rawMod in rawMods) + { + if (rawMod is null) + continue; + + ModDropModExport mod = rawMod.Property("mod")?.Value.ToObject() ?? throw new InvalidOperationException("Can't parse ModDrop response: required element 'mod' not found"); + mod.Files = rawMod.Property("files")?.Value.ToObject()?.ToArray() ?? throw new InvalidOperationException("Can't parse ModDrop response: required element 'mod' not found"); + + mods[mod.Id] = mod; + } + + + // build response + return new() + { + CacheHeaders = this.ReadCacheHeaders(response), + Mods = mods + }; + } + + /// + public void Dispose() + { + this.Client.Dispose(); + } + + + /********* + ** Private methods + *********/ + /// Read the HTTP cache headers from a response. + /// The API response from ModDrop. + private ApiCacheHeaders ReadCacheHeaders(IResponse response) + { + return ApiCacheHeaders.FromResponse(response, allowNullEntityTag: true); // ModDrop doesn't set the ETag header + } +} diff --git a/src/SMAPI.Toolkit/Framework/Clients/ModDropExport/ResponseModels/ModDropFileExport.cs b/src/SMAPI.Toolkit/Framework/Clients/ModDropExport/ResponseModels/ModDropFileExport.cs new file mode 100644 index 000000000..a9cb4bdcb --- /dev/null +++ b/src/SMAPI.Toolkit/Framework/Clients/ModDropExport/ResponseModels/ModDropFileExport.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Newtonsoft.Json; + +namespace StardewModdingAPI.Toolkit.Framework.Clients.ModDropExport.ResponseModels; + +/// The metadata for an uploaded file for a mod from the ModDrop export API. +public class ModDropFileExport +{ + /// The file identifier. + public uint Id { get; set; } + + /// The file's display title. + [JsonProperty("title")] + public string? Name { get; set; } + + /// The file's actual filename. + public string? FileName { get; set; } + + /// The file description. + [JsonProperty("desc")] + public string? Description { get; set; } + + /// The file version. + public string? Version { get; set; } + + /// Whether the file is deleted. + public bool IsDeleted { get; set; } + + /// Whether the file is hidden from users. + public bool IsHidden { get; set; } + + /// Whether this is the default file for the mod. + public bool IsDefault { get; set; } + + /// Whether this is an archived file. + public bool IsOld { get; set; } + + /// Whether this is a pre-release version (e.g. beta). + public bool IsPreRelease { get; set; } + + /// Whether this is an alternative download. + public bool IsAlternative { get; set; } + + /// When the file was uploaded, as a Unix millisecond timestamp since epoch. + public long DateCreated { get; set; } + + /// The extra fields returned by the export API, if any. + [JsonExtensionData] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Used to track any new data provided by the API.")] + public Dictionary? OtherFields; +} diff --git a/src/SMAPI.Toolkit/Framework/Clients/ModDropExport/ResponseModels/ModDropFullExport.cs b/src/SMAPI.Toolkit/Framework/Clients/ModDropExport/ResponseModels/ModDropFullExport.cs new file mode 100644 index 000000000..e29c668d3 --- /dev/null +++ b/src/SMAPI.Toolkit/Framework/Clients/ModDropExport/ResponseModels/ModDropFullExport.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace StardewModdingAPI.Toolkit.Framework.Clients.ModDropExport.ResponseModels; + +/// The metadata for all Stardew Valley from the ModDrop export API. +public class ModDropFullExport +{ + /// The mod data indexed by public mod ID. + public Dictionary Mods { get; set; } = new(); + + /// The HTTP cache headers set by a remote server. + [JsonIgnore] + public ApiCacheHeaders CacheHeaders = null!; // set in API client +} diff --git a/src/SMAPI.Toolkit/Framework/Clients/ModDropExport/ResponseModels/ModDropModExport.cs b/src/SMAPI.Toolkit/Framework/Clients/ModDropExport/ResponseModels/ModDropModExport.cs new file mode 100644 index 000000000..54558a6bf --- /dev/null +++ b/src/SMAPI.Toolkit/Framework/Clients/ModDropExport/ResponseModels/ModDropModExport.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Newtonsoft.Json; + +namespace StardewModdingAPI.Toolkit.Framework.Clients.ModDropExport.ResponseModels; + +/// The metadata for a mod from the ModDrop export API. +public class ModDropModExport +{ + /// The mod ID. + public uint Id { get; set; } + + /// The mod page title. + public string? Title { get; set; } + + /// The author of the mod. + public string? AuthorName { get; set; } + + /// The name of the user who uploaded the mod. + public string? UserName { get; set; } + + /// When the mod was published, as a Unix millisecond timestamp since epoch. + public long DatePublished { get; set; } + + /// When the mod was published, as a Unix millisecond timestamp since epoch. + public long DateUpdated { get; set; } + + /// Whether the mod page is deleted. + public bool IsDeleted { get; set; } + + /// Whether the mod page is published. + public bool IsPublished { get; set; } + + /// Whether the mod page is unlocked. + public bool IsUnlocked { get; set; } + + /// The mod page URL. + public string? PageUrl { get; set; } + + /// ??? + public int Status { get; set; } + + /// The files uploaded for the mod. + public ModDropFileExport[] Files { get; set; } = Array.Empty(); + + /// The extra fields returned by the export API, if any. + [JsonExtensionData] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Used to track any new data provided by the API.")] + public Dictionary? OtherFields; +} diff --git a/src/SMAPI.Toolkit/Framework/Clients/NexusExport/INexusExportApiClient.cs b/src/SMAPI.Toolkit/Framework/Clients/NexusExport/INexusExportApiClient.cs index 0e1007b6f..8f15aad9a 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/NexusExport/INexusExportApiClient.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/NexusExport/INexusExportApiClient.cs @@ -2,12 +2,14 @@ using System.Threading.Tasks; using StardewModdingAPI.Toolkit.Framework.Clients.NexusExport.ResponseModels; -namespace StardewModdingAPI.Toolkit.Framework.Clients.NexusExport +namespace StardewModdingAPI.Toolkit.Framework.Clients.NexusExport; + +/// An HTTP client for fetching the mod export from the Nexus Mods export API. +public interface INexusExportApiClient : IDisposable { - /// An HTTP client for fetching the mod export from the Nexus Mods export API. - public interface INexusExportApiClient : IDisposable - { - /// Fetch the latest export file from the Nexus Mods export API. - public Task FetchExportAsync(); - } + /// Fetch the cache headers for the export data on the server. + Task FetchCacheHeadersAsync(); + + /// Fetch the latest export file from the Nexus Mods export API. + Task FetchExportAsync(); } diff --git a/src/SMAPI.Toolkit/Framework/Clients/NexusExport/NexusExportApiClient.cs b/src/SMAPI.Toolkit/Framework/Clients/NexusExport/NexusExportApiClient.cs index e3d235ac5..50a9bd7d4 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/NexusExport/NexusExportApiClient.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/NexusExport/NexusExportApiClient.cs @@ -1,42 +1,52 @@ +using System.Net.Http; using System.Threading.Tasks; using Pathoschild.Http.Client; using StardewModdingAPI.Toolkit.Framework.Clients.NexusExport.ResponseModels; -namespace StardewModdingAPI.Toolkit.Framework.Clients.NexusExport +namespace StardewModdingAPI.Toolkit.Framework.Clients.NexusExport; + +/// +public class NexusExportApiClient : INexusExportApiClient { - /// - public class NexusExportApiClient : INexusExportApiClient + /********* + ** Fields + *********/ + /// The underlying HTTP client. + private readonly IClient Client; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The user agent for the Nexus export API. + /// The base URL for the Nexus export API. + public NexusExportApiClient(string userAgent, string baseUrl) + { + this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent); + } + + /// + public async Task FetchCacheHeadersAsync() + { + IResponse response = await this.Client.SendAsync(HttpMethod.Head, ""); + return ApiCacheHeaders.FromResponse(response); + } + + /// + public async Task FetchExportAsync() + { + IResponse response = await this.Client.GetAsync(""); + + NexusFullExport export = await response.As(); + export.CacheHeaders = ApiCacheHeaders.FromResponse(response); + + return export; + } + + /// + public void Dispose() { - /********* - ** Fields - *********/ - /// The underlying HTTP client. - private readonly IClient Client; - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The user agent for the Nexus export API. - /// The base URL for the Nexus export API. - public NexusExportApiClient(string userAgent, string baseUrl) - { - this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent); - } - - /// - public async Task FetchExportAsync() - { - return await this.Client - .GetAsync("") - .As(); - } - - /// - public void Dispose() - { - this.Client.Dispose(); - } + this.Client.Dispose(); } } diff --git a/src/SMAPI.Toolkit/Framework/Clients/NexusExport/ResponseModels/NexusFileExport.cs b/src/SMAPI.Toolkit/Framework/Clients/NexusExport/ResponseModels/NexusFileExport.cs index d35721e8d..a34aea67e 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/NexusExport/ResponseModels/NexusFileExport.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/NexusExport/ResponseModels/NexusFileExport.cs @@ -2,45 +2,44 @@ using System.Diagnostics.CodeAnalysis; using Newtonsoft.Json; -namespace StardewModdingAPI.Toolkit.Framework.Clients.NexusExport.ResponseModels +namespace StardewModdingAPI.Toolkit.Framework.Clients.NexusExport.ResponseModels; + +/// The metadata for an uploaded file for a mod from the Nexus Mods export API. +public class NexusFileExport { - /// The metadata for an uploaded file for a mod from the Nexus Mods export API. - public class NexusFileExport - { - /// The unique internal file identifier. - public long Uid { get; set; } + /// The unique internal file identifier. + public long Uid { get; set; } - /// The file's display name. - public string? Name { get; set; } + /// The file's display name. + public string? Name { get; set; } - /// The file's display description. - public string? Description { get; set; } + /// The file's display description. + public string? Description { get; set; } - /// The file name that will be downloaded. - [JsonProperty("uri")] - public string? FileName { get; set; } + /// The file name that will be downloaded. + [JsonProperty("uri")] + public string? FileName { get; set; } - /// The file's semantic version. - public string? Version { get; set; } + /// The file's semantic version. + public string? Version { get; set; } - /// The file category ID. - [JsonProperty("category_id")] - public uint CategoryId { get; set; } + /// The file category ID. + [JsonProperty("category_id")] + public uint CategoryId { get; set; } - /// Whether this is the main Vortex file. - public bool Primary { get; set; } + /// Whether this is the main Vortex file. + public bool Primary { get; set; } - /// The file's size in bytes. - [JsonProperty("size_in_byes")] - public long? SizeInBytes { get; set; } + /// The file's size in bytes. + [JsonProperty("size_in_byes")] + public long? SizeInBytes { get; set; } - /// When the file was uploaded. - [JsonProperty("uploaded_at")] - public long UploadedAt { get; set; } + /// When the file was uploaded. + [JsonProperty("uploaded_at")] + public long UploadedAt { get; set; } - /// The extra fields returned by the export API, if any. - [JsonExtensionData] - [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Used to track any new data provided by the API.")] - public Dictionary? OtherFields; - } + /// The extra fields returned by the export API, if any. + [JsonExtensionData] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Used to track any new data provided by the API.")] + public Dictionary? OtherFields; } diff --git a/src/SMAPI.Toolkit/Framework/Clients/NexusExport/ResponseModels/NexusFullExport.cs b/src/SMAPI.Toolkit/Framework/Clients/NexusExport/ResponseModels/NexusFullExport.cs index 89ec9e81c..b4177b4e0 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/NexusExport/ResponseModels/NexusFullExport.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/NexusExport/ResponseModels/NexusFullExport.cs @@ -1,17 +1,15 @@ -using System; using System.Collections.Generic; using Newtonsoft.Json; -namespace StardewModdingAPI.Toolkit.Framework.Clients.NexusExport.ResponseModels +namespace StardewModdingAPI.Toolkit.Framework.Clients.NexusExport.ResponseModels; + +/// The metadata for all Stardew Valley from the Nexus Mods export API. +public class NexusFullExport { - /// The metadata for all Stardew Valley from the Nexus Mods export API. - public class NexusFullExport - { - /// The mod data indexed by public mod ID. - public Dictionary Data { get; set; } = new(); + /// The mod data indexed by public mod ID. + public Dictionary Data { get; set; } = new(); - /// When this export was last updated. - [JsonProperty("last_updated")] - public DateTimeOffset LastUpdated { get; set; } - } + /// The HTTP cache headers set by a remote server. + [JsonIgnore] + public ApiCacheHeaders CacheHeaders = null!; // set in API client } diff --git a/src/SMAPI.Toolkit/Framework/Clients/NexusExport/ResponseModels/NexusModExport.cs b/src/SMAPI.Toolkit/Framework/Clients/NexusExport/ResponseModels/NexusModExport.cs index 786838c3c..117d506e3 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/NexusExport/ResponseModels/NexusModExport.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/NexusExport/ResponseModels/NexusModExport.cs @@ -2,53 +2,52 @@ using System.Diagnostics.CodeAnalysis; using Newtonsoft.Json; -namespace StardewModdingAPI.Toolkit.Framework.Clients.NexusExport.ResponseModels +namespace StardewModdingAPI.Toolkit.Framework.Clients.NexusExport.ResponseModels; + +/// The metadata for a mod from the Nexus Mods export API. +public class NexusModExport { - /// The metadata for a mod from the Nexus Mods export API. - public class NexusModExport - { - /// The unique internal mod identifier (not the public mod ID). - public long Uid { get; set; } + /// The unique internal mod identifier (not the public mod ID). + public long Uid { get; set; } - /// The mod's display name. - public string? Name { get; set; } + /// The mod's display name. + public string? Name { get; set; } - /// The author display name set for the mod. - public string? Author { get; set; } + /// The author display name set for the mod. + public string? Author { get; set; } - /// The username for the user who uploaded the mod. - public string? Uploader { get; set; } + /// The username for the user who uploaded the mod. + public string? Uploader { get; set; } - /// The ID for the user who uploaded the mod. - [JsonProperty("uploader_id")] - public int UploaderId { get; set; } + /// The ID for the user who uploaded the mod. + [JsonProperty("uploader_id")] + public int UploaderId { get; set; } - /// The mod's semantic version. - public string? Version { get; set; } + /// The mod's semantic version. + public string? Version { get; set; } - /// The category ID. - [JsonProperty("category_id")] - public int CategoryId { get; set; } + /// The category ID. + [JsonProperty("category_id")] + public int CategoryId { get; set; } - /// Whether the mod is published by the author. - public bool Published { get; set; } + /// Whether the mod is published by the author. + public bool Published { get; set; } - /// Whether the mod is hidden by moderators. - public bool Moderated { get; set; } + /// Whether the mod is hidden by moderators. + public bool Moderated { get; set; } - /// Whether the mod page is visible to users. - [JsonProperty("allow_view")] - public bool AllowView { get; set; } + /// Whether the mod page is visible to users. + [JsonProperty("allow_view")] + public bool AllowView { get; set; } - /// Whether the mod is marked as containing adult content. - public bool Adult { get; set; } + /// Whether the mod is marked as containing adult content. + public bool Adult { get; set; } - /// The files uploaded for the mod. - public Dictionary Files { get; set; } = new(); + /// The files uploaded for the mod. + public Dictionary Files { get; set; } = new(); - /// The extra fields returned by the export API, if any. - [JsonExtensionData] - [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Used to track any new data provided by the API.")] - public Dictionary? OtherFields; - } + /// The extra fields returned by the export API, if any. + [JsonExtensionData] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Used to track any new data provided by the API.")] + public Dictionary? OtherFields; } diff --git a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs index 4fc4ea54a..16ec3b1d3 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs @@ -1,34 +1,33 @@ using System; -namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi +namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi; + +/// Metadata about a mod. +public class ModEntryModel { - /// Metadata about a mod. - public class ModEntryModel - { - /********* - ** Accessors - *********/ - /// The mod's unique ID (if known). - public string ID { get; } + /********* + ** Accessors + *********/ + /// The mod's unique ID (if known). + public string ID { get; } - /// The update version recommended by the web API based on its version update and mapping rules. - public ModEntryVersionModel? SuggestedUpdate { get; set; } + /// The update version recommended by the web API based on its version update and mapping rules. + public ModEntryVersionModel? SuggestedUpdate { get; set; } - /// Optional extended data which isn't needed for update checks. - public ModExtendedMetadataModel? Metadata { get; set; } + /// Optional extended data which isn't needed for update checks. + public ModExtendedMetadataModel? Metadata { get; set; } - /// The errors that occurred while fetching update data. - public string[] Errors { get; set; } = Array.Empty(); + /// The errors that occurred while fetching update data. + public string[] Errors { get; set; } = Array.Empty(); - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The mod's unique ID (if known). - public ModEntryModel(string id) - { - this.ID = id; - } + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The mod's unique ID (if known). + public ModEntryModel(string id) + { + this.ID = id; } } diff --git a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryVersionModel.cs b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryVersionModel.cs index a1e789863..8836d113e 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryVersionModel.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryVersionModel.cs @@ -1,32 +1,31 @@ using Newtonsoft.Json; using StardewModdingAPI.Toolkit.Serialization.Converters; -namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi +namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi; + +/// Metadata about a version. +public class ModEntryVersionModel { - /// Metadata about a version. - public class ModEntryVersionModel - { - /********* - ** Accessors - *********/ - /// The version number. - [JsonConverter(typeof(NonStandardSemanticVersionConverter))] - public ISemanticVersion Version { get; } + /********* + ** Accessors + *********/ + /// The version number. + [JsonConverter(typeof(NonStandardSemanticVersionConverter))] + public ISemanticVersion Version { get; } - /// The mod page URL. - public string Url { get; } + /// The mod page URL. + public string Url { get; } - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The version number. - /// The mod page URL. - public ModEntryVersionModel(ISemanticVersion version, string url) - { - this.Version = version; - this.Url = url; - } + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The version number. + /// The mod page URL. + public ModEntryVersionModel(ISemanticVersion version, string url) + { + this.Version = version; + this.Url = url; } } diff --git a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs index 272a20631..0887c95ef 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs @@ -6,163 +6,162 @@ using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; using StardewModdingAPI.Toolkit.Framework.ModData; -namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi +namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi; + +/// Extended metadata about a mod. +public class ModExtendedMetadataModel { - /// Extended metadata about a mod. - public class ModExtendedMetadataModel - { - /********* - ** Accessors - *********/ - /**** - ** Mod info - ****/ - /// The mod's unique ID. A mod may have multiple current IDs in rare cases (e.g. due to parallel releases or unofficial updates). - public string[] ID { get; set; } = Array.Empty(); + /********* + ** Accessors + *********/ + /**** + ** Mod info + ****/ + /// The mod's unique ID. A mod may have multiple current IDs in rare cases (e.g. due to parallel releases or unofficial updates). + public string[] ID { get; set; } = Array.Empty(); - /// The mod's display name. - public string? Name { get; set; } + /// The mod's display name. + public string? Name { get; set; } - /// The mod ID on Nexus. - public int? NexusID { get; set; } + /// The mod ID on Nexus. + public int? NexusID { get; set; } - /// The mod ID in the Chucklefish mod repo. - public int? ChucklefishID { get; set; } + /// The mod ID in the Chucklefish mod repo. + public int? ChucklefishID { get; set; } - /// The mod ID in the CurseForge mod repo. - public int? CurseForgeID { get; set; } + /// The mod ID in the CurseForge mod repo. + public int? CurseForgeID { get; set; } - /// The mod key in the CurseForge mod repo (used in mod page URLs). - public string? CurseForgeKey { get; set; } + /// The mod key in the CurseForge mod repo (used in mod page URLs). + public string? CurseForgeKey { get; set; } - /// The mod ID in the ModDrop mod repo. - public int? ModDropID { get; set; } + /// The mod ID in the ModDrop mod repo. + public int? ModDropID { get; set; } - /// The GitHub repository in the form 'owner/repo'. - public string? GitHubRepo { get; set; } + /// The GitHub repository in the form 'owner/repo'. + public string? GitHubRepo { get; set; } - /// The URL to a non-GitHub source repo. - public string? CustomSourceUrl { get; set; } + /// The URL to a non-GitHub source repo. + public string? CustomSourceUrl { get; set; } - /// The custom mod page URL (if applicable). - public string? CustomUrl { get; set; } + /// The custom mod page URL (if applicable). + public string? CustomUrl { get; set; } - /// The main version. - public ModEntryVersionModel? Main { get; set; } + /// The main version. + public ModEntryVersionModel? Main { get; set; } - /// The latest optional version, if newer than . - public ModEntryVersionModel? Optional { get; set; } + /// The latest optional version, if newer than . + public ModEntryVersionModel? Optional { get; set; } - /// The latest unofficial version, if newer than and . - public ModEntryVersionModel? Unofficial { get; set; } + /// The latest unofficial version, if newer than and . + public ModEntryVersionModel? Unofficial { get; set; } - /// The latest unofficial version for the current Stardew Valley or SMAPI beta, if any. - public ModEntryVersionModel? UnofficialForBeta { get; set; } + /// The latest unofficial version for the current Stardew Valley or SMAPI beta, if any. + public ModEntryVersionModel? UnofficialForBeta { get; set; } - /**** - ** Stable compatibility - ****/ - /// The compatibility status. - [JsonConverter(typeof(StringEnumConverter))] - public WikiCompatibilityStatus? CompatibilityStatus { get; set; } + /**** + ** Stable compatibility + ****/ + /// The compatibility status. + [JsonConverter(typeof(StringEnumConverter))] + public WikiCompatibilityStatus? CompatibilityStatus { get; set; } - /// The human-readable summary of the compatibility status or workaround, without HTML formatting. - public string? CompatibilitySummary { get; set; } + /// The human-readable summary of the compatibility status or workaround, without HTML formatting. + public string? CompatibilitySummary { get; set; } - /// The game or SMAPI version which broke this mod, if applicable. - public string? BrokeIn { get; set; } + /// The game or SMAPI version which broke this mod, if applicable. + public string? BrokeIn { get; set; } - /**** - ** Beta compatibility - ****/ - /// The compatibility status for the Stardew Valley beta (if any). - [JsonConverter(typeof(StringEnumConverter))] - public WikiCompatibilityStatus? BetaCompatibilityStatus { get; set; } + /**** + ** Beta compatibility + ****/ + /// The compatibility status for the Stardew Valley beta (if any). + [JsonConverter(typeof(StringEnumConverter))] + public WikiCompatibilityStatus? BetaCompatibilityStatus { get; set; } - /// The human-readable summary of the compatibility status or workaround for the Stardew Valley beta (if any), without HTML formatting. - public string? BetaCompatibilitySummary { get; set; } + /// The human-readable summary of the compatibility status or workaround for the Stardew Valley beta (if any), without HTML formatting. + public string? BetaCompatibilitySummary { get; set; } - /// The beta game or SMAPI version which broke this mod, if applicable. - public string? BetaBrokeIn { get; set; } + /// The beta game or SMAPI version which broke this mod, if applicable. + public string? BetaBrokeIn { get; set; } - /**** - ** Version mappings - ****/ - /// A serialized change descriptor to apply to the local version during update checks (see ). - public string? ChangeLocalVersions { get; set; } + /**** + ** Version mappings + ****/ + /// A serialized change descriptor to apply to the local version during update checks (see ). + public string? ChangeLocalVersions { get; set; } - /// A serialized change descriptor to apply to the remote version during update checks (see ). - public string? ChangeRemoteVersions { get; set; } + /// A serialized change descriptor to apply to the remote version during update checks (see ). + public string? ChangeRemoteVersions { get; set; } - /// A serialized change descriptor to apply to the update keys during update checks (see ). - public string? ChangeUpdateKeys { get; set; } + /// A serialized change descriptor to apply to the update keys during update checks (see ). + public string? ChangeUpdateKeys { get; set; } - /********* - ** Public methods - *********/ - /// Construct an instance. - public ModExtendedMetadataModel() { } + /********* + ** Public methods + *********/ + /// Construct an instance. + public ModExtendedMetadataModel() { } - /// Construct an instance. - /// The mod metadata from the wiki (if available). - /// The mod metadata from SMAPI's internal DB (if available). - /// The main version. - /// The latest optional version, if newer than . - /// The latest unofficial version, if newer than and . - /// The latest unofficial version for the current Stardew Valley or SMAPI beta, if any. - public ModExtendedMetadataModel(WikiModEntry? wiki, ModDataRecord? db, ModEntryVersionModel? main, ModEntryVersionModel? optional, ModEntryVersionModel? unofficial, ModEntryVersionModel? unofficialForBeta) + /// Construct an instance. + /// The mod metadata from the wiki (if available). + /// The mod metadata from SMAPI's internal DB (if available). + /// The main version. + /// The latest optional version, if newer than . + /// The latest unofficial version, if newer than and . + /// The latest unofficial version for the current Stardew Valley or SMAPI beta, if any. + public ModExtendedMetadataModel(WikiModEntry? wiki, ModDataRecord? db, ModEntryVersionModel? main, ModEntryVersionModel? optional, ModEntryVersionModel? unofficial, ModEntryVersionModel? unofficialForBeta) + { + // versions + this.Main = main; + this.Optional = optional; + this.Unofficial = unofficial; + this.UnofficialForBeta = unofficialForBeta; + + // wiki data + if (wiki != null) { - // versions - this.Main = main; - this.Optional = optional; - this.Unofficial = unofficial; - this.UnofficialForBeta = unofficialForBeta; - - // wiki data - if (wiki != null) - { - this.ID = wiki.ID; - this.Name = wiki.Name.FirstOrDefault(); - this.NexusID = wiki.NexusID; - this.ChucklefishID = wiki.ChucklefishID; - this.CurseForgeID = wiki.CurseForgeID; - this.CurseForgeKey = wiki.CurseForgeKey; - this.ModDropID = wiki.ModDropID; - this.GitHubRepo = wiki.GitHubRepo; - this.CustomSourceUrl = wiki.CustomSourceUrl; - this.CustomUrl = wiki.CustomUrl; - - this.CompatibilityStatus = wiki.Compatibility.Status; - this.CompatibilitySummary = wiki.Compatibility.Summary; - this.BrokeIn = wiki.Compatibility.BrokeIn; - - this.BetaCompatibilityStatus = wiki.BetaCompatibility?.Status; - this.BetaCompatibilitySummary = wiki.BetaCompatibility?.Summary; - this.BetaBrokeIn = wiki.BetaCompatibility?.BrokeIn; - - this.ChangeLocalVersions = wiki.Overrides?.ChangeLocalVersions?.ToString(); - this.ChangeRemoteVersions = wiki.Overrides?.ChangeRemoteVersions?.ToString(); - this.ChangeUpdateKeys = wiki.Overrides?.ChangeUpdateKeys?.ToString(); - } - - // internal DB data - if (db != null) - { - this.ID = this.ID.Union(db.FormerIDs).ToArray(); - this.Name ??= db.DisplayName; - } + this.ID = wiki.ID; + this.Name = wiki.Name.FirstOrDefault(); + this.NexusID = wiki.NexusID; + this.ChucklefishID = wiki.ChucklefishID; + this.CurseForgeID = wiki.CurseForgeID; + this.CurseForgeKey = wiki.CurseForgeKey; + this.ModDropID = wiki.ModDropID; + this.GitHubRepo = wiki.GitHubRepo; + this.CustomSourceUrl = wiki.CustomSourceUrl; + this.CustomUrl = wiki.CustomUrl; + + this.CompatibilityStatus = wiki.Compatibility.Status; + this.CompatibilitySummary = wiki.Compatibility.Summary; + this.BrokeIn = wiki.Compatibility.BrokeIn; + + this.BetaCompatibilityStatus = wiki.BetaCompatibility?.Status; + this.BetaCompatibilitySummary = wiki.BetaCompatibility?.Summary; + this.BetaBrokeIn = wiki.BetaCompatibility?.BrokeIn; + + this.ChangeLocalVersions = wiki.Overrides?.ChangeLocalVersions?.ToString(); + this.ChangeRemoteVersions = wiki.Overrides?.ChangeRemoteVersions?.ToString(); + this.ChangeUpdateKeys = wiki.Overrides?.ChangeUpdateKeys?.ToString(); } - /// Get update keys based on the metadata. - public IEnumerable GetUpdateKeys() + // internal DB data + if (db != null) { - if (this.NexusID.HasValue) - yield return $"Nexus:{this.NexusID}"; - if (this.ChucklefishID.HasValue) - yield return $"Chucklefish:{this.ChucklefishID}"; - if (this.GitHubRepo != null) - yield return $"GitHub:{this.GitHubRepo}"; + this.ID = this.ID.Union(db.FormerIDs).ToArray(); + this.Name ??= db.DisplayName; } } + + /// Get update keys based on the metadata. + public IEnumerable GetUpdateKeys() + { + if (this.NexusID.HasValue) + yield return $"Nexus:{this.NexusID}"; + if (this.ChucklefishID.HasValue) + yield return $"Chucklefish:{this.ChucklefishID}"; + if (this.GitHubRepo != null) + yield return $"GitHub:{this.GitHubRepo}"; + } } diff --git a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModSearchEntryModel.cs b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModSearchEntryModel.cs index 9c11e1dbc..2514e8738 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModSearchEntryModel.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModSearchEntryModel.cs @@ -1,48 +1,47 @@ using System; using System.Linq; -namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi +namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi; + +/// Specifies the identifiers for a mod to match. +public class ModSearchEntryModel { - /// Specifies the identifiers for a mod to match. - public class ModSearchEntryModel + /********* + ** Accessors + *********/ + /// The unique mod ID. + public string ID { get; } + + /// The namespaced mod update keys (if available). + public string[] UpdateKeys { get; private set; } + + /// The mod version installed by the local player. This is used for version mapping in some cases. + public ISemanticVersion? InstalledVersion { get; } + + /// Whether the installed version is broken or could not be loaded. + public bool IsBroken { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The unique mod ID. + /// The version installed by the local player. This is used for version mapping in some cases. + /// The namespaced mod update keys (if available). + /// Whether the installed version is broken or could not be loaded. + public ModSearchEntryModel(string id, ISemanticVersion? installedVersion, string[]? updateKeys, bool isBroken = false) + { + this.ID = id; + this.InstalledVersion = installedVersion; + this.UpdateKeys = updateKeys ?? Array.Empty(); + this.IsBroken = isBroken; + } + + /// Add update keys for the mod. + /// The update keys to add. + public void AddUpdateKeys(params string[] updateKeys) { - /********* - ** Accessors - *********/ - /// The unique mod ID. - public string ID { get; } - - /// The namespaced mod update keys (if available). - public string[] UpdateKeys { get; private set; } - - /// The mod version installed by the local player. This is used for version mapping in some cases. - public ISemanticVersion? InstalledVersion { get; } - - /// Whether the installed version is broken or could not be loaded. - public bool IsBroken { get; } - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The unique mod ID. - /// The version installed by the local player. This is used for version mapping in some cases. - /// The namespaced mod update keys (if available). - /// Whether the installed version is broken or could not be loaded. - public ModSearchEntryModel(string id, ISemanticVersion? installedVersion, string[]? updateKeys, bool isBroken = false) - { - this.ID = id; - this.InstalledVersion = installedVersion; - this.UpdateKeys = updateKeys ?? Array.Empty(); - this.IsBroken = isBroken; - } - - /// Add update keys for the mod. - /// The update keys to add. - public void AddUpdateKeys(params string[] updateKeys) - { - this.UpdateKeys = this.UpdateKeys.Concat(updateKeys).ToArray(); - } + this.UpdateKeys = this.UpdateKeys.Concat(updateKeys).ToArray(); } } diff --git a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModSearchModel.cs b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModSearchModel.cs index 3c74bab0e..c2e70673f 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModSearchModel.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModSearchModel.cs @@ -3,59 +3,58 @@ using System.Linq; using StardewModdingAPI.Toolkit.Utilities; -namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi +namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi; + +/// Specifies mods whose update-check info to fetch. +public class ModSearchModel { - /// Specifies mods whose update-check info to fetch. - public class ModSearchModel + /********* + ** Accessors + *********/ + /// The mods for which to find data. + public ModSearchEntryModel[] Mods { get; set; } + + /// Whether to include extended metadata for each mod. + public bool IncludeExtendedMetadata { get; set; } + + /// The SMAPI version installed by the player. This is used for version mapping in some cases. + public ISemanticVersion ApiVersion { get; set; } + + /// The Stardew Valley version installed by the player. + public ISemanticVersion GameVersion { get; set; } + + /// The OS on which the player plays. + public Platform Platform { get; set; } + + + /********* + ** Public methods + *********/ + /// Construct an empty instance. + [Obsolete("This constructor only exists to support ASP.NET model binding, and shouldn't be used directly.")] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Used by ASP.NET model binding.")] + public ModSearchModel() + { + // ASP.NET Web API needs a public empty constructor for top-level request models, and + // it'll fail if the other constructor is marked with [JsonConstructor]. Apparently + // it's fine with non-empty constructors in nested models like ModSearchEntryModel. + this.Mods = Array.Empty(); + this.ApiVersion = null!; + this.GameVersion = null!; + } + + /// Construct an instance. + /// The mods to search. + /// The SMAPI version installed by the player. If this is null, the API won't provide a recommended update. + /// The Stardew Valley version installed by the player. + /// The OS on which the player plays. + /// Whether to include extended metadata for each mod. + public ModSearchModel(ModSearchEntryModel[] mods, ISemanticVersion apiVersion, ISemanticVersion gameVersion, Platform platform, bool includeExtendedMetadata) { - /********* - ** Accessors - *********/ - /// The mods for which to find data. - public ModSearchEntryModel[] Mods { get; set; } - - /// Whether to include extended metadata for each mod. - public bool IncludeExtendedMetadata { get; set; } - - /// The SMAPI version installed by the player. This is used for version mapping in some cases. - public ISemanticVersion ApiVersion { get; set; } - - /// The Stardew Valley version installed by the player. - public ISemanticVersion GameVersion { get; set; } - - /// The OS on which the player plays. - public Platform Platform { get; set; } - - - /********* - ** Public methods - *********/ - /// Construct an empty instance. - [Obsolete("This constructor only exists to support ASP.NET model binding, and shouldn't be used directly.")] - [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Used by ASP.NET model binding.")] - public ModSearchModel() - { - // ASP.NET Web API needs a public empty constructor for top-level request models, and - // it'll fail if the other constructor is marked with [JsonConstructor]. Apparently - // it's fine with non-empty constructors in nested models like ModSearchEntryModel. - this.Mods = Array.Empty(); - this.ApiVersion = null!; - this.GameVersion = null!; - } - - /// Construct an instance. - /// The mods to search. - /// The SMAPI version installed by the player. If this is null, the API won't provide a recommended update. - /// The Stardew Valley version installed by the player. - /// The OS on which the player plays. - /// Whether to include extended metadata for each mod. - public ModSearchModel(ModSearchEntryModel[] mods, ISemanticVersion apiVersion, ISemanticVersion gameVersion, Platform platform, bool includeExtendedMetadata) - { - this.Mods = mods.ToArray(); - this.ApiVersion = apiVersion; - this.GameVersion = gameVersion; - this.Platform = platform; - this.IncludeExtendedMetadata = includeExtendedMetadata; - } + this.Mods = mods.ToArray(); + this.ApiVersion = apiVersion; + this.GameVersion = gameVersion; + this.Platform = platform; + this.IncludeExtendedMetadata = includeExtendedMetadata; } } diff --git a/src/SMAPI.Toolkit/Framework/Clients/WebApi/WebApiClient.cs b/src/SMAPI.Toolkit/Framework/Clients/WebApi/WebApiClient.cs index ef1904d43..ca695b09b 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/WebApi/WebApiClient.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/WebApi/WebApiClient.cs @@ -6,58 +6,57 @@ using StardewModdingAPI.Toolkit.Serialization; using StardewModdingAPI.Toolkit.Utilities; -namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi +namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi; + +/// Provides methods for interacting with the SMAPI web API. +public class WebApiClient : IDisposable { - /// Provides methods for interacting with the SMAPI web API. - public class WebApiClient : IDisposable + /********* + ** Fields + *********/ + /// The API version number. + private readonly ISemanticVersion Version; + + /// The underlying HTTP client. + private readonly IClient Client; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The base URL for the web API. + /// The web API version. + public WebApiClient(string baseUrl, ISemanticVersion version) + { + this.Version = version; + this.Client = new FluentClient(baseUrl) + .SetUserAgent($"SMAPI/{version}"); + + this.Client.Formatters.JsonFormatter.SerializerSettings = JsonHelper.CreateDefaultSettings(); + } + + /// Get metadata about a set of mods from the web API. + /// The mod keys for which to fetch the latest version. + /// The SMAPI version installed by the player. If this is null, the API won't provide a recommended update. + /// The Stardew Valley version installed by the player. + /// The OS on which the player plays. + /// Whether to include extended metadata for each mod. + public async Task> GetModInfoAsync(ModSearchEntryModel[] mods, ISemanticVersion apiVersion, ISemanticVersion gameVersion, Platform platform, bool includeExtendedMetadata = false) + { + ModEntryModel[] result = await this.Client + .PostAsync( + $"v{this.Version}/mods", + new ModSearchModel(mods, apiVersion, gameVersion, platform, includeExtendedMetadata) + ) + .As(); + + return result.ToDictionary(p => p.ID); + } + + /// + public void Dispose() { - /********* - ** Fields - *********/ - /// The API version number. - private readonly ISemanticVersion Version; - - /// The underlying HTTP client. - private readonly IClient Client; - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The base URL for the web API. - /// The web API version. - public WebApiClient(string baseUrl, ISemanticVersion version) - { - this.Version = version; - this.Client = new FluentClient(baseUrl) - .SetUserAgent($"SMAPI/{version}"); - - this.Client.Formatters.JsonFormatter.SerializerSettings = JsonHelper.CreateDefaultSettings(); - } - - /// Get metadata about a set of mods from the web API. - /// The mod keys for which to fetch the latest version. - /// The SMAPI version installed by the player. If this is null, the API won't provide a recommended update. - /// The Stardew Valley version installed by the player. - /// The OS on which the player plays. - /// Whether to include extended metadata for each mod. - public async Task> GetModInfoAsync(ModSearchEntryModel[] mods, ISemanticVersion apiVersion, ISemanticVersion gameVersion, Platform platform, bool includeExtendedMetadata = false) - { - ModEntryModel[] result = await this.Client - .PostAsync( - $"v{this.Version}/mods", - new ModSearchModel(mods, apiVersion, gameVersion, platform, includeExtendedMetadata) - ) - .As(); - - return result.ToDictionary(p => p.ID); - } - - /// - public void Dispose() - { - this.Client.Dispose(); - } + this.Client.Dispose(); } } diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/ChangeDescriptor.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/ChangeDescriptor.cs index f4f62b4c1..5cfcb5ce2 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/Wiki/ChangeDescriptor.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/ChangeDescriptor.cs @@ -4,199 +4,198 @@ using System.Diagnostics.CodeAnalysis; using System.Linq; -namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki +namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki; + +/// A set of changes which can be applied to a mod data field. +public class ChangeDescriptor { - /// A set of changes which can be applied to a mod data field. - public class ChangeDescriptor + /********* + ** Accessors + *********/ + /// The values to add to the field. + public ISet Add { get; } + + /// The values to remove from the field. + public ISet Remove { get; } + + /// The values to replace in the field, if matched. + public IReadOnlyDictionary Replace { get; } + + /// Whether the change descriptor would make any changes. + public bool HasChanges { get; } + + /// Format a raw value into a normalized form. + public Func FormatValue { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The values to add to the field. + /// The values to remove from the field. + /// The values to replace in the field, if matched. + /// Format a raw value into a normalized form. + public ChangeDescriptor(ISet add, ISet remove, IReadOnlyDictionary replace, Func formatValue) { - /********* - ** Accessors - *********/ - /// The values to add to the field. - public ISet Add { get; } - - /// The values to remove from the field. - public ISet Remove { get; } - - /// The values to replace in the field, if matched. - public IReadOnlyDictionary Replace { get; } - - /// Whether the change descriptor would make any changes. - public bool HasChanges { get; } - - /// Format a raw value into a normalized form. - public Func FormatValue { get; } - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The values to add to the field. - /// The values to remove from the field. - /// The values to replace in the field, if matched. - /// Format a raw value into a normalized form. - public ChangeDescriptor(ISet add, ISet remove, IReadOnlyDictionary replace, Func formatValue) - { - this.Add = add; - this.Remove = remove; - this.Replace = replace; - this.HasChanges = add.Any() || remove.Any() || replace.Any(); - this.FormatValue = formatValue; - } + this.Add = add; + this.Remove = remove; + this.Replace = replace; + this.HasChanges = add.Any() || remove.Any() || replace.Any(); + this.FormatValue = formatValue; + } - /// Apply the change descriptors to a comma-delimited field. - /// The raw field text. - /// Returns the modified field. + /// Apply the change descriptors to a comma-delimited field. + /// The raw field text. + /// Returns the modified field. #if NET6_0_OR_GREATER - [return: NotNullIfNotNull("rawField")] + [return: NotNullIfNotNull("rawField")] #endif - public string? ApplyToCopy(string? rawField) - { - // get list - List values = !string.IsNullOrWhiteSpace(rawField) - ? new List( - from field in rawField.Split(',') - let value = field.Trim() - where value.Length > 0 - select value - ) - : new List(); - - // apply changes - this.Apply(values); - - // format - if (rawField == null && !values.Any()) - return null; - return string.Join(", ", values); - } + public string? ApplyToCopy(string? rawField) + { + // get list + List values = !string.IsNullOrWhiteSpace(rawField) + ? new List( + from field in rawField.Split(',') + let value = field.Trim() + where value.Length > 0 + select value + ) + : new List(); + + // apply changes + this.Apply(values); + + // format + if (rawField == null && !values.Any()) + return null; + return string.Join(", ", values); + } - /// Apply the change descriptors to the given field values. - /// The field values. - /// Returns the modified field values. - public void Apply(List values) + /// Apply the change descriptors to the given field values. + /// The field values. + /// Returns the modified field values. + public void Apply(List values) + { + // replace/remove values + if (this.Replace.Any() || this.Remove.Any()) { - // replace/remove values - if (this.Replace.Any() || this.Remove.Any()) + for (int i = values.Count - 1; i >= 0; i--) { - for (int i = values.Count - 1; i >= 0; i--) - { - string value = this.FormatValue(values[i].Trim()); + string value = this.FormatValue(values[i].Trim()); - if (this.Remove.Contains(value)) - values.RemoveAt(i); + if (this.Remove.Contains(value)) + values.RemoveAt(i); - else if (this.Replace.TryGetValue(value, out string? newValue)) - values[i] = newValue; - } + else if (this.Replace.TryGetValue(value, out string? newValue)) + values[i] = newValue; } + } - // add values - if (this.Add.Any()) + // add values + if (this.Add.Any()) + { + HashSet curValues = new HashSet(values.Select(p => p.Trim()), StringComparer.OrdinalIgnoreCase); + foreach (string add in this.Add) { - HashSet curValues = new HashSet(values.Select(p => p.Trim()), StringComparer.OrdinalIgnoreCase); - foreach (string add in this.Add) + if (!curValues.Contains(add)) { - if (!curValues.Contains(add)) - { - values.Add(add); - curValues.Add(add); - } + values.Add(add); + curValues.Add(add); } } } + } - /// - public override string ToString() - { - if (!this.HasChanges) - return string.Empty; - - List descriptors = new List(this.Add.Count + this.Remove.Count + this.Replace.Count); - foreach (string add in this.Add) - descriptors.Add($"+{add}"); - foreach (string remove in this.Remove) - descriptors.Add($"-{remove}"); - foreach (var pair in this.Replace) - descriptors.Add($"{pair.Key} → {pair.Value}"); - - return string.Join(", ", descriptors); - } + /// + public override string ToString() + { + if (!this.HasChanges) + return string.Empty; + + List descriptors = new List(this.Add.Count + this.Remove.Count + this.Replace.Count); + foreach (string add in this.Add) + descriptors.Add($"+{add}"); + foreach (string remove in this.Remove) + descriptors.Add($"-{remove}"); + foreach (var pair in this.Replace) + descriptors.Add($"{pair.Key} → {pair.Value}"); + + return string.Join(", ", descriptors); + } - /// Parse a raw change descriptor string into a model. - /// The raw change descriptor. - /// The human-readable error message describing any invalid values that were ignored. - /// Format a raw value into a normalized form if needed. - public static ChangeDescriptor Parse(string? descriptor, out string[] errors, Func? formatValue = null) + /// Parse a raw change descriptor string into a model. + /// The raw change descriptor. + /// The human-readable error message describing any invalid values that were ignored. + /// Format a raw value into a normalized form if needed. + public static ChangeDescriptor Parse(string? descriptor, out string[] errors, Func? formatValue = null) + { + // init + formatValue ??= p => p; + var add = new HashSet(StringComparer.OrdinalIgnoreCase); + var remove = new HashSet(StringComparer.OrdinalIgnoreCase); + var replace = new Dictionary(StringComparer.OrdinalIgnoreCase); + + // parse each change in the descriptor + if (!string.IsNullOrWhiteSpace(descriptor)) { - // init - formatValue ??= p => p; - var add = new HashSet(StringComparer.OrdinalIgnoreCase); - var remove = new HashSet(StringComparer.OrdinalIgnoreCase); - var replace = new Dictionary(StringComparer.OrdinalIgnoreCase); - - // parse each change in the descriptor - if (!string.IsNullOrWhiteSpace(descriptor)) + List rawErrors = new List(); + foreach (string rawEntry in descriptor.Split(',')) { - List rawErrors = new List(); - foreach (string rawEntry in descriptor.Split(',')) + // normalize entry + string entry = rawEntry.Trim(); + if (entry == string.Empty) + continue; + + // parse as replace (old value → new value) + if (entry.Contains('→')) { - // normalize entry - string entry = rawEntry.Trim(); - if (entry == string.Empty) - continue; + string[] parts = entry.Split(new[] { '→' }, 2); + string oldValue = formatValue(parts[0].Trim()); + string newValue = formatValue(parts[1].Trim()); - // parse as replace (old value → new value) - if (entry.Contains('→')) + if (oldValue == string.Empty) { - string[] parts = entry.Split(new[] { '→' }, 2); - string oldValue = formatValue(parts[0].Trim()); - string newValue = formatValue(parts[1].Trim()); - - if (oldValue == string.Empty) - { - rawErrors.Add($"Failed parsing '{rawEntry}': can't map from a blank old value. Use the '+value' format to add a value."); - continue; - } - - if (newValue == string.Empty) - { - rawErrors.Add($"Failed parsing '{rawEntry}': can't map to a blank value. Use the '-value' format to remove a value."); - continue; - } - - replace[oldValue] = newValue; + rawErrors.Add($"Failed parsing '{rawEntry}': can't map from a blank old value. Use the '+value' format to add a value."); + continue; } - // else as remove - else if (entry.StartsWith("-")) + if (newValue == string.Empty) { - entry = formatValue(entry.Substring(1).Trim()); - remove.Add(entry); + rawErrors.Add($"Failed parsing '{rawEntry}': can't map to a blank value. Use the '-value' format to remove a value."); + continue; } - // else as add - else - { - if (entry.StartsWith("+")) - entry = formatValue(entry.Substring(1).Trim()); - add.Add(entry); - } + replace[oldValue] = newValue; + } + + // else as remove + else if (entry.StartsWith("-")) + { + entry = formatValue(entry.Substring(1).Trim()); + remove.Add(entry); } - errors = rawErrors.ToArray(); + // else as add + else + { + if (entry.StartsWith("+")) + entry = formatValue(entry.Substring(1).Trim()); + add.Add(entry); + } } - else - errors = Array.Empty(); - - // build model - return new ChangeDescriptor( - add: add, - remove: remove, - replace: new ReadOnlyDictionary(replace), - formatValue: formatValue - ); + + errors = rawErrors.ToArray(); } + else + errors = Array.Empty(); + + // build model + return new ChangeDescriptor( + add: add, + remove: remove, + replace: new ReadOnlyDictionary(replace), + formatValue: formatValue + ); } } diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs index 3bdd145a2..8c6681ebb 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs @@ -8,314 +8,313 @@ using Pathoschild.Http.Client; using StardewModdingAPI.Toolkit.Framework.UpdateData; -namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki +namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki; + +/// An HTTP client for fetching mod metadata from the wiki. +public class WikiClient : IDisposable { - /// An HTTP client for fetching mod metadata from the wiki. - public class WikiClient : IDisposable + /********* + ** Fields + *********/ + /// The underlying HTTP client. + private readonly IClient Client; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The user agent for the wiki API. + /// The base URL for the wiki API. + public WikiClient(string userAgent, string baseUrl = "https://stardewvalleywiki.com/mediawiki/api.php") { - /********* - ** Fields - *********/ - /// The underlying HTTP client. - private readonly IClient Client; - + this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent); + } - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The user agent for the wiki API. - /// The base URL for the wiki API. - public WikiClient(string userAgent, string baseUrl = "https://stardewvalleywiki.com/mediawiki/api.php") + /// Fetch mods from the compatibility list. + public async Task FetchModsAsync() + { + // fetch HTML + ResponseModel response = await this.Client + .GetAsync("") + .WithArguments(new + { + action = "parse", + page = "Modding:Mod_compatibility", + format = "json" + }) + .As(); + string html = response.Parse.Text["*"]; + + // parse HTML + var doc = new HtmlDocument(); + doc.LoadHtml(html); + + // fetch game versions + string? stableVersion = doc.DocumentNode.SelectSingleNode("//div[@class='game-stable-version']")?.InnerText; + string? betaVersion = doc.DocumentNode.SelectSingleNode("//div[@class='game-beta-version']")?.InnerText; + if (betaVersion == stableVersion) + betaVersion = null; + + // parse mod data overrides + Dictionary overrides = new Dictionary(StringComparer.OrdinalIgnoreCase); { - this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent); - } + HtmlNodeCollection modNodes = doc.DocumentNode.SelectNodes("//table[@id='mod-overrides-list']//tr[@class='mod']"); + if (modNodes == null) + throw new InvalidOperationException("Can't parse wiki compatibility list, no mod data overrides section found."); - /// Fetch mods from the compatibility list. - public async Task FetchModsAsync() - { - // fetch HTML - ResponseModel response = await this.Client - .GetAsync("") - .WithArguments(new - { - action = "parse", - page = "Modding:Mod_compatibility", - format = "json" - }) - .As(); - string html = response.Parse.Text["*"]; - - // parse HTML - var doc = new HtmlDocument(); - doc.LoadHtml(html); - - // fetch game versions - string? stableVersion = doc.DocumentNode.SelectSingleNode("//div[@class='game-stable-version']")?.InnerText; - string? betaVersion = doc.DocumentNode.SelectSingleNode("//div[@class='game-beta-version']")?.InnerText; - if (betaVersion == stableVersion) - betaVersion = null; - - // parse mod data overrides - Dictionary overrides = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (WikiDataOverrideEntry entry in this.ParseOverrideEntries(modNodes)) { - HtmlNodeCollection modNodes = doc.DocumentNode.SelectNodes("//table[@id='mod-overrides-list']//tr[@class='mod']"); - if (modNodes == null) - throw new InvalidOperationException("Can't parse wiki compatibility list, no mod data overrides section found."); + if (entry.Ids.Any() != true || !entry.HasChanges) + continue; - foreach (WikiDataOverrideEntry entry in this.ParseOverrideEntries(modNodes)) - { - if (entry.Ids.Any() != true || !entry.HasChanges) - continue; - - foreach (string id in entry.Ids) - overrides[id] = entry; - } + foreach (string id in entry.Ids) + overrides[id] = entry; } - - // parse mod entries - WikiModEntry[] mods; - { - HtmlNodeCollection modNodes = doc.DocumentNode.SelectNodes("//table[@id='mod-list']//tr[@class='mod']"); - if (modNodes == null) - throw new InvalidOperationException("Can't parse wiki compatibility list, no mods found."); - mods = this.ParseModEntries(modNodes, overrides).ToArray(); - } - - // build model - return new WikiModList( - stableVersion: stableVersion, - betaVersion: betaVersion, - mods: mods - ); } - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. - public void Dispose() + // parse mod entries + WikiModEntry[] mods; { - this.Client.Dispose(); + HtmlNodeCollection modNodes = doc.DocumentNode.SelectNodes("//table[@id='mod-list']//tr[@class='mod']"); + if (modNodes == null) + throw new InvalidOperationException("Can't parse wiki compatibility list, no mods found."); + mods = this.ParseModEntries(modNodes, overrides).ToArray(); } + // build model + return new WikiModList( + stableVersion: stableVersion, + betaVersion: betaVersion, + mods: mods + ); + } - /********* - ** Private methods - *********/ - /// Parse valid mod compatibility entries. - /// The HTML compatibility entries. - /// The mod data overrides to apply, if any. - private IEnumerable ParseModEntries(IEnumerable nodes, IDictionary overridesById) + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + public void Dispose() + { + this.Client.Dispose(); + } + + + /********* + ** Private methods + *********/ + /// Parse valid mod compatibility entries. + /// The HTML compatibility entries. + /// The mod data overrides to apply, if any. + private IEnumerable ParseModEntries(IEnumerable nodes, IDictionary overridesById) + { + foreach (HtmlNode node in nodes) { - foreach (HtmlNode node in nodes) + // extract fields + string[] names = this.GetAttributeAsCsv(node, "data-name"); + string[] authors = this.GetAttributeAsCsv(node, "data-author"); + string[] ids = this.GetAttributeAsCsv(node, "data-id"); + string[] warnings = this.GetAttributeAsCsv(node, "data-warnings"); + int? nexusID = this.GetAttributeAsNullableInt(node, "data-nexus-id"); + int? chucklefishID = this.GetAttributeAsNullableInt(node, "data-cf-id"); + int? curseForgeID = this.GetAttributeAsNullableInt(node, "data-curseforge-id"); + string? curseForgeKey = this.GetAttribute(node, "data-curseforge-key"); + int? modDropID = this.GetAttributeAsNullableInt(node, "data-moddrop-id"); + string? githubRepo = this.GetAttribute(node, "data-github"); + string? customSourceUrl = this.GetAttribute(node, "data-custom-source"); + string? customUrl = this.GetAttribute(node, "data-url"); + string? anchor = this.GetAttribute(node, "id"); + string? contentPackFor = this.GetAttribute(node, "data-content-pack-for"); + string? devNote = this.GetAttribute(node, "data-dev-note"); + string? pullRequestUrl = this.GetAttribute(node, "data-pr"); + + // parse stable compatibility + WikiCompatibilityInfo compatibility = new( + status: this.GetAttributeAsEnum(node, "data-status") ?? WikiCompatibilityStatus.Ok, + brokeIn: this.GetAttribute(node, "data-broke-in"), + unofficialVersion: this.GetAttributeAsSemanticVersion(node, "data-unofficial-version"), + unofficialUrl: this.GetAttribute(node, "data-unofficial-url"), + summary: this.GetInnerHtml(node, "mod-summary")?.Trim() + ); + + // parse beta compatibility + WikiCompatibilityInfo? betaCompatibility = null; { - // extract fields - string[] names = this.GetAttributeAsCsv(node, "data-name"); - string[] authors = this.GetAttributeAsCsv(node, "data-author"); - string[] ids = this.GetAttributeAsCsv(node, "data-id"); - string[] warnings = this.GetAttributeAsCsv(node, "data-warnings"); - int? nexusID = this.GetAttributeAsNullableInt(node, "data-nexus-id"); - int? chucklefishID = this.GetAttributeAsNullableInt(node, "data-cf-id"); - int? curseForgeID = this.GetAttributeAsNullableInt(node, "data-curseforge-id"); - string? curseForgeKey = this.GetAttribute(node, "data-curseforge-key"); - int? modDropID = this.GetAttributeAsNullableInt(node, "data-moddrop-id"); - string? githubRepo = this.GetAttribute(node, "data-github"); - string? customSourceUrl = this.GetAttribute(node, "data-custom-source"); - string? customUrl = this.GetAttribute(node, "data-url"); - string? anchor = this.GetAttribute(node, "id"); - string? contentPackFor = this.GetAttribute(node, "data-content-pack-for"); - string? devNote = this.GetAttribute(node, "data-dev-note"); - string? pullRequestUrl = this.GetAttribute(node, "data-pr"); - - // parse stable compatibility - WikiCompatibilityInfo compatibility = new( - status: this.GetAttributeAsEnum(node, "data-status") ?? WikiCompatibilityStatus.Ok, - brokeIn: this.GetAttribute(node, "data-broke-in"), - unofficialVersion: this.GetAttributeAsSemanticVersion(node, "data-unofficial-version"), - unofficialUrl: this.GetAttribute(node, "data-unofficial-url"), - summary: this.GetInnerHtml(node, "mod-summary")?.Trim() - ); - - // parse beta compatibility - WikiCompatibilityInfo? betaCompatibility = null; + WikiCompatibilityStatus? betaStatus = this.GetAttributeAsEnum(node, "data-beta-status"); + if (betaStatus.HasValue) { - WikiCompatibilityStatus? betaStatus = this.GetAttributeAsEnum(node, "data-beta-status"); - if (betaStatus.HasValue) - { - betaCompatibility = new WikiCompatibilityInfo( - status: betaStatus.Value, - brokeIn: this.GetAttribute(node, "data-beta-broke-in"), - unofficialVersion: this.GetAttributeAsSemanticVersion(node, "data-beta-unofficial-version"), - unofficialUrl: this.GetAttribute(node, "data-beta-unofficial-url"), - summary: this.GetInnerHtml(node, "mod-beta-summary") - ); - } + betaCompatibility = new WikiCompatibilityInfo( + status: betaStatus.Value, + brokeIn: this.GetAttribute(node, "data-beta-broke-in"), + unofficialVersion: this.GetAttributeAsSemanticVersion(node, "data-beta-unofficial-version"), + unofficialUrl: this.GetAttribute(node, "data-beta-unofficial-url"), + summary: this.GetInnerHtml(node, "mod-beta-summary") + ); } - - // find data overrides - WikiDataOverrideEntry? overrides = ids - .Select(id => overridesById.TryGetValue(id, out overrides) ? overrides : null) - .FirstOrDefault(p => p != null); - - // yield model - yield return new WikiModEntry( - id: ids, - name: names, - author: authors, - nexusId: nexusID, - chucklefishId: chucklefishID, - curseForgeId: curseForgeID, - curseForgeKey: curseForgeKey, - modDropId: modDropID, - githubRepo: githubRepo, - customSourceUrl: customSourceUrl, - customUrl: customUrl, - contentPackFor: contentPackFor, - compatibility: compatibility, - betaCompatibility: betaCompatibility, - warnings: warnings, - pullRequestUrl: pullRequestUrl, - devNote: devNote, - overrides: overrides, - anchor: anchor - ); } + + // find data overrides + WikiDataOverrideEntry? overrides = ids + .Select(id => overridesById.TryGetValue(id, out overrides) ? overrides : null) + .FirstOrDefault(p => p != null); + + // yield model + yield return new WikiModEntry( + id: ids, + name: names, + author: authors, + nexusId: nexusID, + chucklefishId: chucklefishID, + curseForgeId: curseForgeID, + curseForgeKey: curseForgeKey, + modDropId: modDropID, + githubRepo: githubRepo, + customSourceUrl: customSourceUrl, + customUrl: customUrl, + contentPackFor: contentPackFor, + compatibility: compatibility, + betaCompatibility: betaCompatibility, + warnings: warnings, + pullRequestUrl: pullRequestUrl, + devNote: devNote, + overrides: overrides, + anchor: anchor + ); } + } - /// Parse valid mod data override entries. - /// The HTML mod data override entries. - private IEnumerable ParseOverrideEntries(IEnumerable nodes) + /// Parse valid mod data override entries. + /// The HTML mod data override entries. + private IEnumerable ParseOverrideEntries(IEnumerable nodes) + { + foreach (HtmlNode node in nodes) { - foreach (HtmlNode node in nodes) + yield return new WikiDataOverrideEntry { - yield return new WikiDataOverrideEntry - { - Ids = this.GetAttributeAsCsv(node, "data-id"), - ChangeLocalVersions = this.GetAttributeAsChangeDescriptor(node, "data-local-version", - raw => SemanticVersion.TryParse(raw, out ISemanticVersion? version) ? version.ToString() : raw - ), - ChangeRemoteVersions = this.GetAttributeAsChangeDescriptor(node, "data-remote-version", - raw => SemanticVersion.TryParse(raw, out ISemanticVersion? version) ? version.ToString() : raw - ), - - ChangeUpdateKeys = this.GetAttributeAsChangeDescriptor(node, "data-update-keys", - raw => UpdateKey.TryParse(raw, out UpdateKey key) ? key.ToString() : raw - ) - }; - } + Ids = this.GetAttributeAsCsv(node, "data-id"), + ChangeLocalVersions = this.GetAttributeAsChangeDescriptor(node, "data-local-version", + raw => SemanticVersion.TryParse(raw, out ISemanticVersion? version) ? version.ToString() : raw + ), + ChangeRemoteVersions = this.GetAttributeAsChangeDescriptor(node, "data-remote-version", + raw => SemanticVersion.TryParse(raw, out ISemanticVersion? version) ? version.ToString() : raw + ), + + ChangeUpdateKeys = this.GetAttributeAsChangeDescriptor(node, "data-update-keys", + raw => UpdateKey.TryParse(raw, out UpdateKey key) ? key.ToString() : raw + ) + }; } + } - /// Get an attribute value. - /// The element whose attributes to read. - /// The attribute name. - private string? GetAttribute(HtmlNode element, string name) - { - string value = element.GetAttributeValue(name, null); - if (string.IsNullOrWhiteSpace(value)) - return null; + /// Get an attribute value. + /// The element whose attributes to read. + /// The attribute name. + private string? GetAttribute(HtmlNode element, string name) + { + string value = element.GetAttributeValue(name, null); + if (string.IsNullOrWhiteSpace(value)) + return null; - return WebUtility.HtmlDecode(value); - } + return WebUtility.HtmlDecode(value); + } - /// Get an attribute value and parse it as a change descriptor. - /// The element whose attributes to read. - /// The attribute name. - /// Format an raw entry value when applying changes. - private ChangeDescriptor? GetAttributeAsChangeDescriptor(HtmlNode element, string name, Func formatValue) - { - string? raw = this.GetAttribute(element, name); - return raw != null - ? ChangeDescriptor.Parse(raw, out _, formatValue) - : null; - } + /// Get an attribute value and parse it as a change descriptor. + /// The element whose attributes to read. + /// The attribute name. + /// Format an raw entry value when applying changes. + private ChangeDescriptor? GetAttributeAsChangeDescriptor(HtmlNode element, string name, Func formatValue) + { + string? raw = this.GetAttribute(element, name); + return raw != null + ? ChangeDescriptor.Parse(raw, out _, formatValue) + : null; + } - /// Get an attribute value and parse it as a comma-delimited list of strings. - /// The element whose attributes to read. - /// The attribute name. - private string[] GetAttributeAsCsv(HtmlNode element, string name) - { - string? raw = this.GetAttribute(element, name); - return !string.IsNullOrWhiteSpace(raw) - ? raw.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).Select(p => p.Trim()).ToArray() - : Array.Empty(); - } + /// Get an attribute value and parse it as a comma-delimited list of strings. + /// The element whose attributes to read. + /// The attribute name. + private string[] GetAttributeAsCsv(HtmlNode element, string name) + { + string? raw = this.GetAttribute(element, name); + return !string.IsNullOrWhiteSpace(raw) + ? raw.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).Select(p => p.Trim()).ToArray() + : Array.Empty(); + } - /// Get an attribute value and parse it as an enum value. - /// The enum type. - /// The element whose attributes to read. - /// The attribute name. - private TEnum? GetAttributeAsEnum(HtmlNode element, string name) where TEnum : struct - { - string? raw = this.GetAttribute(element, name); - if (raw == null) - return null; - if (!Enum.TryParse(raw, true, out TEnum value) && Enum.IsDefined(typeof(TEnum), value)) - throw new InvalidOperationException($"Unknown {typeof(TEnum).Name} value '{raw}' when parsing compatibility list."); + /// Get an attribute value and parse it as an enum value. + /// The enum type. + /// The element whose attributes to read. + /// The attribute name. + private TEnum? GetAttributeAsEnum(HtmlNode element, string name) where TEnum : struct + { + string? raw = this.GetAttribute(element, name); + if (raw == null) + return null; + if (!Enum.TryParse(raw, true, out TEnum value) && Enum.IsDefined(typeof(TEnum), value)) + throw new InvalidOperationException($"Unknown {typeof(TEnum).Name} value '{raw}' when parsing compatibility list."); + return value; + } + + /// Get an attribute value and parse it as a semantic version. + /// The element whose attributes to read. + /// The attribute name. + private ISemanticVersion? GetAttributeAsSemanticVersion(HtmlNode element, string name) + { + string? raw = this.GetAttribute(element, name); + return SemanticVersion.TryParse(raw, out ISemanticVersion? version) + ? version + : null; + } + + /// Get an attribute value and parse it as a nullable int. + /// The element whose attributes to read. + /// The attribute name. + private int? GetAttributeAsNullableInt(HtmlNode element, string name) + { + string? raw = this.GetAttribute(element, name); + if (raw != null && int.TryParse(raw, out int value)) return value; - } + return null; + } - /// Get an attribute value and parse it as a semantic version. - /// The element whose attributes to read. - /// The attribute name. - private ISemanticVersion? GetAttributeAsSemanticVersion(HtmlNode element, string name) - { - string? raw = this.GetAttribute(element, name); - return SemanticVersion.TryParse(raw, out ISemanticVersion? version) - ? version - : null; - } + /// Get the text of an element with the given class name. + /// The metadata container. + /// The field name. + private string? GetInnerHtml(HtmlNode container, string className) + { + return container.Descendants().FirstOrDefault(p => p.HasClass(className))?.InnerHtml; + } - /// Get an attribute value and parse it as a nullable int. - /// The element whose attributes to read. - /// The attribute name. - private int? GetAttributeAsNullableInt(HtmlNode element, string name) - { - string? raw = this.GetAttribute(element, name); - if (raw != null && int.TryParse(raw, out int value)) - return value; - return null; - } + /// The response model for the MediaWiki parse API. + [SuppressMessage("ReSharper", "ClassNeverInstantiated.Local", Justification = "Used via JSON deserialization.")] + [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Local", Justification = "Used via JSON deserialization.")] + private class ResponseModel + { + /********* + ** Accessors + *********/ + /// The parse API results. + public ResponseParseModel Parse { get; } - /// Get the text of an element with the given class name. - /// The metadata container. - /// The field name. - private string? GetInnerHtml(HtmlNode container, string className) - { - return container.Descendants().FirstOrDefault(p => p.HasClass(className))?.InnerHtml; - } - /// The response model for the MediaWiki parse API. - [SuppressMessage("ReSharper", "ClassNeverInstantiated.Local", Justification = "Used via JSON deserialization.")] - [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Local", Justification = "Used via JSON deserialization.")] - private class ResponseModel + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The parse API results. + public ResponseModel(ResponseParseModel parse) { - /********* - ** Accessors - *********/ - /// The parse API results. - public ResponseParseModel Parse { get; } - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The parse API results. - public ResponseModel(ResponseParseModel parse) - { - this.Parse = parse; - } + this.Parse = parse; } + } - /// The inner response model for the MediaWiki parse API. - [SuppressMessage("ReSharper", "ClassNeverInstantiated.Local", Justification = "Used via JSON deserialization.")] - [SuppressMessage("ReSharper", "CollectionNeverUpdated.Local", Justification = "Used via JSON deserialization.")] - [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Local", Justification = "Used via JSON deserialization.")] - private class ResponseParseModel - { - /********* - ** Accessors - *********/ - /// The parsed text. - public IDictionary Text { get; } = new Dictionary(); - } + /// The inner response model for the MediaWiki parse API. + [SuppressMessage("ReSharper", "ClassNeverInstantiated.Local", Justification = "Used via JSON deserialization.")] + [SuppressMessage("ReSharper", "CollectionNeverUpdated.Local", Justification = "Used via JSON deserialization.")] + [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Local", Justification = "Used via JSON deserialization.")] + private class ResponseParseModel + { + /********* + ** Accessors + *********/ + /// The parsed text. + public IDictionary Text { get; } = new Dictionary(); } } diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityInfo.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityInfo.cs index 71c90d0c7..5a2bd4436 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityInfo.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityInfo.cs @@ -1,43 +1,42 @@ -namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki +namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki; + +/// Compatibility info for a mod. +public class WikiCompatibilityInfo { - /// Compatibility info for a mod. - public class WikiCompatibilityInfo - { - /********* - ** Accessors - *********/ - /// The compatibility status. - public WikiCompatibilityStatus Status { get; } + /********* + ** Accessors + *********/ + /// The compatibility status. + public WikiCompatibilityStatus Status { get; } - /// The human-readable summary of the compatibility status or workaround, without HTML formatting. - public string? Summary { get; } + /// The human-readable summary of the compatibility status or workaround, without HTML formatting. + public string? Summary { get; } - /// The game or SMAPI version which broke this mod, if applicable. - public string? BrokeIn { get; } + /// The game or SMAPI version which broke this mod, if applicable. + public string? BrokeIn { get; } - /// The version of the latest unofficial update, if applicable. - public ISemanticVersion? UnofficialVersion { get; } + /// The version of the latest unofficial update, if applicable. + public ISemanticVersion? UnofficialVersion { get; } - /// The URL to the latest unofficial update, if applicable. - public string? UnofficialUrl { get; } + /// The URL to the latest unofficial update, if applicable. + public string? UnofficialUrl { get; } - /********* - ** Accessors - *********/ - /// Construct an instance. - /// The compatibility status. - /// The human-readable summary of the compatibility status or workaround, without HTML formatting. - /// The game or SMAPI version which broke this mod, if applicable. - /// The version of the latest unofficial update, if applicable. - /// The URL to the latest unofficial update, if applicable. - public WikiCompatibilityInfo(WikiCompatibilityStatus status, string? summary, string? brokeIn, ISemanticVersion? unofficialVersion, string? unofficialUrl) - { - this.Status = status; - this.Summary = summary; - this.BrokeIn = brokeIn; - this.UnofficialVersion = unofficialVersion; - this.UnofficialUrl = unofficialUrl; - } + /********* + ** Accessors + *********/ + /// Construct an instance. + /// The compatibility status. + /// The human-readable summary of the compatibility status or workaround, without HTML formatting. + /// The game or SMAPI version which broke this mod, if applicable. + /// The version of the latest unofficial update, if applicable. + /// The URL to the latest unofficial update, if applicable. + public WikiCompatibilityInfo(WikiCompatibilityStatus status, string? summary, string? brokeIn, ISemanticVersion? unofficialVersion, string? unofficialUrl) + { + this.Status = status; + this.Summary = summary; + this.BrokeIn = brokeIn; + this.UnofficialVersion = unofficialVersion; + this.UnofficialUrl = unofficialUrl; } } diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityStatus.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityStatus.cs index 5cdf489f5..e30ee3ac7 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityStatus.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityStatus.cs @@ -1,30 +1,29 @@ -namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki +namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki; + +/// The compatibility status for a mod. +public enum WikiCompatibilityStatus { - /// The compatibility status for a mod. - public enum WikiCompatibilityStatus - { - /// The status is unknown. - Unknown, + /// The status is unknown. + Unknown, - /// The mod is compatible. - Ok, + /// The mod is compatible. + Ok, - /// The mod is compatible if you use an optional official download. - Optional, + /// The mod is compatible if you use an optional official download. + Optional, - /// The mod is compatible if you use an unofficial update. - Unofficial, + /// The mod is compatible if you use an unofficial update. + Unofficial, - /// The mod isn't compatible, but the player can fix it or there's a good alternative. - Workaround, + /// The mod isn't compatible, but the player can fix it or there's a good alternative. + Workaround, - /// The mod isn't compatible. - Broken, + /// The mod isn't compatible. + Broken, - /// The mod is no longer maintained by the author, and an unofficial update or continuation is unlikely. - Abandoned, + /// The mod is no longer maintained by the author, and an unofficial update or continuation is unlikely. + Abandoned, - /// The mod is no longer needed and should be removed. - Obsolete - } + /// The mod is no longer needed and should be removed. + Obsolete } diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiDataOverrideEntry.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiDataOverrideEntry.cs index a6f5a88f8..e56b4bcbe 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiDataOverrideEntry.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiDataOverrideEntry.cs @@ -1,29 +1,28 @@ using System; -namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki +namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki; + +/// The data overrides to apply to matching mods. +public class WikiDataOverrideEntry { - /// The data overrides to apply to matching mods. - public class WikiDataOverrideEntry - { - /********* - ** Accessors - *********/ - /// The unique mod IDs for the mods to override. - public string[] Ids { get; set; } = Array.Empty(); + /********* + ** Accessors + *********/ + /// The unique mod IDs for the mods to override. + public string[] Ids { get; set; } = Array.Empty(); - /// Maps local versions to a semantic version for update checks. - public ChangeDescriptor? ChangeLocalVersions { get; set; } + /// Maps local versions to a semantic version for update checks. + public ChangeDescriptor? ChangeLocalVersions { get; set; } - /// Maps remote versions to a semantic version for update checks. - public ChangeDescriptor? ChangeRemoteVersions { get; set; } + /// Maps remote versions to a semantic version for update checks. + public ChangeDescriptor? ChangeRemoteVersions { get; set; } - /// Update keys to add (optionally prefixed by '+'), remove (prefixed by '-'), or replace. - public ChangeDescriptor? ChangeUpdateKeys { get; set; } + /// Update keys to add (optionally prefixed by '+'), remove (prefixed by '-'), or replace. + public ChangeDescriptor? ChangeUpdateKeys { get; set; } - /// Whether the entry has any changes. - public bool HasChanges => - this.ChangeLocalVersions?.HasChanges == true - || this.ChangeRemoteVersions?.HasChanges == true - || this.ChangeUpdateKeys?.HasChanges == true; - } + /// Whether the entry has any changes. + public bool HasChanges => + this.ChangeLocalVersions?.HasChanges == true + || this.ChangeRemoteVersions?.HasChanges == true + || this.ChangeUpdateKeys?.HasChanges == true; } diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs index 586f4b671..291e31085 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs @@ -1,121 +1,161 @@ +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using StardewModdingAPI.Toolkit.Framework.UpdateData; -namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki +namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki; + +/// A mod entry in the wiki list. +public class WikiModEntry { - /// A mod entry in the wiki list. - public class WikiModEntry - { - /********* - ** Accessors - *********/ - /// The mod's unique ID. If the mod has alternate/old IDs, they're listed in latest to oldest order. - public string[] ID { get; } + /********* + ** Accessors + *********/ + /// The mod's unique ID. If the mod has alternate/old IDs, they're listed in latest to oldest order. + public string[] ID { get; } - /// The mod's display name. If the mod has multiple names, the first one is the most canonical name. - public string[] Name { get; } + /// The mod's display name. If the mod has multiple names, the first one is the most canonical name. + public string[] Name { get; } - /// The mod's author name. If the author has multiple names, the first one is the most canonical name. - public string[] Author { get; } + /// The mod's author name. If the author has multiple names, the first one is the most canonical name. + public string[] Author { get; } - /// The mod ID on Nexus. - public int? NexusID { get; } + /// The mod ID on Nexus. + public int? NexusID { get; } - /// The mod ID in the Chucklefish mod repo. - public int? ChucklefishID { get; } + /// The mod ID in the Chucklefish mod repo. + public int? ChucklefishID { get; } - /// The mod ID in the CurseForge mod repo. - public int? CurseForgeID { get; } + /// The mod ID in the CurseForge mod repo. + public int? CurseForgeID { get; } - /// The mod key in the CurseForge mod repo (used in mod page URLs). - public string? CurseForgeKey { get; } + /// The mod key in the CurseForge mod repo (used in mod page URLs). + public string? CurseForgeKey { get; } - /// The mod ID in the ModDrop mod repo. - public int? ModDropID { get; } + /// The mod ID in the ModDrop mod repo. + public int? ModDropID { get; } - /// The GitHub repository in the form 'owner/repo'. - public string? GitHubRepo { get; } + /// The GitHub repository in the form 'owner/repo'. + public string? GitHubRepo { get; } - /// The URL to a non-GitHub source repo. - public string? CustomSourceUrl { get; } + /// The URL to a non-GitHub source repo. + public string? CustomSourceUrl { get; } - /// The custom mod page URL (if applicable). - public string? CustomUrl { get; } + /// The custom mod page URL (if applicable). + public string? CustomUrl { get; } - /// The name of the mod which loads this content pack, if applicable. - public string? ContentPackFor { get; } + /// The name of the mod which loads this content pack, if applicable. + public string? ContentPackFor { get; } - /// The mod's compatibility with the latest stable version of the game. - public WikiCompatibilityInfo Compatibility { get; } + /// The mod's compatibility with the latest stable version of the game. + public WikiCompatibilityInfo Compatibility { get; } - /// The mod's compatibility with the latest beta version of the game (if any). - public WikiCompatibilityInfo? BetaCompatibility { get; } + /// The mod's compatibility with the latest beta version of the game (if any). + public WikiCompatibilityInfo? BetaCompatibility { get; } - /// Whether a Stardew Valley or SMAPI beta which affects mod compatibility is in progress. If this is true, should be used for beta versions of SMAPI instead of . + /// Whether a Stardew Valley or SMAPI beta which affects mod compatibility is in progress. If this is true, should be used for beta versions of SMAPI instead of . #if NET6_0_OR_GREATER - [MemberNotNullWhen(true, nameof(WikiModEntry.BetaCompatibility))] + [MemberNotNullWhen(true, nameof(WikiModEntry.BetaCompatibility))] #endif - public bool HasBetaInfo => this.BetaCompatibility != null; - - /// The human-readable warnings for players about this mod. - public string[] Warnings { get; } - - /// The URL of the pull request which submits changes for an unofficial update to the author, if any. - public string? PullRequestUrl { get; } - - /// Special notes intended for developers who maintain unofficial updates or submit pull requests. - public string? DevNote { get; } - - /// The data overrides to apply to the mod's manifest or remote mod page data, if any. - public WikiDataOverrideEntry? Overrides { get; } - - /// The link anchor for the mod entry in the wiki compatibility list. - public string? Anchor { get; } - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The mod's unique ID. If the mod has alternate/old IDs, they're listed in latest to oldest order. - /// The mod's display name. If the mod has multiple names, the first one is the most canonical name. - /// The mod's author name. If the author has multiple names, the first one is the most canonical name. - /// The mod ID on Nexus. - /// The mod ID in the Chucklefish mod repo. - /// The mod ID in the CurseForge mod repo. - /// The mod ID in the CurseForge mod repo. - /// The mod ID in the ModDrop mod repo. - /// The GitHub repository in the form 'owner/repo'. - /// The URL to a non-GitHub source repo. - /// The custom mod page URL (if applicable). - /// The name of the mod which loads this content pack, if applicable. - /// The mod's compatibility with the latest stable version of the game. - /// The mod's compatibility with the latest beta version of the game (if any). - /// The human-readable warnings for players about this mod. - /// The URL of the pull request which submits changes for an unofficial update to the author, if any. - /// Special notes intended for developers who maintain unofficial updates or submit pull requests. - /// The data overrides to apply to the mod's manifest or remote mod page data, if any. - /// The link anchor for the mod entry in the wiki compatibility list. - public WikiModEntry(string[] id, string[] name, string[] author, int? nexusId, int? chucklefishId, int? curseForgeId, string? curseForgeKey, int? modDropId, string? githubRepo, string? customSourceUrl, string? customUrl, string? contentPackFor, WikiCompatibilityInfo compatibility, WikiCompatibilityInfo? betaCompatibility, string[] warnings, string? pullRequestUrl, string? devNote, WikiDataOverrideEntry? overrides, string? anchor) + public bool HasBetaInfo => this.BetaCompatibility != null; + + /// The human-readable warnings for players about this mod. + public string[] Warnings { get; } + + /// The URL of the pull request which submits changes for an unofficial update to the author, if any. + public string? PullRequestUrl { get; } + + /// Special notes intended for developers who maintain unofficial updates or submit pull requests. + public string? DevNote { get; } + + /// The data overrides to apply to the mod's manifest or remote mod page data, if any. + public WikiDataOverrideEntry? Overrides { get; } + + /// The link anchor for the mod entry in the wiki compatibility list. + public string? Anchor { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The mod's unique ID. If the mod has alternate/old IDs, they're listed in latest to oldest order. + /// The mod's display name. If the mod has multiple names, the first one is the most canonical name. + /// The mod's author name. If the author has multiple names, the first one is the most canonical name. + /// The mod ID on Nexus. + /// The mod ID in the Chucklefish mod repo. + /// The mod ID in the CurseForge mod repo. + /// The mod ID in the CurseForge mod repo. + /// The mod ID in the ModDrop mod repo. + /// The GitHub repository in the form 'owner/repo'. + /// The URL to a non-GitHub source repo. + /// The custom mod page URL (if applicable). + /// The name of the mod which loads this content pack, if applicable. + /// The mod's compatibility with the latest stable version of the game. + /// The mod's compatibility with the latest beta version of the game (if any). + /// The human-readable warnings for players about this mod. + /// The URL of the pull request which submits changes for an unofficial update to the author, if any. + /// Special notes intended for developers who maintain unofficial updates or submit pull requests. + /// The data overrides to apply to the mod's manifest or remote mod page data, if any. + /// The link anchor for the mod entry in the wiki compatibility list. + public WikiModEntry(string[] id, string[] name, string[] author, int? nexusId, int? chucklefishId, int? curseForgeId, string? curseForgeKey, int? modDropId, string? githubRepo, string? customSourceUrl, string? customUrl, string? contentPackFor, WikiCompatibilityInfo compatibility, WikiCompatibilityInfo? betaCompatibility, string[] warnings, string? pullRequestUrl, string? devNote, WikiDataOverrideEntry? overrides, string? anchor) + { + this.ID = id; + this.Name = name; + this.Author = author; + this.NexusID = nexusId; + this.ChucklefishID = chucklefishId; + this.CurseForgeID = curseForgeId; + this.CurseForgeKey = curseForgeKey; + this.ModDropID = modDropId; + this.GitHubRepo = githubRepo; + this.CustomSourceUrl = customSourceUrl; + this.CustomUrl = customUrl; + this.ContentPackFor = contentPackFor; + this.Compatibility = compatibility; + this.BetaCompatibility = betaCompatibility; + this.Warnings = warnings; + this.PullRequestUrl = pullRequestUrl; + this.DevNote = devNote; + this.Overrides = overrides; + this.Anchor = anchor; + } + + /// Get the web URLs for the mod pages, if any. + public IEnumerable> GetModPageUrls() + { + bool anyFound = false; + + // normal mod pages + if (this.NexusID.HasValue) + { + anyFound = true; + yield return new KeyValuePair(ModSiteKey.Nexus, $"https://www.nexusmods.com/stardewvalley/mods/{this.NexusID}"); + } + if (this.ModDropID.HasValue) { - this.ID = id; - this.Name = name; - this.Author = author; - this.NexusID = nexusId; - this.ChucklefishID = chucklefishId; - this.CurseForgeID = curseForgeId; - this.CurseForgeKey = curseForgeKey; - this.ModDropID = modDropId; - this.GitHubRepo = githubRepo; - this.CustomSourceUrl = customSourceUrl; - this.CustomUrl = customUrl; - this.ContentPackFor = contentPackFor; - this.Compatibility = compatibility; - this.BetaCompatibility = betaCompatibility; - this.Warnings = warnings; - this.PullRequestUrl = pullRequestUrl; - this.DevNote = devNote; - this.Overrides = overrides; - this.Anchor = anchor; + anyFound = true; + yield return new KeyValuePair(ModSiteKey.ModDrop, $"https://www.moddrop.com/stardew-valley/mod/{this.ModDropID}"); } + if (!string.IsNullOrWhiteSpace(this.CurseForgeKey)) + { + anyFound = true; + yield return new KeyValuePair(ModSiteKey.CurseForge, $"https://www.curseforge.com/stardewvalley/mods/{this.CurseForgeKey}"); + } + if (this.ChucklefishID.HasValue) + { + anyFound = true; + yield return new KeyValuePair(ModSiteKey.Chucklefish, $"https://community.playstarbound.com/resources/{this.ChucklefishID}"); + } + + // custom URL + if (!anyFound && !string.IsNullOrWhiteSpace(this.CustomUrl)) + { + anyFound = true; + yield return new KeyValuePair(ModSiteKey.Unknown, this.CustomUrl); + } + + // fallback + if (!anyFound && !string.IsNullOrWhiteSpace(this.GitHubRepo)) + yield return new KeyValuePair(ModSiteKey.GitHub, $"https://github.com/{this.GitHubRepo}/releases"); } } diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModList.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModList.cs index 245480783..3329c3005 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModList.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModList.cs @@ -1,33 +1,32 @@ -namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki +namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki; + +/// Metadata from the wiki's mod compatibility list. +public class WikiModList { - /// Metadata from the wiki's mod compatibility list. - public class WikiModList - { - /********* - ** Accessors - *********/ - /// The stable game version. - public string? StableVersion { get; } + /********* + ** Accessors + *********/ + /// The stable game version. + public string? StableVersion { get; } - /// The beta game version (if any). - public string? BetaVersion { get; } + /// The beta game version (if any). + public string? BetaVersion { get; } - /// The mods on the wiki. - public WikiModEntry[] Mods { get; } + /// The mods on the wiki. + public WikiModEntry[] Mods { get; } - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The stable game version. - /// The beta game version (if any). - /// The mods on the wiki. - public WikiModList(string? stableVersion, string? betaVersion, WikiModEntry[] mods) - { - this.StableVersion = stableVersion; - this.BetaVersion = betaVersion; - this.Mods = mods; - } + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The stable game version. + /// The beta game version (if any). + /// The mods on the wiki. + public WikiModList(string? stableVersion, string? betaVersion, WikiModEntry[] mods) + { + this.StableVersion = stableVersion; + this.BetaVersion = betaVersion; + this.Mods = mods; } } diff --git a/src/SMAPI.Toolkit/Framework/Constants.cs b/src/SMAPI.Toolkit/Framework/Constants.cs index 55f265822..a3b3df318 100644 --- a/src/SMAPI.Toolkit/Framework/Constants.cs +++ b/src/SMAPI.Toolkit/Framework/Constants.cs @@ -1,9 +1,8 @@ -namespace StardewModdingAPI.Toolkit.Framework +namespace StardewModdingAPI.Toolkit.Framework; + +/// Contains the SMAPI installer's constants and assumptions. +internal static class Constants { - /// Contains the SMAPI installer's constants and assumptions. - internal static class Constants - { - /// The name of the game's main DLL, used to detect game folders. - public const string GameDllName = "Stardew Valley.dll"; - } + /// The name of the game's main DLL, used to detect game folders. + public const string GameDllName = "Stardew Valley.dll"; } diff --git a/src/SMAPI.Toolkit/Framework/GameScanning/GameFolderType.cs b/src/SMAPI.Toolkit/Framework/GameScanning/GameFolderType.cs index 5a049cc9e..2941ed2ea 100644 --- a/src/SMAPI.Toolkit/Framework/GameScanning/GameFolderType.cs +++ b/src/SMAPI.Toolkit/Framework/GameScanning/GameFolderType.cs @@ -1,21 +1,20 @@ -namespace StardewModdingAPI.Toolkit.Framework.GameScanning +namespace StardewModdingAPI.Toolkit.Framework.GameScanning; + +/// The detected validity for a Stardew Valley game folder based on file structure heuristics. +public enum GameFolderType { - /// The detected validity for a Stardew Valley game folder based on file structure heuristics. - public enum GameFolderType - { - /// The folder seems to contain a valid Stardew Valley 1.5.5+ install. - Valid, + /// The folder seems to contain a valid Stardew Valley 1.5.5+ install. + Valid, - /// The folder doesn't contain Stardew Valley. - NoGameFound, + /// The folder doesn't contain Stardew Valley. + NoGameFound, - /// The folder contains Stardew Valley 1.5.6 or earlier, which isn't compatible with current versions of SMAPI. - LegacyVersion, + /// The folder contains Stardew Valley 1.5.6 or earlier, which isn't compatible with current versions of SMAPI. + LegacyVersion, - /// The folder contains Stardew Valley from the game's legacy compatibility branch, which backports newer changes to the format. - LegacyCompatibilityBranch, + /// The folder contains Stardew Valley from the game's legacy compatibility branch, which backports newer changes to the format. + LegacyCompatibilityBranch, - /// The folder seems to contain Stardew Valley files, but they failed to load for unknown reasons (e.g. corrupted executable). - InvalidUnknown - } + /// The folder seems to contain Stardew Valley files, but they failed to load for unknown reasons (e.g. corrupted executable). + InvalidUnknown } diff --git a/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs b/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs index 6e706cc50..ae0333339 100644 --- a/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs +++ b/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs @@ -12,297 +12,297 @@ using VdfParser; #endif -namespace StardewModdingAPI.Toolkit.Framework.GameScanning +namespace StardewModdingAPI.Toolkit.Framework.GameScanning; + +/// Finds installed game folders. +[SuppressMessage("ReSharper", "StringLiteralTypo", Justification = "These are valid game install paths.")] +public class GameScanner { - /// Finds installed game folders. - [SuppressMessage("ReSharper", "StringLiteralTypo", Justification = "These are valid game install paths.")] - public class GameScanner - { - /********* - ** Fields - *********/ - /// The current OS. - private readonly Platform Platform; + /********* + ** Fields + *********/ + /// The current OS. + private readonly Platform Platform; - /// The Steam app ID for Stardew Valley. - private const string SteamAppId = "413150"; + /// The Steam app ID for Stardew Valley. + private const string SteamAppId = "413150"; - /********* - ** Public methods - *********/ - /// Construct an instance. - public GameScanner() - { - this.Platform = EnvironmentUtility.DetectPlatform(); - } + /********* + ** Public methods + *********/ + /// Construct an instance. + public GameScanner() + { + this.Platform = EnvironmentUtility.DetectPlatform(); + } - /// Find all valid Stardew Valley install folders. - /// This checks default game locations, and on Windows checks the Windows registry for GOG/Steam install data. A folder is considered 'valid' if it contains the Stardew Valley executable for the current OS. - public IEnumerable Scan() + /// Find all valid Stardew Valley install folders. + /// This checks default game locations, and on Windows checks the Windows registry for GOG/Steam install data. A folder is considered 'valid' if it contains the Stardew Valley executable for the current OS. + public IEnumerable Scan() + { + foreach ((DirectoryInfo folder, GameFolderType type) in this.ScanIncludingInvalid()) { - foreach ((DirectoryInfo folder, GameFolderType type) in this.ScanIncludingInvalid()) - { - if (type is GameFolderType.Valid) - yield return folder; - } + if (type is GameFolderType.Valid) + yield return folder; } + } - /// Find all valid Stardew Valley install folders. - /// This checks default game locations, and on Windows checks the Windows registry for GOG/Steam install data. A folder is considered 'valid' if it contains the Stardew Valley executable for the current OS. - public IEnumerable<(DirectoryInfo, GameFolderType)> ScanIncludingInvalid() + /// Find all valid Stardew Valley install folders. + /// This checks default game locations, and on Windows checks the Windows registry for GOG/Steam install data. A folder is considered 'valid' if it contains the Stardew Valley executable for the current OS. + public IEnumerable<(DirectoryInfo, GameFolderType)> ScanIncludingInvalid() + { + // get install paths + IEnumerable paths = this + .GetCustomInstallPaths() + .Concat(this.GetDefaultInstallPaths()) + .Select(path => PathUtilities.NormalizePath(path)) + .Distinct(StringComparer.OrdinalIgnoreCase); + + // yield valid folders + foreach (string path in paths) { - // get install paths - IEnumerable paths = this - .GetCustomInstallPaths() - .Concat(this.GetDefaultInstallPaths()) - .Select(path => PathUtilities.NormalizePath(path)) - .Distinct(StringComparer.OrdinalIgnoreCase); - - // yield valid folders - foreach (string path in paths) - { - DirectoryInfo folder = new(path); - if (folder.Exists) - yield return (folder, this.GetGameFolderType(folder)); - } + DirectoryInfo folder = new(path); + if (folder.Exists) + yield return (folder, this.GetGameFolderType(folder)); } + } - /// Detect the validity of a game folder based on file structure heuristics. - /// The folder to check. - public GameFolderType GetGameFolderType(DirectoryInfo dir) + /// Detect the validity of a game folder based on file structure heuristics. + /// The folder to check. + public GameFolderType GetGameFolderType(DirectoryInfo dir) + { + // no such folder + if (!dir.Exists) + return GameFolderType.NoGameFound; + + // invalid folder + if (!File.Exists(Path.Combine(dir.FullName, "Stardew Valley.dll"))) { - // no such folder - if (!dir.Exists) + // get executable + FileInfo executable = new(Path.Combine(dir.FullName, "Stardew Valley.exe")); + if (!executable.Exists) + executable = new(Path.Combine(dir.FullName, "StardewValley.exe")); // pre-1.5.5 Linux/macOS executable + if (!executable.Exists) return GameFolderType.NoGameFound; - // invalid folder - if (!File.Exists(Path.Combine(dir.FullName, "Stardew Valley.dll"))) + // get assembly version + Version? version; + try { - // get executable - FileInfo executable = new(Path.Combine(dir.FullName, "Stardew Valley.exe")); - if (!executable.Exists) - executable = new(Path.Combine(dir.FullName, "StardewValley.exe")); // pre-1.5.5 Linux/macOS executable - if (!executable.Exists) - return GameFolderType.NoGameFound; - - // get assembly version - Version? version; - try - { - version = AssemblyName.GetAssemblyName(executable.FullName).Version; - if (version == null) - return GameFolderType.InvalidUnknown; - } - catch - { - return GameFolderType.InvalidUnknown; // executable exists, but it doesn't seem to be a valid assembly - } - - // legacy version that's no longer supported - if (version.Major < 1 || version is { Major: 1, Minor: < 6 }) - return GameFolderType.LegacyVersion; + version = AssemblyName.GetAssemblyName(executable.FullName).Version; + if (version == null) + return GameFolderType.InvalidUnknown; + } + catch + { + return GameFolderType.InvalidUnknown; // executable exists, but it doesn't seem to be a valid assembly + } - // compatibility branch - if (!File.Exists(Path.Combine(dir.FullName, "MonoGame.Framework.dll"))) - return GameFolderType.LegacyCompatibilityBranch; + // legacy version that's no longer supported + if (version.Major < 1 || version is { Major: 1, Minor: < 6 }) + return GameFolderType.LegacyVersion; - return GameFolderType.InvalidUnknown; - } + // compatibility branch + if (!File.Exists(Path.Combine(dir.FullName, "MonoGame.Framework.dll"))) + return GameFolderType.LegacyCompatibilityBranch; - // apparently valid - return GameFolderType.Valid; + return GameFolderType.InvalidUnknown; } - /********* - ** Private methods - *********/ - /// The default file paths where Stardew Valley can be installed. - /// Derived from the crossplatform mod config. - private IEnumerable GetDefaultInstallPaths() - { - switch (this.Platform) - { - case Platform.Linux: - case Platform.Mac: - { - string home = Environment.GetEnvironmentVariable("HOME")!; + // apparently valid + return GameFolderType.Valid; + } - // Linux - yield return $"{home}/GOG Games/Stardew Valley/game"; - yield return Directory.Exists($"{home}/.steam/steam/steamapps/common/Stardew Valley") - ? $"{home}/.steam/steam/steamapps/common/Stardew Valley" - : $"{home}/.local/share/Steam/steamapps/common/Stardew Valley"; + /********* + ** Private methods + *********/ + /// The default file paths where Stardew Valley can be installed. + /// Derived from the crossplatform mod config. + private IEnumerable GetDefaultInstallPaths() + { + switch (this.Platform) + { + case Platform.Linux: + case Platform.Mac: + { + string home = Environment.GetEnvironmentVariable("HOME")!; + + // Linux + yield return $"{home}/GOG Games/Stardew Valley/game"; + yield return Directory.Exists($"{home}/.steam/steam/steamapps/common/Stardew Valley") + ? $"{home}/.steam/steam/steamapps/common/Stardew Valley" + : $"{home}/.local/share/Steam/steamapps/common/Stardew Valley"; + yield return $"{home}/.var/app/com.valvesoftware.Steam/data/Steam/steamapps/common/Stardew Valley"; // Flatpak + + // macOS + yield return "/Applications/Stardew Valley.app/Contents/MacOS"; + yield return $"{home}/Library/Application Support/Steam/steamapps/common/Stardew Valley/Contents/MacOS"; + } + break; - // macOS - yield return "/Applications/Stardew Valley.app/Contents/MacOS"; - yield return $"{home}/Library/Application Support/Steam/steamapps/common/Stardew Valley/Contents/MacOS"; + case Platform.Windows: + { + // Windows registry +#if SMAPI_FOR_WINDOWS + IDictionary registryKeys = new Dictionary + { + [@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Steam App " + GameScanner.SteamAppId] = "InstallLocation", // Steam + [@"SOFTWARE\WOW6432Node\GOG.com\Games\1453375253"] = "PATH", // GOG on 64-bit Windows + }; + foreach (var pair in registryKeys) + { + string? path = this.GetLocalMachineRegistryValue(pair.Key, pair.Value); + if (!string.IsNullOrWhiteSpace(path)) + yield return path; } - break; - case Platform.Windows: + // via Steam library path + string? steamPath = this.GetCurrentUserRegistryValue(@"Software\Valve\Steam", "SteamPath"); + if (steamPath != null) { - // Windows registry -#if SMAPI_FOR_WINDOWS - IDictionary registryKeys = new Dictionary - { - [@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Steam App " + GameScanner.SteamAppId] = "InstallLocation", // Steam - [@"SOFTWARE\WOW6432Node\GOG.com\Games\1453375253"] = "PATH", // GOG on 64-bit Windows - }; - foreach (var pair in registryKeys) - { - string? path = this.GetLocalMachineRegistryValue(pair.Key, pair.Value); - if (!string.IsNullOrWhiteSpace(path)) - yield return path; - } - - // via Steam library path - string? steamPath = this.GetCurrentUserRegistryValue(@"Software\Valve\Steam", "SteamPath"); - if (steamPath != null) - { - // conventional path - yield return Path.Combine(steamPath.Replace('/', '\\'), @"steamapps\common\Stardew Valley"); - - // from Steam's .vdf file - string? path = this.GetPathFromSteamLibrary(steamPath); - if (!string.IsNullOrWhiteSpace(path)) - yield return path; - } + // conventional path + yield return Path.Combine(steamPath.Replace('/', '\\'), @"steamapps\common\Stardew Valley"); + + // from Steam's .vdf file + string? path = this.GetPathFromSteamLibrary(steamPath); + if (!string.IsNullOrWhiteSpace(path)) + yield return path; + } #endif - // default GOG/Steam paths - foreach (string programFiles in new[] { @"C:\Program Files", @"C:\Program Files (x86)" }) - { - yield return $@"{programFiles}\GalaxyClient\Games\Stardew Valley"; - yield return $@"{programFiles}\GOG Galaxy\Games\Stardew Valley"; - yield return $@"{programFiles}\GOG Games\Stardew Valley"; - yield return $@"{programFiles}\Steam\steamapps\common\Stardew Valley"; - } - - // default Xbox app paths - // The Xbox app saves the install path to the registry, but we can't use it - // here since it saves the internal readonly path (like C:\Program Files\WindowsApps\Mutable\) - // instead of the mods-enabled path(like C:\Program Files\ModifiableWindowsApps\Stardew Valley). - // Fortunately we can cheat a bit: players can customize the install drive, but they can't - // change the install path on the drive. - for (char driveLetter = 'C'; driveLetter <= 'H'; driveLetter++) - yield return $@"{driveLetter}:\Program Files\ModifiableWindowsApps\Stardew Valley"; + // default GOG/Steam paths + foreach (string programFiles in new[] { @"C:\Program Files", @"C:\Program Files (x86)" }) + { + yield return $@"{programFiles}\GalaxyClient\Games\Stardew Valley"; + yield return $@"{programFiles}\GOG Galaxy\Games\Stardew Valley"; + yield return $@"{programFiles}\GOG Games\Stardew Valley"; + yield return $@"{programFiles}\Steam\steamapps\common\Stardew Valley"; } - break; - default: - throw new InvalidOperationException($"Unknown platform '{this.Platform}'."); - } + // default Xbox app paths + // The Xbox app saves the install path to the registry, but we can't use it + // here since it saves the internal readonly path (like C:\Program Files\WindowsApps\Mutable\) + // instead of the mods-enabled path(like C:\Program Files\ModifiableWindowsApps\Stardew Valley). + // Fortunately we can cheat a bit: players can customize the install drive, but they can't + // change the install path on the drive. + for (char driveLetter = 'C'; driveLetter <= 'H'; driveLetter++) + yield return $@"{driveLetter}:\Program Files\ModifiableWindowsApps\Stardew Valley"; + } + break; + + default: + throw new InvalidOperationException($"Unknown platform '{this.Platform}'."); } + } - /// Get the custom install path from the stardewvalley.targets file in the home directory, if any. - private IEnumerable GetCustomInstallPaths() + /// Get the custom install path from the stardewvalley.targets file in the home directory, if any. + private IEnumerable GetCustomInstallPaths() + { + // get home path + string homePath = Environment.GetEnvironmentVariable(this.Platform == Platform.Windows ? "USERPROFILE" : "HOME")!; + if (string.IsNullOrWhiteSpace(homePath)) + yield break; + + // get targets file + FileInfo file = new(Path.Combine(homePath, "stardewvalley.targets")); + if (!file.Exists) + yield break; + + // parse file + XElement root; + try { - // get home path - string homePath = Environment.GetEnvironmentVariable(this.Platform == Platform.Windows ? "USERPROFILE" : "HOME")!; - if (string.IsNullOrWhiteSpace(homePath)) - yield break; - - // get targets file - FileInfo file = new(Path.Combine(homePath, "stardewvalley.targets")); - if (!file.Exists) - yield break; - - // parse file - XElement root; - try - { - using FileStream stream = file.OpenRead(); - root = XElement.Load(stream); - } - catch - { - yield break; - } - - // get install path - XElement? element = root.XPathSelectElement("//*[local-name() = 'GamePath']"); // can't use '//GamePath' due to the default namespace - if (!string.IsNullOrWhiteSpace(element?.Value)) - yield return element.Value.Trim(); + using FileStream stream = file.OpenRead(); + root = XElement.Load(stream); + } + catch + { + yield break; } + // get install path + XElement? element = root.XPathSelectElement("//*[local-name() = 'GamePath']"); // can't use '//GamePath' due to the default namespace + if (!string.IsNullOrWhiteSpace(element?.Value)) + yield return element.Value.Trim(); + } + #if SMAPI_FOR_WINDOWS - /// Get the value of a key in the Windows HKLM registry. - /// The full path of the registry key relative to HKLM. - /// The name of the value. - private string? GetLocalMachineRegistryValue(string key, string name) + /// Get the value of a key in the Windows HKLM registry. + /// The full path of the registry key relative to HKLM. + /// The name of the value. + private string? GetLocalMachineRegistryValue(string key, string name) + { + RegistryKey localMachine = Environment.Is64BitOperatingSystem ? RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64) : Registry.LocalMachine; + RegistryKey? openKey = localMachine.OpenSubKey(key); + if (openKey == null) + return null; + using (openKey) + return (string?)openKey.GetValue(name); + } + + /// Get the value of a key in the Windows HKCU registry. + /// The full path of the registry key relative to HKCU. + /// The name of the value. + private string? GetCurrentUserRegistryValue(string key, string name) + { + RegistryKey currentUser = Environment.Is64BitOperatingSystem ? RegistryKey.OpenBaseKey(RegistryHive.CurrentUser, RegistryView.Registry64) : Registry.CurrentUser; + RegistryKey? openKey = currentUser.OpenSubKey(key); + if (openKey == null) + return null; + using (openKey) + return (string?)openKey.GetValue(name); + } + + /// Get the game directory path from alternative Steam library locations. + /// The full path to the directory containing steam.exe. + /// The game directory, if found. + private string? GetPathFromSteamLibrary(string? steamPath) + { + try { - RegistryKey localMachine = Environment.Is64BitOperatingSystem ? RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64) : Registry.LocalMachine; - RegistryKey? openKey = localMachine.OpenSubKey(key); - if (openKey == null) + if (steamPath == null) return null; - using (openKey) - return (string?)openKey.GetValue(name); - } - /// Get the value of a key in the Windows HKCU registry. - /// The full path of the registry key relative to HKCU. - /// The name of the value. - private string? GetCurrentUserRegistryValue(string key, string name) - { - RegistryKey currentUser = Environment.Is64BitOperatingSystem ? RegistryKey.OpenBaseKey(RegistryHive.CurrentUser, RegistryView.Registry64) : Registry.CurrentUser; - RegistryKey? openKey = currentUser.OpenSubKey(key); - if (openKey == null) + // get .vdf file path + string libraryFoldersPath = Path.Combine(steamPath.Replace('/', '\\'), "steamapps\\libraryfolders.vdf"); + if (!File.Exists(libraryFoldersPath)) return null; - using (openKey) - return (string?)openKey.GetValue(name); - } - /// Get the game directory path from alternative Steam library locations. - /// The full path to the directory containing steam.exe. - /// The game directory, if found. - private string? GetPathFromSteamLibrary(string? steamPath) - { - try + // read data + using FileStream fileStream = File.OpenRead(libraryFoldersPath); + VdfDeserializer deserializer = new(); + dynamic libraries = deserializer.Deserialize(fileStream); + if (libraries?.libraryfolders is null) + return null; + + // get path from Stardew Valley app (if any) + foreach (dynamic pair in libraries.libraryfolders) { - if (steamPath == null) - return null; - - // get .vdf file path - string libraryFoldersPath = Path.Combine(steamPath.Replace('/', '\\'), "steamapps\\libraryfolders.vdf"); - if (!File.Exists(libraryFoldersPath)) - return null; - - // read data - using FileStream fileStream = File.OpenRead(libraryFoldersPath); - VdfDeserializer deserializer = new(); - dynamic libraries = deserializer.Deserialize(fileStream); - if (libraries?.libraryfolders is null) - return null; - - // get path from Stardew Valley app (if any) - foreach (dynamic pair in libraries.libraryfolders) - { - dynamic library = pair.Value; + dynamic library = pair.Value; - foreach (dynamic app in library.apps) + foreach (dynamic app in library.apps) + { + string key = app.Key; + if (key == GameScanner.SteamAppId) { - string key = app.Key; - if (key == GameScanner.SteamAppId) - { - string path = library.path; + string path = library.path; - return Path.Combine(path.Replace("\\\\", "\\"), "steamapps", "common", "Stardew Valley"); - } + return Path.Combine(path.Replace("\\\\", "\\"), "steamapps", "common", "Stardew Valley"); } } - - return null; - } - catch - { - // The file might not be parseable in some cases (e.g. some players have an older Steam version using - // a different format). Ideally we'd log an error to know when it's actually an issue, but the SMAPI - // installer doesn't have a logging mechanism (and third-party code calling the toolkit may not either). - // So for now, just ignore the error and fallback to the other discovery mechanisms. - return null; } + + return null; + } + catch + { + // The file might not be parseable in some cases (e.g. some players have an older Steam version using + // a different format). Ideally we'd log an error to know when it's actually an issue, but the SMAPI + // installer doesn't have a logging mechanism (and third-party code calling the toolkit may not either). + // So for now, just ignore the error and fallback to the other discovery mechanisms. + return null; } -#endif } +#endif } diff --git a/src/SMAPI.Toolkit/Framework/LowLevelEnvironmentUtility.cs b/src/SMAPI.Toolkit/Framework/LowLevelEnvironmentUtility.cs index f464f4bb0..9afbf6f65 100644 --- a/src/SMAPI.Toolkit/Framework/LowLevelEnvironmentUtility.cs +++ b/src/SMAPI.Toolkit/Framework/LowLevelEnvironmentUtility.cs @@ -9,151 +9,150 @@ using System.Runtime.InteropServices; using StardewModdingAPI.Toolkit.Utilities; -namespace StardewModdingAPI.Toolkit.Framework +namespace StardewModdingAPI.Toolkit.Framework; + +/// Provides low-level methods for fetching environment information. +/// This is used by the SMAPI core before the toolkit DLL is available; most code should use instead. +internal static class LowLevelEnvironmentUtility { - /// Provides low-level methods for fetching environment information. - /// This is used by the SMAPI core before the toolkit DLL is available; most code should use instead. - internal static class LowLevelEnvironmentUtility + /********* + ** Fields + *********/ + /// Get the OS name from the system uname command. + /// The buffer to fill with the resulting string. + [DllImport("libc")] + [SuppressMessage("ReSharper", "IdentifierTypo", Justification = "This is the actual external command name.")] + private static extern int uname(IntPtr buffer); + + + /********* + ** Public methods + *********/ + /// Detect the current OS. + public static string DetectPlatform() { - /********* - ** Fields - *********/ - /// Get the OS name from the system uname command. - /// The buffer to fill with the resulting string. - [DllImport("libc")] - [SuppressMessage("ReSharper", "IdentifierTypo", Justification = "This is the actual external command name.")] - private static extern int uname(IntPtr buffer); - - - /********* - ** Public methods - *********/ - /// Detect the current OS. - public static string DetectPlatform() + switch (Environment.OSVersion.Platform) { - switch (Environment.OSVersion.Platform) - { - case PlatformID.MacOSX: - return nameof(Platform.Mac); + case PlatformID.MacOSX: + return nameof(Platform.Mac); - case PlatformID.Unix when LowLevelEnvironmentUtility.IsRunningAndroid(): - return nameof(Platform.Android); + case PlatformID.Unix when LowLevelEnvironmentUtility.IsRunningAndroid(): + return nameof(Platform.Android); - case PlatformID.Unix when LowLevelEnvironmentUtility.IsRunningMac(): - return nameof(Platform.Mac); + case PlatformID.Unix when LowLevelEnvironmentUtility.IsRunningMac(): + return nameof(Platform.Mac); - case PlatformID.Unix: - return nameof(Platform.Linux); + case PlatformID.Unix: + return nameof(Platform.Linux); - default: - return nameof(Platform.Windows); - } + default: + return nameof(Platform.Windows); } + } - /// Get the human-readable OS name and version. - /// The current platform. - public static string GetFriendlyPlatformName(string platform) - { + /// Get the human-readable OS name and version. + /// The current platform. + public static string GetFriendlyPlatformName(string platform) + { #if SMAPI_FOR_WINDOWS - try - { - string? result = new ManagementObjectSearcher("SELECT Caption FROM Win32_OperatingSystem") - .Get() - .Cast() - .Select(entry => entry.GetPropertyValue("Caption").ToString()) - .FirstOrDefault(); + try + { + string? result = new ManagementObjectSearcher("SELECT Caption FROM Win32_OperatingSystem") + .Get() + .Cast() + .Select(entry => entry.GetPropertyValue("Caption").ToString()) + .FirstOrDefault(); - return result ?? "Windows"; - } - catch - { - // fallback to default behavior - } + return result ?? "Windows"; + } + catch + { + // fallback to default behavior + } #endif - string name = Environment.OSVersion.ToString(); - switch (platform) - { - case nameof(Platform.Android): - name = $"Android {name}"; - break; + string name = Environment.OSVersion.ToString(); + switch (platform) + { + case nameof(Platform.Android): + name = $"Android {name}"; + break; - case nameof(Platform.Mac): - name = $"macOS {name}"; - break; - } - return name; + case nameof(Platform.Mac): + name = $"macOS {name}"; + break; } + return name; + } - /// Get whether an executable is 64-bit. - /// The absolute path to the assembly file. - public static bool Is64BitAssembly(string path) - { - return AssemblyName.GetAssemblyName(path).ProcessorArchitecture != ProcessorArchitecture.X86; - } + /// Get whether an executable is 64-bit. + /// The absolute path to the assembly file. + public static bool Is64BitAssembly(string path) + { + return AssemblyName.GetAssemblyName(path).ProcessorArchitecture != ProcessorArchitecture.X86; + } - /********* - ** Private methods - *********/ - /// Detect whether the code is running on Android. - /// - /// This code is derived from https://stackoverflow.com/a/47521647/262123. It detects Android by calling the - /// getprop system command to check for an Android-specific property. - /// - private static bool IsRunningAndroid() + /********* + ** Private methods + *********/ + /// Detect whether the code is running on Android. + /// + /// This code is derived from https://stackoverflow.com/a/47521647/262123. It detects Android by calling the + /// getprop system command to check for an Android-specific property. + /// + private static bool IsRunningAndroid() + { + using Process process = new() { - using Process process = new() - { - StartInfo = - { - FileName = "getprop", - Arguments = "ro.build.user", - RedirectStandardOutput = true, - UseShellExecute = false, - CreateNoWindow = true - } - }; - - try - { - process.Start(); - string output = process.StandardOutput.ReadToEnd(); - return !string.IsNullOrWhiteSpace(output); - } - catch + StartInfo = { - return false; + FileName = "getprop", + Arguments = "ro.build.user", + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true } + }; + + try + { + process.Start(); + string output = process.StandardOutput.ReadToEnd(); + return !string.IsNullOrWhiteSpace(output); + } + catch + { + return false; } + } - /// Detect whether the code is running on macOS. - /// - /// This code is derived from the Mono project (see System.Windows.Forms/System.Windows.Forms/XplatUI.cs). It detects macOS by calling the - /// uname system command and checking the response, which is always 'Darwin' for macOS. - /// - private static bool IsRunningMac() + /// Detect whether the code is running on macOS. + /// + /// This code is derived from the Mono project (see System.Windows.Forms/System.Windows.Forms/XplatUI.cs). It detects macOS by calling the + /// uname system command and checking the response, which is always 'Darwin' for macOS. + /// + private static bool IsRunningMac() + { + IntPtr buffer = IntPtr.Zero; + try { - IntPtr buffer = IntPtr.Zero; - try - { - buffer = Marshal.AllocHGlobal(8192); - if (LowLevelEnvironmentUtility.uname(buffer) == 0) - { - string? os = Marshal.PtrToStringAnsi(buffer); - return os == "Darwin"; - } - return false; - } - catch - { - return false; // default to Linux - } - finally + buffer = Marshal.AllocHGlobal(8192); + if (LowLevelEnvironmentUtility.uname(buffer) == 0) { - if (buffer != IntPtr.Zero) - Marshal.FreeHGlobal(buffer); + string? os = Marshal.PtrToStringAnsi(buffer); + return os == "Darwin"; } + return false; + } + catch + { + return false; // default to Linux + } + finally + { + if (buffer != IntPtr.Zero) + Marshal.FreeHGlobal(buffer); } } } diff --git a/src/SMAPI.Toolkit/Framework/ManifestValidator.cs b/src/SMAPI.Toolkit/Framework/ManifestValidator.cs index 461dc3255..164df58cf 100644 --- a/src/SMAPI.Toolkit/Framework/ManifestValidator.cs +++ b/src/SMAPI.Toolkit/Framework/ManifestValidator.cs @@ -4,103 +4,141 @@ using System.Linq; using StardewModdingAPI.Toolkit.Utilities; -namespace StardewModdingAPI.Toolkit.Framework +namespace StardewModdingAPI.Toolkit.Framework; + +/// Validates manifest fields. +public static class ManifestValidator { - /// Validates manifest fields. - public static class ManifestValidator + /// Validate a manifest's fields. + /// The manifest to validate. + /// The error message indicating why validation failed, if applicable. + /// Returns whether all manifest fields validated successfully. + [SuppressMessage("ReSharper", "ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract", Justification = "This is the method that ensures those annotations are respected.")] + public static bool TryValidateFields(IManifest manifest, out string error) { - /// Validate a manifest's fields. - /// The manifest to validate. - /// The error message indicating why validation failed, if applicable. - /// Returns whether all manifest fields validated successfully. - [SuppressMessage("ReSharper", "ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract", Justification = "This is the method that ensures those annotations are respected.")] - public static bool TryValidateFields(IManifest manifest, out string error) - { - // - // Note: SMAPI assumes that it can grammatically append the returned sentence in the - // form "failed loading because its ". Any errors returned should be valid - // in that format, unless the SMAPI call is adjusted accordingly. - // + // + // Note: SMAPI assumes that it can grammatically append the returned sentence in the + // form "failed loading because its ". Any errors returned should be valid + // in that format, unless the SMAPI call is adjusted accordingly. + // + + bool hasDll = !string.IsNullOrWhiteSpace(manifest.EntryDll); + bool isContentPack = manifest.ContentPackFor != null; - bool hasDll = !string.IsNullOrWhiteSpace(manifest.EntryDll); - bool isContentPack = manifest.ContentPackFor != null; + // validate use of EntryDll vs ContentPackFor fields + if (hasDll == isContentPack) + { + error = hasDll + ? $"manifest sets both {nameof(IManifest.EntryDll)} and {nameof(IManifest.ContentPackFor)}, which are mutually exclusive." + : $"manifest has no {nameof(IManifest.EntryDll)} or {nameof(IManifest.ContentPackFor)} field; must specify one."; + return false; + } - // validate use of EntryDll vs ContentPackFor fields - if (hasDll == isContentPack) + // validate EntryDll/ContentPackFor format + if (hasDll) + { + if (manifest.EntryDll!.Intersect(Path.GetInvalidFileNameChars()).Any()) { - error = hasDll - ? $"manifest sets both {nameof(IManifest.EntryDll)} and {nameof(IManifest.ContentPackFor)}, which are mutually exclusive." - : $"manifest has no {nameof(IManifest.EntryDll)} or {nameof(IManifest.ContentPackFor)} field; must specify one."; + error = $"manifest has invalid filename '{manifest.EntryDll}' for the {nameof(IManifest.EntryDll)} field."; return false; } - - // validate EntryDll/ContentPackFor format - if (hasDll) + } + else + { + if (string.IsNullOrWhiteSpace(manifest.ContentPackFor!.UniqueID)) { - if (manifest.EntryDll!.Intersect(Path.GetInvalidFileNameChars()).Any()) - { - error = $"manifest has invalid filename '{manifest.EntryDll}' for the {nameof(IManifest.EntryDll)} field."; - return false; - } + error = $"manifest declares {nameof(IManifest.ContentPackFor)} without its required {nameof(IManifestContentPackFor.UniqueID)} field."; + return false; } - else + } + + // validate required fields + { + List missingFields = new List(3); + + if (string.IsNullOrWhiteSpace(manifest.Name)) + missingFields.Add(nameof(IManifest.Name)); + if (manifest.Version == null || manifest.Version.ToString() == "0.0.0") + missingFields.Add(nameof(IManifest.Version)); + if (string.IsNullOrWhiteSpace(manifest.UniqueID)) + missingFields.Add(nameof(IManifest.UniqueID)); + + if (missingFields.Any()) { - if (string.IsNullOrWhiteSpace(manifest.ContentPackFor!.UniqueID)) - { - error = $"manifest declares {nameof(IManifest.ContentPackFor)} without its required {nameof(IManifestContentPackFor.UniqueID)} field."; - return false; - } + error = $"manifest is missing required fields ({string.Join(", ", missingFields)})."; + return false; } + } - // validate required fields - { - List missingFields = new List(3); + // validate ID format + if (!PathUtilities.IsSlug(manifest.UniqueID)) + { + error = "manifest specifies an invalid ID (IDs must only contain letters, numbers, underscores, periods, or hyphens)."; + return false; + } - if (string.IsNullOrWhiteSpace(manifest.Name)) - missingFields.Add(nameof(IManifest.Name)); - if (manifest.Version == null || manifest.Version.ToString() == "0.0.0") - missingFields.Add(nameof(IManifest.Version)); - if (string.IsNullOrWhiteSpace(manifest.UniqueID)) - missingFields.Add(nameof(IManifest.UniqueID)); + // validate dependency format + foreach (IManifestDependency? dependency in manifest.Dependencies) + { + if (dependency is null) + { + error = $"manifest has a null entry under {nameof(IManifest.Dependencies)}."; + return false; + } - if (missingFields.Any()) - { - error = $"manifest is missing required fields ({string.Join(", ", missingFields)})."; - return false; - } + if (string.IsNullOrWhiteSpace(dependency.UniqueID)) + { + error = $"manifest has a {nameof(IManifest.Dependencies)} entry with no {nameof(IManifestDependency.UniqueID)} field."; + return false; } - // validate ID format - if (!PathUtilities.IsSlug(manifest.UniqueID)) + if (!PathUtilities.IsSlug(dependency.UniqueID)) { - error = "manifest specifies an invalid ID (IDs must only contain letters, numbers, underscores, periods, or hyphens)."; + error = $"manifest has a {nameof(IManifest.Dependencies)} entry with an invalid {nameof(IManifestDependency.UniqueID)} field (IDs must only contain letters, numbers, underscores, periods, or hyphens)."; return false; } + } - // validate dependency format - foreach (IManifestDependency? dependency in manifest.Dependencies) + // validate private assemblies format + if (!hasDll) + { + if (manifest.PrivateAssemblies.Length > 0) + { + error = $"manifest includes {nameof(IManifest.PrivateAssemblies)}, which isn't valid for a content pack."; + return false; + } + } + else + { + foreach (IManifestPrivateAssembly? assembly in manifest.PrivateAssemblies) { - if (dependency == null) + if (assembly is null) { - error = $"manifest has a null entry under {nameof(IManifest.Dependencies)}."; + error = $"manifest has a null entry under {nameof(IManifest.PrivateAssemblies)}."; return false; } - if (string.IsNullOrWhiteSpace(dependency.UniqueID)) + if (string.IsNullOrWhiteSpace(assembly.Name)) { - error = $"manifest has a {nameof(IManifest.Dependencies)} entry with no {nameof(IManifestDependency.UniqueID)} field."; + error = $"manifest has a {nameof(IManifest.PrivateAssemblies)} entry with no {nameof(IManifestPrivateAssembly.Name)} field."; return false; } - if (!PathUtilities.IsSlug(dependency.UniqueID)) + if (assembly.Name.Contains('/') || assembly.Name.Contains('\\') || assembly.Name.Contains(".dll")) { - error = $"manifest has a {nameof(IManifest.Dependencies)} entry with an invalid {nameof(IManifestDependency.UniqueID)} field (IDs must only contain letters, numbers, underscores, periods, or hyphens)."; + error = $"manifest has a {nameof(IManifest.PrivateAssemblies)} entry with an invalid {nameof(IManifestPrivateAssembly.Name)} field (must be the assembly name without the file path, extension, or metadata)."; return false; } - } - error = ""; - return true; + if (assembly.Name is "0Harmony" or "MonoGame.Framework" or "StardewModdingAPI" or "Stardew Valley" or "StardewValley.GameData") + { + error = $"manifest has a {nameof(IManifest.PrivateAssemblies)} entry with an invalid {nameof(IManifestPrivateAssembly.Name)} field (the '{assembly.Name}' assembly can't be private)."; + return false; + } + } } + + error = ""; + return true; } } diff --git a/src/SMAPI.Toolkit/Framework/ModData/MetadataModel.cs b/src/SMAPI.Toolkit/Framework/ModData/MetadataModel.cs index da678ac99..0c9ad1088 100644 --- a/src/SMAPI.Toolkit/Framework/ModData/MetadataModel.cs +++ b/src/SMAPI.Toolkit/Framework/ModData/MetadataModel.cs @@ -1,14 +1,13 @@ using System.Collections.Generic; -namespace StardewModdingAPI.Toolkit.Framework.ModData +namespace StardewModdingAPI.Toolkit.Framework.ModData; + +/// The SMAPI predefined metadata. +internal class MetadataModel { - /// The SMAPI predefined metadata. - internal class MetadataModel - { - /******** - ** Accessors - ********/ - /// Extra metadata about mods. - public IDictionary ModData { get; } = new Dictionary(); - } + /******** + ** Accessors + ********/ + /// Extra metadata about mods. + public IDictionary ModData { get; } = new Dictionary(); } diff --git a/src/SMAPI.Toolkit/Framework/ModData/ModDataField.cs b/src/SMAPI.Toolkit/Framework/ModData/ModDataField.cs index 9674d2832..fe792df18 100644 --- a/src/SMAPI.Toolkit/Framework/ModData/ModDataField.cs +++ b/src/SMAPI.Toolkit/Framework/ModData/ModDataField.cs @@ -1,82 +1,81 @@ using System.Linq; -namespace StardewModdingAPI.Toolkit.Framework.ModData +namespace StardewModdingAPI.Toolkit.Framework.ModData; + +/// A versioned mod metadata field. +public class ModDataField { - /// A versioned mod metadata field. - public class ModDataField - { - /********* - ** Accessors - *********/ - /// The field key. - public ModDataFieldKey Key { get; } + /********* + ** Accessors + *********/ + /// The field key. + public ModDataFieldKey Key { get; } - /// The field value. - public string Value { get; } + /// The field value. + public string Value { get; } - /// Whether this field should only be applied if it's not already set. - public bool IsDefault { get; } + /// Whether this field should only be applied if it's not already set. + public bool IsDefault { get; } - /// The lowest version in the range, or null for all past versions. - public ISemanticVersion? LowerVersion { get; } + /// The lowest version in the range, or null for all past versions. + public ISemanticVersion? LowerVersion { get; } - /// The highest version in the range, or null for all future versions. - public ISemanticVersion? UpperVersion { get; } + /// The highest version in the range, or null for all future versions. + public ISemanticVersion? UpperVersion { get; } - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The field key. - /// The field value. - /// Whether this field should only be applied if it's not already set. - /// The lowest version in the range, or null for all past versions. - /// The highest version in the range, or null for all future versions. - public ModDataField(ModDataFieldKey key, string value, bool isDefault, ISemanticVersion? lowerVersion, ISemanticVersion? upperVersion) - { - this.Key = key; - this.Value = value; - this.IsDefault = isDefault; - this.LowerVersion = lowerVersion; - this.UpperVersion = upperVersion; - } + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The field key. + /// The field value. + /// Whether this field should only be applied if it's not already set. + /// The lowest version in the range, or null for all past versions. + /// The highest version in the range, or null for all future versions. + public ModDataField(ModDataFieldKey key, string value, bool isDefault, ISemanticVersion? lowerVersion, ISemanticVersion? upperVersion) + { + this.Key = key; + this.Value = value; + this.IsDefault = isDefault; + this.LowerVersion = lowerVersion; + this.UpperVersion = upperVersion; + } - /// Get whether this data field applies for the given manifest. - /// The mod manifest. - public bool IsMatch(IManifest? manifest) - { - return - manifest?.Version != null // ignore invalid manifest - && (!this.IsDefault || !this.HasFieldValue(manifest, this.Key)) - && (this.LowerVersion == null || !manifest.Version.IsOlderThan(this.LowerVersion)) - && (this.UpperVersion == null || !manifest.Version.IsNewerThan(this.UpperVersion)); - } + /// Get whether this data field applies for the given manifest. + /// The mod manifest. + public bool IsMatch(IManifest? manifest) + { + return + manifest?.Version != null // ignore invalid manifest + && (!this.IsDefault || !this.HasFieldValue(manifest, this.Key)) + && (this.LowerVersion == null || !manifest.Version.IsOlderThan(this.LowerVersion)) + && (this.UpperVersion == null || !manifest.Version.IsNewerThan(this.UpperVersion)); + } - /********* - ** Private methods - *********/ - /// Get whether a manifest field has a meaningful value for the purposes of enforcing . - /// The mod manifest. - /// The field key matching . - private bool HasFieldValue(IManifest manifest, ModDataFieldKey key) + /********* + ** Private methods + *********/ + /// Get whether a manifest field has a meaningful value for the purposes of enforcing . + /// The mod manifest. + /// The field key matching . + private bool HasFieldValue(IManifest manifest, ModDataFieldKey key) + { + switch (key) { - switch (key) - { - // update key - case ModDataFieldKey.UpdateKey: - return manifest.UpdateKeys.Any(p => !string.IsNullOrWhiteSpace(p)); + // update key + case ModDataFieldKey.UpdateKey: + return manifest.UpdateKeys.Any(p => !string.IsNullOrWhiteSpace(p)); - // non-manifest fields - case ModDataFieldKey.StatusReasonPhrase: - case ModDataFieldKey.StatusReasonDetails: - case ModDataFieldKey.Status: - return false; + // non-manifest fields + case ModDataFieldKey.StatusReasonPhrase: + case ModDataFieldKey.StatusReasonDetails: + case ModDataFieldKey.Status: + return false; - default: - return false; - } + default: + return false; } } } diff --git a/src/SMAPI.Toolkit/Framework/ModData/ModDataFieldKey.cs b/src/SMAPI.Toolkit/Framework/ModData/ModDataFieldKey.cs index 2b59096d2..8e23bc387 100644 --- a/src/SMAPI.Toolkit/Framework/ModData/ModDataFieldKey.cs +++ b/src/SMAPI.Toolkit/Framework/ModData/ModDataFieldKey.cs @@ -1,18 +1,17 @@ -namespace StardewModdingAPI.Toolkit.Framework.ModData +namespace StardewModdingAPI.Toolkit.Framework.ModData; + +/// The valid field keys. +public enum ModDataFieldKey { - /// The valid field keys. - public enum ModDataFieldKey - { - /// A manifest update key. - UpdateKey, + /// A manifest update key. + UpdateKey, - /// The mod's predefined compatibility status. - Status, + /// The mod's predefined compatibility status. + Status, - /// A reason phrase for the , or null to use the default reason. - StatusReasonPhrase, + /// A reason phrase for the , or null to use the default reason. + StatusReasonPhrase, - /// Technical details shown in TRACE logs for the , or null to omit it. - StatusReasonDetails - } + /// Technical details shown in TRACE logs for the , or null to omit it. + StatusReasonDetails } diff --git a/src/SMAPI.Toolkit/Framework/ModData/ModDataModel.cs b/src/SMAPI.Toolkit/Framework/ModData/ModDataModel.cs index d21e87643..3ed173c41 100644 --- a/src/SMAPI.Toolkit/Framework/ModData/ModDataModel.cs +++ b/src/SMAPI.Toolkit/Framework/ModData/ModDataModel.cs @@ -5,130 +5,129 @@ using Newtonsoft.Json; using Newtonsoft.Json.Linq; -namespace StardewModdingAPI.Toolkit.Framework.ModData +namespace StardewModdingAPI.Toolkit.Framework.ModData; + +/// The raw mod metadata from SMAPI's internal mod list. +internal class ModDataModel { - /// The raw mod metadata from SMAPI's internal mod list. - internal class ModDataModel + /********* + ** Accessors + *********/ + /// The mod's current unique ID. + public string ID { get; } + + /// The former mod IDs (if any). + /// + /// This uses a custom format which uniquely identifies a mod across multiple versions and + /// supports matching other fields if no ID was specified. This doesn't include the latest + /// ID, if any. If the mod's ID changed over time, multiple variants can be separated by the + /// | character. + /// + public string? FormerIDs { get; } + + /// The mod warnings to suppress, even if they'd normally be shown. + public ModWarning SuppressWarnings { get; } + + /// Whether to ignore dependencies on this mod ID when it's not loaded. + public bool IgnoreDependencies { get; set; } + + /// This field stores properties that aren't mapped to another field before they're parsed into . + [JsonExtensionData] + public IDictionary ExtensionData { get; } = new Dictionary(); + + /// The versioned field data. + /// + /// This maps field names to values. This should be accessed via . + /// Format notes: + /// - Each key consists of a field name prefixed with any combination of version range + /// and Default, separated by pipes (whitespace trimmed). For example, Name + /// will always override the name, Default | Name will only override a blank + /// name, and ~1.1 | Default | Name will override blank names up to version 1.1. + /// - The version format is min~max (where either side can be blank for unbounded), or + /// a single version number. + /// - The field name itself corresponds to a value. + /// + public IDictionary Fields { get; set; } = new Dictionary(); + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The mod's current unique ID. + /// The former mod IDs (if any). + /// The mod warnings to suppress, even if they'd normally be shown. + /// Whether to ignore dependencies on this mod ID when it's not loaded. + public ModDataModel(string id, string? formerIds, ModWarning suppressWarnings, bool ignoreDependencies) { - /********* - ** Accessors - *********/ - /// The mod's current unique ID. - public string ID { get; } - - /// The former mod IDs (if any). - /// - /// This uses a custom format which uniquely identifies a mod across multiple versions and - /// supports matching other fields if no ID was specified. This doesn't include the latest - /// ID, if any. If the mod's ID changed over time, multiple variants can be separated by the - /// | character. - /// - public string? FormerIDs { get; } - - /// The mod warnings to suppress, even if they'd normally be shown. - public ModWarning SuppressWarnings { get; } - - /// Whether to ignore dependencies on this mod ID when it's not loaded. - public bool IgnoreDependencies { get; set; } - - /// This field stores properties that aren't mapped to another field before they're parsed into . - [JsonExtensionData] - public IDictionary ExtensionData { get; } = new Dictionary(); - - /// The versioned field data. - /// - /// This maps field names to values. This should be accessed via . - /// Format notes: - /// - Each key consists of a field name prefixed with any combination of version range - /// and Default, separated by pipes (whitespace trimmed). For example, Name - /// will always override the name, Default | Name will only override a blank - /// name, and ~1.1 | Default | Name will override blank names up to version 1.1. - /// - The version format is min~max (where either side can be blank for unbounded), or - /// a single version number. - /// - The field name itself corresponds to a value. - /// - public IDictionary Fields { get; set; } = new Dictionary(); - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The mod's current unique ID. - /// The former mod IDs (if any). - /// The mod warnings to suppress, even if they'd normally be shown. - /// Whether to ignore dependencies on this mod ID when it's not loaded. - public ModDataModel(string id, string? formerIds, ModWarning suppressWarnings, bool ignoreDependencies) - { - this.ID = id; - this.FormerIDs = formerIds; - this.SuppressWarnings = suppressWarnings; - this.IgnoreDependencies = ignoreDependencies; - } + this.ID = id; + this.FormerIDs = formerIds; + this.SuppressWarnings = suppressWarnings; + this.IgnoreDependencies = ignoreDependencies; + } - /// Get a parsed representation of the . - public IEnumerable GetFields() + /// Get a parsed representation of the . + public IEnumerable GetFields() + { + foreach (KeyValuePair pair in this.Fields) { - foreach (KeyValuePair pair in this.Fields) + // init fields + string packedKey = pair.Key; + string value = pair.Value; + bool isDefault = false; + ISemanticVersion? lowerVersion = null; + ISemanticVersion? upperVersion = null; + + // parse + string[] parts = packedKey.Split('|').Select(p => p.Trim()).ToArray(); + ModDataFieldKey fieldKey = (ModDataFieldKey)Enum.Parse(typeof(ModDataFieldKey), parts.Last(), ignoreCase: true); + foreach (string part in parts.Take(parts.Length - 1)) { - // init fields - string packedKey = pair.Key; - string value = pair.Value; - bool isDefault = false; - ISemanticVersion? lowerVersion = null; - ISemanticVersion? upperVersion = null; - - // parse - string[] parts = packedKey.Split('|').Select(p => p.Trim()).ToArray(); - ModDataFieldKey fieldKey = (ModDataFieldKey)Enum.Parse(typeof(ModDataFieldKey), parts.Last(), ignoreCase: true); - foreach (string part in parts.Take(parts.Length - 1)) + // 'default' + if (part.Equals("Default", StringComparison.OrdinalIgnoreCase)) { - // 'default' - if (part.Equals("Default", StringComparison.OrdinalIgnoreCase)) - { - isDefault = true; - continue; - } - - // version range - if (part.Contains("~")) - { - string[] versionParts = part.Split(new[] { '~' }, 2); - lowerVersion = versionParts[0] != "" ? new SemanticVersion(versionParts[0]) : null; - upperVersion = versionParts[1] != "" ? new SemanticVersion(versionParts[1]) : null; - continue; - } - - // single version - lowerVersion = new SemanticVersion(part); - upperVersion = new SemanticVersion(part); + isDefault = true; + continue; } - yield return new ModDataField(fieldKey, value, isDefault, lowerVersion, upperVersion); + // version range + if (part.Contains("~")) + { + string[] versionParts = part.Split(new[] { '~' }, 2); + lowerVersion = versionParts[0] != "" ? new SemanticVersion(versionParts[0]) : null; + upperVersion = versionParts[1] != "" ? new SemanticVersion(versionParts[1]) : null; + continue; + } + + // single version + lowerVersion = new SemanticVersion(part); + upperVersion = new SemanticVersion(part); } + + yield return new ModDataField(fieldKey, value, isDefault, lowerVersion, upperVersion); } + } - /// Get the former mod IDs. - public IEnumerable GetFormerIDs() + /// Get the former mod IDs. + public IEnumerable GetFormerIDs() + { + if (this.FormerIDs != null) { - if (this.FormerIDs != null) - { - foreach (string id in this.FormerIDs.Split('|')) - yield return id.Trim(); - } + foreach (string id in this.FormerIDs.Split('|')) + yield return id.Trim(); } + } - /********* - ** Private methods - *********/ - /// The method invoked after JSON deserialization. - /// The deserialization context. - [OnDeserialized] - private void OnDeserialized(StreamingContext context) - { - this.Fields = this.ExtensionData.ToDictionary(p => p.Key, p => p.Value.ToString()); - this.ExtensionData.Clear(); - } + /********* + ** Private methods + *********/ + /// The method invoked after JSON deserialization. + /// The deserialization context. + [OnDeserialized] + private void OnDeserialized(StreamingContext context) + { + this.Fields = this.ExtensionData.ToDictionary(p => p.Key, p => p.Value.ToString()); + this.ExtensionData.Clear(); } } diff --git a/src/SMAPI.Toolkit/Framework/ModData/ModDataRecord.cs b/src/SMAPI.Toolkit/Framework/ModData/ModDataRecord.cs index 938e9e5a6..1ebc867e0 100644 --- a/src/SMAPI.Toolkit/Framework/ModData/ModDataRecord.cs +++ b/src/SMAPI.Toolkit/Framework/ModData/ModDataRecord.cs @@ -2,119 +2,118 @@ using System.Collections.Generic; using System.Linq; -namespace StardewModdingAPI.Toolkit.Framework.ModData +namespace StardewModdingAPI.Toolkit.Framework.ModData; + +/// The parsed mod metadata from SMAPI's internal mod list. +public class ModDataRecord { - /// The parsed mod metadata from SMAPI's internal mod list. - public class ModDataRecord - { - /********* - ** Accessors - *********/ - /// The mod's default display name. - public string DisplayName { get; } + /********* + ** Accessors + *********/ + /// The mod's default display name. + public string DisplayName { get; } - /// The mod's current unique ID. - public string ID { get; } + /// The mod's current unique ID. + public string ID { get; } - /// The former mod IDs (if any). - public string[] FormerIDs { get; } + /// The former mod IDs (if any). + public string[] FormerIDs { get; } - /// The mod warnings to suppress, even if they'd normally be shown. - public ModWarning SuppressWarnings { get; } + /// The mod warnings to suppress, even if they'd normally be shown. + public ModWarning SuppressWarnings { get; } - /// Whether to ignore dependencies on this mod ID when it's not loaded. - public bool IgnoreDependencies { get; set; } + /// Whether to ignore dependencies on this mod ID when it's not loaded. + public bool IgnoreDependencies { get; set; } - /// The versioned field data. - public ModDataField[] Fields { get; } + /// The versioned field data. + public ModDataField[] Fields { get; } - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The mod's default display name. - /// The raw data model. - internal ModDataRecord(string displayName, ModDataModel model) - { - this.DisplayName = displayName; - this.ID = model.ID; - this.FormerIDs = model.GetFormerIDs().ToArray(); - this.SuppressWarnings = model.SuppressWarnings; - this.IgnoreDependencies = model.IgnoreDependencies; - this.Fields = model.GetFields().ToArray(); - } + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The mod's default display name. + /// The raw data model. + internal ModDataRecord(string displayName, ModDataModel model) + { + this.DisplayName = displayName; + this.ID = model.ID; + this.FormerIDs = model.GetFormerIDs().ToArray(); + this.SuppressWarnings = model.SuppressWarnings; + this.IgnoreDependencies = model.IgnoreDependencies; + this.Fields = model.GetFields().ToArray(); + } - /// Get whether the mod has (or previously had) the given ID. - /// The mod ID. - public bool HasID(string id) + /// Get whether the mod has (or previously had) the given ID. + /// The mod ID. + public bool HasID(string id) + { + // try main ID + if (this.ID.Equals(id, StringComparison.OrdinalIgnoreCase)) + return true; + + // try former IDs + foreach (string formerID in this.FormerIDs) { - // try main ID - if (this.ID.Equals(id, StringComparison.OrdinalIgnoreCase)) + if (formerID.Equals(id, StringComparison.OrdinalIgnoreCase)) return true; - - // try former IDs - foreach (string formerID in this.FormerIDs) - { - if (formerID.Equals(id, StringComparison.OrdinalIgnoreCase)) - return true; - } - - return false; } - /// Get the possible mod IDs. - public IEnumerable GetIDs() - { - return this.FormerIDs - .Concat(new[] { this.ID }) - .Where(p => !string.IsNullOrWhiteSpace(p)) - .Select(p => p.Trim()) - .Distinct(); - } + return false; + } - /// Get the default update key for this mod, if any. - public string? GetDefaultUpdateKey() - { - string? updateKey = this.Fields.FirstOrDefault(p => p.Key == ModDataFieldKey.UpdateKey && p.IsDefault)?.Value; - return !string.IsNullOrWhiteSpace(updateKey) - ? updateKey - : null; - } + /// Get the possible mod IDs. + public IEnumerable GetIDs() + { + return this.FormerIDs + .Concat(new[] { this.ID }) + .Where(p => !string.IsNullOrWhiteSpace(p)) + .Select(p => p.Trim()) + .Distinct(); + } - /// Get a parsed representation of the which match a given manifest. - /// The manifest to match. - public ModDataRecordVersionedFields GetVersionedFields(IManifest? manifest) + /// Get the default update key for this mod, if any. + public string? GetDefaultUpdateKey() + { + string? updateKey = this.Fields.FirstOrDefault(p => p.Key == ModDataFieldKey.UpdateKey && p.IsDefault)?.Value; + return !string.IsNullOrWhiteSpace(updateKey) + ? updateKey + : null; + } + + /// Get a parsed representation of the which match a given manifest. + /// The manifest to match. + public ModDataRecordVersionedFields GetVersionedFields(IManifest? manifest) + { + ModDataRecordVersionedFields parsed = new(this); + foreach (ModDataField field in this.Fields.Where(field => field.IsMatch(manifest))) { - ModDataRecordVersionedFields parsed = new(this); - foreach (ModDataField field in this.Fields.Where(field => field.IsMatch(manifest))) + switch (field.Key) { - switch (field.Key) - { - // update key - case ModDataFieldKey.UpdateKey: - parsed.UpdateKey = field.Value; - break; - - // status - case ModDataFieldKey.Status: - parsed.Status = (ModStatus)Enum.Parse(typeof(ModStatus), field.Value, ignoreCase: true); - parsed.StatusUpperVersion = field.UpperVersion; - break; - - // status reason phrase - case ModDataFieldKey.StatusReasonPhrase: - parsed.StatusReasonPhrase = field.Value; - break; - - // status technical reason - case ModDataFieldKey.StatusReasonDetails: - parsed.StatusReasonDetails = field.Value; - break; - } + // update key + case ModDataFieldKey.UpdateKey: + parsed.UpdateKey = field.Value; + break; + + // status + case ModDataFieldKey.Status: + parsed.Status = (ModStatus)Enum.Parse(typeof(ModStatus), field.Value, ignoreCase: true); + parsed.StatusUpperVersion = field.UpperVersion; + break; + + // status reason phrase + case ModDataFieldKey.StatusReasonPhrase: + parsed.StatusReasonPhrase = field.Value; + break; + + // status technical reason + case ModDataFieldKey.StatusReasonDetails: + parsed.StatusReasonDetails = field.Value; + break; } - - return parsed; } + + return parsed; } } diff --git a/src/SMAPI.Toolkit/Framework/ModData/ModDataRecordVersionedFields.cs b/src/SMAPI.Toolkit/Framework/ModData/ModDataRecordVersionedFields.cs index 65fa424ec..2705d08a0 100644 --- a/src/SMAPI.Toolkit/Framework/ModData/ModDataRecordVersionedFields.cs +++ b/src/SMAPI.Toolkit/Framework/ModData/ModDataRecordVersionedFields.cs @@ -1,38 +1,37 @@ -namespace StardewModdingAPI.Toolkit.Framework.ModData +namespace StardewModdingAPI.Toolkit.Framework.ModData; + +/// The versioned fields from a for a specific manifest. +public class ModDataRecordVersionedFields { - /// The versioned fields from a for a specific manifest. - public class ModDataRecordVersionedFields - { - /********* - ** Accessors - *********/ - /// The underlying data record. - public ModDataRecord DataRecord { get; } + /********* + ** Accessors + *********/ + /// The underlying data record. + public ModDataRecord DataRecord { get; } - /// The update key to apply (if any). - public string? UpdateKey { get; set; } + /// The update key to apply (if any). + public string? UpdateKey { get; set; } - /// The predefined compatibility status. - public ModStatus Status { get; set; } = ModStatus.None; + /// The predefined compatibility status. + public ModStatus Status { get; set; } = ModStatus.None; - /// A reason phrase for the , or null to use the default reason. - public string? StatusReasonPhrase { get; set; } + /// A reason phrase for the , or null to use the default reason. + public string? StatusReasonPhrase { get; set; } - /// Technical details shown in TRACE logs for the , or null to omit it. - public string? StatusReasonDetails { get; set; } + /// Technical details shown in TRACE logs for the , or null to omit it. + public string? StatusReasonDetails { get; set; } - /// The upper version for which the applies (if any). - public ISemanticVersion? StatusUpperVersion { get; set; } + /// The upper version for which the applies (if any). + public ISemanticVersion? StatusUpperVersion { get; set; } - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The underlying data record. - public ModDataRecordVersionedFields(ModDataRecord dataRecord) - { - this.DataRecord = dataRecord; - } + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The underlying data record. + public ModDataRecordVersionedFields(ModDataRecord dataRecord) + { + this.DataRecord = dataRecord; } } diff --git a/src/SMAPI.Toolkit/Framework/ModData/ModDatabase.cs b/src/SMAPI.Toolkit/Framework/ModData/ModDatabase.cs index 168b8aac1..78b8cdbb0 100644 --- a/src/SMAPI.Toolkit/Framework/ModData/ModDatabase.cs +++ b/src/SMAPI.Toolkit/Framework/ModData/ModDatabase.cs @@ -2,64 +2,63 @@ using System.Collections.Generic; using System.Linq; -namespace StardewModdingAPI.Toolkit.Framework.ModData +namespace StardewModdingAPI.Toolkit.Framework.ModData; + +/// Handles access to SMAPI's internal mod metadata list. +public class ModDatabase { - /// Handles access to SMAPI's internal mod metadata list. - public class ModDatabase - { - /********* - ** Fields - *********/ - /// The underlying mod data records indexed by default display name. - private readonly ModDataRecord[] Records; + /********* + ** Fields + *********/ + /// The underlying mod data records indexed by default display name. + private readonly ModDataRecord[] Records; - /// Get an update URL for an update key (if valid). - private readonly Func GetUpdateUrl; + /// Get an update URL for an update key (if valid). + private readonly Func GetUpdateUrl; - /********* - ** Public methods - *********/ - /// Construct an empty instance. - public ModDatabase() + /********* + ** Public methods + *********/ + /// Construct an empty instance. + public ModDatabase() : this(Array.Empty(), _ => null) { } - /// Construct an instance. - /// The underlying mod data records indexed by default display name. - /// Get an update URL for an update key (if valid). - public ModDatabase(IEnumerable records, Func getUpdateUrl) - { - this.Records = records.ToArray(); - this.GetUpdateUrl = getUpdateUrl; - } + /// Construct an instance. + /// The underlying mod data records indexed by default display name. + /// Get an update URL for an update key (if valid). + public ModDatabase(IEnumerable records, Func getUpdateUrl) + { + this.Records = records.ToArray(); + this.GetUpdateUrl = getUpdateUrl; + } - /// Get all mod data records. - public IEnumerable GetAll() - { - return this.Records; - } + /// Get all mod data records. + public IEnumerable GetAll() + { + return this.Records; + } - /// Get a mod data record. - /// The unique mod ID. - public ModDataRecord? Get(string? modID) - { - return !string.IsNullOrWhiteSpace(modID) - ? this.Records.FirstOrDefault(p => p.HasID(modID)) - : null; - } + /// Get a mod data record. + /// The unique mod ID. + public ModDataRecord? Get(string? modID) + { + return !string.IsNullOrWhiteSpace(modID) + ? this.Records.FirstOrDefault(p => p.HasID(modID)) + : null; + } - /// Get the mod page URL for a mod (if available). - /// The unique mod ID. - public string? GetModPageUrlFor(string? id) - { - // get update key - ModDataRecord? record = this.Get(id); - ModDataField? updateKeyField = record?.Fields.FirstOrDefault(p => p.Key == ModDataFieldKey.UpdateKey); - if (updateKeyField == null) - return null; + /// Get the mod page URL for a mod (if available). + /// The unique mod ID. + public string? GetModPageUrlFor(string? id) + { + // get update key + ModDataRecord? record = this.Get(id); + ModDataField? updateKeyField = record?.Fields.FirstOrDefault(p => p.Key == ModDataFieldKey.UpdateKey); + if (updateKeyField == null) + return null; - // get update URL - return this.GetUpdateUrl(updateKeyField.Value); - } + // get update URL + return this.GetUpdateUrl(updateKeyField.Value); } } diff --git a/src/SMAPI.Toolkit/Framework/ModData/ModStatus.cs b/src/SMAPI.Toolkit/Framework/ModData/ModStatus.cs index 09da74bff..a7cbf27ac 100644 --- a/src/SMAPI.Toolkit/Framework/ModData/ModStatus.cs +++ b/src/SMAPI.Toolkit/Framework/ModData/ModStatus.cs @@ -1,18 +1,17 @@ -namespace StardewModdingAPI.Toolkit.Framework.ModData +namespace StardewModdingAPI.Toolkit.Framework.ModData; + +/// Indicates how SMAPI should treat a mod. +public enum ModStatus { - /// Indicates how SMAPI should treat a mod. - public enum ModStatus - { - /// Don't override the status. - None, + /// Don't override the status. + None, - /// The mod is obsolete and shouldn't be used, regardless of version. - Obsolete, + /// The mod is obsolete and shouldn't be used, regardless of version. + Obsolete, - /// Assume the mod is not compatible, even if SMAPI doesn't detect any incompatible code. - AssumeBroken, + /// Assume the mod is not compatible, even if SMAPI doesn't detect any incompatible code. + AssumeBroken, - /// Assume the mod is compatible, even if SMAPI detects incompatible code. - AssumeCompatible - } + /// Assume the mod is compatible, even if SMAPI detects incompatible code. + AssumeCompatible } diff --git a/src/SMAPI.Toolkit/Framework/ModData/ModWarning.cs b/src/SMAPI.Toolkit/Framework/ModData/ModWarning.cs index b3082fa2c..577f0082c 100644 --- a/src/SMAPI.Toolkit/Framework/ModData/ModWarning.cs +++ b/src/SMAPI.Toolkit/Framework/ModData/ModWarning.cs @@ -1,36 +1,35 @@ using System; -namespace StardewModdingAPI.Toolkit.Framework.ModData +namespace StardewModdingAPI.Toolkit.Framework.ModData; + +/// Indicates a detected non-error mod issue. +[Flags] +public enum ModWarning { - /// Indicates a detected non-error mod issue. - [Flags] - public enum ModWarning - { - /// No issues detected. - None = 0, + /// No issues detected. + None = 0, - /// SMAPI detected incompatible code in the mod, but was configured to load it anyway. - BrokenCodeLoaded = 1, + /// SMAPI detected incompatible code in the mod, but was configured to load it anyway. + BrokenCodeLoaded = 1, - /// The mod affects the save serializer in a way that may make saves unloadable without the mod. - ChangesSaveSerializer = 2, + /// The mod affects the save serializer in a way that may make saves unloadable without the mod. + ChangesSaveSerializer = 2, - /// The mod patches the game in a way that may impact stability. - PatchesGame = 4, + /// The mod patches the game in a way that may impact stability. + PatchesGame = 4, - /// The mod references specialized 'unvalidated update tick' events which may impact stability. - UsesUnvalidatedUpdateTick = 8, + /// The mod references specialized 'unvalidated update tick' events which may impact stability. + UsesUnvalidatedUpdateTick = 8, - /// The mod has no update keys set. - NoUpdateKeys = 16, + /// The mod has no update keys set. + NoUpdateKeys = 16, - /// Uses .NET APIs for reading and writing to the console. - AccessesConsole = 32, + /// Uses .NET APIs for reading and writing to the console. + AccessesConsole = 32, - /// Uses .NET APIs for filesystem access. - AccessesFilesystem = 64, + /// Uses .NET APIs for filesystem access. + AccessesFilesystem = 64, - /// Uses .NET APIs for shell or process access. - AccessesShell = 128 - } + /// Uses .NET APIs for shell or process access. + AccessesShell = 128 } diff --git a/src/SMAPI.Toolkit/Framework/ModScanning/ModFolder.cs b/src/SMAPI.Toolkit/Framework/ModScanning/ModFolder.cs index 106e3622c..f6f9b0e5d 100644 --- a/src/SMAPI.Toolkit/Framework/ModScanning/ModFolder.cs +++ b/src/SMAPI.Toolkit/Framework/ModScanning/ModFolder.cs @@ -5,104 +5,103 @@ using StardewModdingAPI.Toolkit.Serialization.Models; using StardewModdingAPI.Toolkit.Utilities; -namespace StardewModdingAPI.Toolkit.Framework.ModScanning +namespace StardewModdingAPI.Toolkit.Framework.ModScanning; + +/// The info about a mod read from its folder. +public class ModFolder { - /// The info about a mod read from its folder. - public class ModFolder + /********* + ** Fields + *********/ + /// The backing field for . + private DirectoryInfo? DirectoryImpl; + + + /********* + ** Accessors + *********/ + /// A suggested display name for the mod folder. + public string DisplayName { get; } + + /// The folder path containing the mod's manifest.json. + public string DirectoryPath { get; } + + /// The folder containing the mod's manifest.json. + [JsonIgnore] + public DirectoryInfo Directory => this.DirectoryImpl ??= new DirectoryInfo(this.DirectoryPath); + + /// The mod type. + public ModType Type { get; } + + /// The mod manifest. + public Manifest? Manifest { get; } + + /// The error which occurred parsing the manifest, if any. + public ModParseError ManifestParseError { get; set; } + + /// A human-readable message for the , if any. + public string? ManifestParseErrorText { get; set; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The root folder containing mods. + /// The folder containing the mod's manifest.json. + /// The mod type. + /// The mod manifest. + public ModFolder(DirectoryInfo root, DirectoryInfo directory, ModType type, Manifest manifest) + : this(root, directory, type, manifest, ModParseError.None, null) { } + + /// Construct an instance. + /// The root folder containing mods. + /// The folder containing the mod's manifest.json. + /// The mod type. + /// The mod manifest. + /// The error which occurred parsing the manifest, if any. + /// A human-readable message for the , if any. + public ModFolder(DirectoryInfo root, DirectoryInfo directory, ModType type, Manifest? manifest, ModParseError manifestParseError, string? manifestParseErrorText) + { + // save info + this.DirectoryImpl = directory; + this.DirectoryPath = directory.FullName; + this.Type = type; + this.Manifest = manifest; + this.ManifestParseError = manifestParseError; + this.ManifestParseErrorText = manifestParseErrorText; + + // set display name + this.DisplayName = !string.IsNullOrWhiteSpace(manifest?.Name) + ? manifest.Name + : PathUtilities.GetRelativePath(root.FullName, directory.FullName); + } + + /// Construct an instance. + /// A suggested display name for the mod folder. + /// The folder path containing the mod's manifest.json. + /// The mod type. + /// The mod manifest. + /// The error which occurred parsing the manifest, if any. + /// A human-readable message for the , if any. + [JsonConstructor] + public ModFolder(string displayName, string directoryPath, ModType type, Manifest? manifest, ModParseError manifestParseError, string? manifestParseErrorText) + { + this.DisplayName = displayName; + this.DirectoryPath = directoryPath; + this.Type = type; + this.Manifest = manifest; + this.ManifestParseError = manifestParseError; + this.ManifestParseErrorText = manifestParseErrorText; + } + + /// Get the update keys for a mod. + /// The mod manifest. + public IEnumerable GetUpdateKeys(Manifest manifest) { - /********* - ** Fields - *********/ - /// The backing field for . - private DirectoryInfo? DirectoryImpl; - - - /********* - ** Accessors - *********/ - /// A suggested display name for the mod folder. - public string DisplayName { get; } - - /// The folder path containing the mod's manifest.json. - public string DirectoryPath { get; } - - /// The folder containing the mod's manifest.json. - [JsonIgnore] - public DirectoryInfo Directory => this.DirectoryImpl ??= new DirectoryInfo(this.DirectoryPath); - - /// The mod type. - public ModType Type { get; } - - /// The mod manifest. - public Manifest? Manifest { get; } - - /// The error which occurred parsing the manifest, if any. - public ModParseError ManifestParseError { get; set; } - - /// A human-readable message for the , if any. - public string? ManifestParseErrorText { get; set; } - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The root folder containing mods. - /// The folder containing the mod's manifest.json. - /// The mod type. - /// The mod manifest. - public ModFolder(DirectoryInfo root, DirectoryInfo directory, ModType type, Manifest manifest) - : this(root, directory, type, manifest, ModParseError.None, null) { } - - /// Construct an instance. - /// The root folder containing mods. - /// The folder containing the mod's manifest.json. - /// The mod type. - /// The mod manifest. - /// The error which occurred parsing the manifest, if any. - /// A human-readable message for the , if any. - public ModFolder(DirectoryInfo root, DirectoryInfo directory, ModType type, Manifest? manifest, ModParseError manifestParseError, string? manifestParseErrorText) - { - // save info - this.DirectoryImpl = directory; - this.DirectoryPath = directory.FullName; - this.Type = type; - this.Manifest = manifest; - this.ManifestParseError = manifestParseError; - this.ManifestParseErrorText = manifestParseErrorText; - - // set display name - this.DisplayName = !string.IsNullOrWhiteSpace(manifest?.Name) - ? manifest.Name - : PathUtilities.GetRelativePath(root.FullName, directory.FullName); - } - - /// Construct an instance. - /// A suggested display name for the mod folder. - /// The folder path containing the mod's manifest.json. - /// The mod type. - /// The mod manifest. - /// The error which occurred parsing the manifest, if any. - /// A human-readable message for the , if any. - [JsonConstructor] - public ModFolder(string displayName, string directoryPath, ModType type, Manifest? manifest, ModParseError manifestParseError, string? manifestParseErrorText) - { - this.DisplayName = displayName; - this.DirectoryPath = directoryPath; - this.Type = type; - this.Manifest = manifest; - this.ManifestParseError = manifestParseError; - this.ManifestParseErrorText = manifestParseErrorText; - } - - /// Get the update keys for a mod. - /// The mod manifest. - public IEnumerable GetUpdateKeys(Manifest manifest) - { - return - manifest.UpdateKeys - .Where(p => !string.IsNullOrWhiteSpace(p)) - .ToArray(); - } + return + manifest.UpdateKeys + .Where(p => !string.IsNullOrWhiteSpace(p)) + .ToArray(); } } diff --git a/src/SMAPI.Toolkit/Framework/ModScanning/ModParseError.cs b/src/SMAPI.Toolkit/Framework/ModScanning/ModParseError.cs index f1e782b65..a4d206b79 100644 --- a/src/SMAPI.Toolkit/Framework/ModScanning/ModParseError.cs +++ b/src/SMAPI.Toolkit/Framework/ModScanning/ModParseError.cs @@ -1,27 +1,26 @@ -namespace StardewModdingAPI.Toolkit.Framework.ModScanning +namespace StardewModdingAPI.Toolkit.Framework.ModScanning; + +/// Indicates why a mod could not be parsed. +public enum ModParseError { - /// Indicates why a mod could not be parsed. - public enum ModParseError - { - /// No parse error. - None, + /// No parse error. + None, - /// The folder is empty or contains only ignored files. - EmptyFolder, + /// The folder is empty or contains only ignored files. + EmptyFolder, - /// The folder is an empty folder managed by Vortex. - EmptyVortexFolder, + /// The folder is an empty folder managed by Vortex. + EmptyVortexFolder, - /// The folder is ignored by convention. - IgnoredFolder, + /// The folder is ignored by convention. + IgnoredFolder, - /// The mod's manifest.json could not be parsed. - ManifestInvalid, + /// The mod's manifest.json could not be parsed. + ManifestInvalid, - /// The folder contains non-ignored and non-XNB files, but none of them are manifest.json. - ManifestMissing, + /// The folder contains non-ignored and non-XNB files, but none of them are manifest.json. + ManifestMissing, - /// The folder is an XNB mod, which can't be loaded through SMAPI. - XnbMod - } + /// The folder is an XNB mod, which can't be loaded through SMAPI. + XnbMod } diff --git a/src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs b/src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs index 5e9e3c357..38cf50549 100644 --- a/src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs +++ b/src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs @@ -7,362 +7,361 @@ using StardewModdingAPI.Toolkit.Serialization.Models; using StardewModdingAPI.Toolkit.Utilities.PathLookups; -namespace StardewModdingAPI.Toolkit.Framework.ModScanning +namespace StardewModdingAPI.Toolkit.Framework.ModScanning; + +/// Scans folders for mod data. +public class ModScanner { - /// Scans folders for mod data. - public class ModScanner + /********* + ** Fields + *********/ + /// The JSON helper with which to read manifests. + private readonly JsonHelper JsonHelper; + + /// A list of filesystem entry names to ignore when checking whether a folder should be treated as a mod. + private readonly HashSet IgnoreFilesystemNames = new() { - /********* - ** Fields - *********/ - /// The JSON helper with which to read manifests. - private readonly JsonHelper JsonHelper; - - /// A list of filesystem entry names to ignore when checking whether a folder should be treated as a mod. - private readonly HashSet IgnoreFilesystemNames = new() - { - new Regex(@"^__folder_managed_by_vortex$", RegexOptions.Compiled | RegexOptions.IgnoreCase), // Vortex mod manager - new Regex(@"(?:^\._|^\.DS_Store$|^__MACOSX$|^mcs$)", RegexOptions.Compiled | RegexOptions.IgnoreCase), // macOS - new Regex(@"^(?:desktop\.ini|Thumbs\.db)$", RegexOptions.Compiled | RegexOptions.IgnoreCase) // Windows - }; + new Regex(@"^__folder_managed_by_vortex$", RegexOptions.Compiled | RegexOptions.IgnoreCase), // Vortex mod manager + new Regex(@"(?:^\._|^\.DS_Store$|^__MACOSX$|^mcs$)", RegexOptions.Compiled | RegexOptions.IgnoreCase), // macOS + new Regex(@"^(?:desktop\.ini|Thumbs\.db)$", RegexOptions.Compiled | RegexOptions.IgnoreCase) // Windows + }; - /// A list of file extensions to ignore when searching for mod files. - private readonly HashSet IgnoreFileExtensions = new(StringComparer.OrdinalIgnoreCase) - { - // text - ".doc", - ".docx", - ".md", - ".rtf", - ".txt", - - // images - ".bmp", - ".gif", - ".ico", - ".jpeg", - ".jpg", - ".png", - ".psd", - ".tif", - ".xcf", // gimp files - - // archives - ".rar", - ".zip", - ".7z", - ".tar", - ".tar.gz", - - // backup files - ".backup", - ".bak", - ".old", - - // Windows shortcut files - ".url", - ".lnk" - }; - - /// The extensions for packed content files. - private readonly HashSet StrictXnbModExtensions = new(StringComparer.OrdinalIgnoreCase) - { - ".xgs", - ".xnb", - ".xsb", - ".xwb" - }; - - /// The extensions for files which an XNB mod may contain, in addition to . - private readonly HashSet PotentialXnbModExtensions = new(StringComparer.OrdinalIgnoreCase) - { - ".json", - ".yaml" - }; + /// A list of file extensions to ignore when searching for mod files. + private readonly HashSet IgnoreFileExtensions = new(StringComparer.OrdinalIgnoreCase) + { + // text + ".doc", + ".docx", + ".md", + ".rtf", + ".txt", + + // images + ".bmp", + ".gif", + ".ico", + ".jpeg", + ".jpg", + ".png", + ".psd", + ".tif", + ".xcf", // gimp files + + // archives + ".rar", + ".zip", + ".7z", + ".tar", + ".tar.gz", + + // backup files + ".backup", + ".bak", + ".old", + + // Windows shortcut files + ".url", + ".lnk" + }; + + /// The extensions for packed content files. + private readonly HashSet StrictXnbModExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".xgs", + ".xnb", + ".xsb", + ".xwb" + }; + + /// The extensions for files which an XNB mod may contain, in addition to . + private readonly HashSet PotentialXnbModExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".json", + ".yaml" + }; - /// The name of the marker file added by Vortex to indicate it's managing the folder. - private readonly string VortexMarkerFileName = "__folder_managed_by_vortex"; + /// The name of the marker file added by Vortex to indicate it's managing the folder. + private readonly string VortexMarkerFileName = "__folder_managed_by_vortex"; - /// The name for a mod's configuration JSON file. - private readonly string ConfigFileName = "config.json"; + /// The name for a mod's configuration JSON file. + private readonly string ConfigFileName = "config.json"; - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The JSON helper with which to read manifests. - public ModScanner(JsonHelper jsonHelper) - { - this.JsonHelper = jsonHelper; - } + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The JSON helper with which to read manifests. + public ModScanner(JsonHelper jsonHelper) + { + this.JsonHelper = jsonHelper; + } - /// Extract information about all mods in the given folder. - /// The root folder containing mods. - /// Whether to match file paths case-insensitively, even on Linux. - public IEnumerable GetModFolders(string rootPath, bool useCaseInsensitiveFilePaths) - { - DirectoryInfo root = new(rootPath); - return this.GetModFolders(root, root, useCaseInsensitiveFilePaths); - } + /// Extract information about all mods in the given folder. + /// The root folder containing mods. + /// Whether to match file paths case-insensitively, even on Linux. + public IEnumerable GetModFolders(string rootPath, bool useCaseInsensitiveFilePaths) + { + DirectoryInfo root = new(rootPath); + return this.GetModFolders(root, root, useCaseInsensitiveFilePaths); + } - /// Extract information about all mods in the given folder. - /// The root folder containing mods. Only the will be searched, but this field allows it to be treated as a potential mod folder of its own. - /// The mod path to search. - /// Whether to match file paths case-insensitively, even on Linux. - public IEnumerable GetModFolders(string rootPath, string modPath, bool useCaseInsensitiveFilePaths) - { - return this.GetModFolders(root: new DirectoryInfo(rootPath), folder: new DirectoryInfo(modPath), useCaseInsensitiveFilePaths: useCaseInsensitiveFilePaths); - } + /// Extract information about all mods in the given folder. + /// The root folder containing mods. Only the will be searched, but this field allows it to be treated as a potential mod folder of its own. + /// The mod path to search. + /// Whether to match file paths case-insensitively, even on Linux. + public IEnumerable GetModFolders(string rootPath, string modPath, bool useCaseInsensitiveFilePaths) + { + return this.GetModFolders(root: new DirectoryInfo(rootPath), folder: new DirectoryInfo(modPath), useCaseInsensitiveFilePaths: useCaseInsensitiveFilePaths); + } - /// Extract information from a mod folder. - /// The root folder containing mods. - /// The folder to search for a mod. - /// Whether to match file paths case-insensitively, even on Linux. - public ModFolder ReadFolder(DirectoryInfo root, DirectoryInfo searchFolder, bool useCaseInsensitiveFilePaths) - { - // find manifest.json - FileInfo? manifestFile = this.FindManifest(searchFolder, useCaseInsensitiveFilePaths); + /// Extract information from a mod folder. + /// The root folder containing mods. + /// The folder to search for a mod. + /// Whether to match file paths case-insensitively, even on Linux. + public ModFolder ReadFolder(DirectoryInfo root, DirectoryInfo searchFolder, bool useCaseInsensitiveFilePaths) + { + // find manifest.json + FileInfo? manifestFile = this.FindManifest(searchFolder, useCaseInsensitiveFilePaths); - // set appropriate invalid-mod error - if (manifestFile == null) - { - FileInfo[] files = this.RecursivelyGetFiles(searchFolder).ToArray(); - FileInfo[] relevantFiles = files.Where(this.IsRelevant).ToArray(); + // set appropriate invalid-mod error + if (manifestFile == null) + { + FileInfo[] files = this.RecursivelyGetFiles(searchFolder).ToArray(); + FileInfo[] relevantFiles = files.Where(this.IsRelevant).ToArray(); - // empty Vortex folder - // (this filters relevant files internally so it can check for the normally-ignored Vortex marker file) - if (this.IsEmptyVortexFolder(files)) - return new ModFolder(root, searchFolder, ModType.Invalid, null, ModParseError.EmptyVortexFolder, "it's an empty Vortex folder (is the mod disabled in Vortex?)."); + // empty Vortex folder + // (this filters relevant files internally so it can check for the normally-ignored Vortex marker file) + if (this.IsEmptyVortexFolder(files)) + return new ModFolder(root, searchFolder, ModType.Invalid, null, ModParseError.EmptyVortexFolder, "it's an empty Vortex folder (is the mod disabled in Vortex?)."); - // empty folder - if (!relevantFiles.Any()) - return new ModFolder(root, searchFolder, ModType.Invalid, null, ModParseError.EmptyFolder, "it's an empty folder."); + // empty folder + if (!relevantFiles.Any()) + return new ModFolder(root, searchFolder, ModType.Invalid, null, ModParseError.EmptyFolder, "it's an empty folder."); - // XNB mod - if (this.IsXnbMod(relevantFiles)) - return new ModFolder(root, searchFolder, ModType.Xnb, null, ModParseError.XnbMod, "it's not a SMAPI mod (see https://smapi.io/xnb for info)."); + // XNB mod + if (this.IsXnbMod(relevantFiles)) + return new ModFolder(root, searchFolder, ModType.Xnb, null, ModParseError.XnbMod, "it's not a SMAPI mod (see https://smapi.io/xnb for info)."); - // SMAPI installer - if (relevantFiles.Any(p => p.Name is "install on Linux.sh" or "install on macOS.command" or "install on Windows.bat")) - return new ModFolder(root, searchFolder, ModType.Invalid, null, ModParseError.ManifestMissing, "the SMAPI installer isn't a mod (you can delete this folder after running the installer file)."); + // SMAPI installer + if (relevantFiles.Any(p => p.Name is "install on Linux.sh" or "install on macOS.command" or "install on Windows.bat")) + return new ModFolder(root, searchFolder, ModType.Invalid, null, ModParseError.ManifestMissing, "the SMAPI installer isn't a mod (you can delete this folder after running the installer file)."); - // not a mod? - return new ModFolder(root, searchFolder, ModType.Invalid, null, ModParseError.ManifestMissing, "it contains files, but none of them are manifest.json."); - } + // not a mod? + return new ModFolder(root, searchFolder, ModType.Invalid, null, ModParseError.ManifestMissing, "it contains files, but none of them are manifest.json."); + } - // read mod info - Manifest? manifest = null; - ModParseError error = ModParseError.None; - string? errorText = null; + // read mod info + Manifest? manifest = null; + ModParseError error = ModParseError.None; + string? errorText = null; + { + try { - try - { - if (!this.JsonHelper.ReadJsonFileIfExists(manifestFile.FullName, out manifest)) - { - error = ModParseError.ManifestInvalid; - errorText = "its manifest is invalid."; - } - } - catch (SParseException ex) - { - error = ModParseError.ManifestInvalid; - errorText = $"parsing its manifest failed: {ex.Message}"; - } - catch (Exception ex) + if (!this.JsonHelper.ReadJsonFileIfExists(manifestFile.FullName, out manifest)) { error = ModParseError.ManifestInvalid; - errorText = $"parsing its manifest failed:\n{ex}"; + errorText = "its manifest is invalid."; } } - - // get mod type - ModType type; + catch (SParseException ex) { - bool isContentPack = !string.IsNullOrWhiteSpace(manifest?.ContentPackFor?.UniqueID); - bool isSmapi = !string.IsNullOrWhiteSpace(manifest?.EntryDll); - - if (isContentPack == isSmapi) - type = ModType.Invalid; - else if (isContentPack) - type = ModType.ContentPack; - else - type = ModType.Smapi; + error = ModParseError.ManifestInvalid; + errorText = $"parsing its manifest failed: {ex.Message}"; } - - // build result - return new ModFolder(root, manifestFile.Directory!, type, manifest, error, errorText); - } - - - /********* - ** Private methods - *********/ - /// Recursively extract information about all mods in the given folder. - /// The root mod folder. - /// The folder to search for mods. - /// Whether to match file paths case-insensitively, even on Linux. - private IEnumerable GetModFolders(DirectoryInfo root, DirectoryInfo folder, bool useCaseInsensitiveFilePaths) - { - bool isRoot = folder.FullName == root.FullName; - - // skip - if (!isRoot) + catch (Exception ex) { - if (folder.Name.StartsWith(".")) - { - yield return new ModFolder(root, folder, ModType.Ignored, null, ModParseError.IgnoredFolder, "ignored folder because its name starts with a dot."); - yield break; - } - if (!this.IsRelevant(folder)) - yield break; + error = ModParseError.ManifestInvalid; + errorText = $"parsing its manifest failed:\n{ex}"; } + } - // find mods in subfolders - if (this.IsModSearchFolder(root, folder)) - { - IEnumerable subfolders = folder.EnumerateDirectories().SelectMany(sub => this.GetModFolders(root, sub, useCaseInsensitiveFilePaths)); - if (!isRoot) - subfolders = this.TryConsolidate(root, folder, subfolders.ToArray()); - foreach (ModFolder subfolder in subfolders) - yield return subfolder; - } + // get mod type + ModType type; + { + bool isContentPack = !string.IsNullOrWhiteSpace(manifest?.ContentPackFor?.UniqueID); + bool isSmapi = !string.IsNullOrWhiteSpace(manifest?.EntryDll); - // treat as mod folder + if (isContentPack == isSmapi) + type = ModType.Invalid; + else if (isContentPack) + type = ModType.ContentPack; else - yield return this.ReadFolder(root, folder, useCaseInsensitiveFilePaths); + type = ModType.Smapi; } - /// Consolidate adjacent folders into one mod folder, if possible. - /// The folder containing both parent and subfolders. - /// The parent folder to consolidate, if possible. - /// The subfolders to consolidate, if possible. - private IEnumerable TryConsolidate(DirectoryInfo root, DirectoryInfo parentFolder, ModFolder[] subfolders) - { - if (subfolders.Length > 1) - { - // a collection of empty folders - if (subfolders.All(p => p.ManifestParseError == ModParseError.EmptyFolder)) - return new[] { new ModFolder(root, parentFolder, ModType.Invalid, null, ModParseError.EmptyFolder, subfolders[0].ManifestParseErrorText) }; + // build result + return new ModFolder(root, manifestFile.Directory!, type, manifest, error, errorText); + } - // an XNB mod - if (subfolders.All(p => p.Type == ModType.Xnb || p.ManifestParseError == ModParseError.EmptyFolder)) - return new[] { new ModFolder(root, parentFolder, ModType.Xnb, null, ModParseError.XnbMod, subfolders[0].ManifestParseErrorText) }; - } - return subfolders; - } + /********* + ** Private methods + *********/ + /// Recursively extract information about all mods in the given folder. + /// The root mod folder. + /// The folder to search for mods. + /// Whether to match file paths case-insensitively, even on Linux. + private IEnumerable GetModFolders(DirectoryInfo root, DirectoryInfo folder, bool useCaseInsensitiveFilePaths) + { + bool isRoot = folder.FullName == root.FullName; - /// Find the manifest for a mod folder. - /// The folder to search. - /// Whether to match file paths case-insensitively, even on Linux. - private FileInfo? FindManifest(DirectoryInfo folder, bool useCaseInsensitiveFilePaths) + // skip + if (!isRoot) { - // check for conventional manifest in current folder - const string defaultName = "manifest.json"; - FileInfo file = new(Path.Combine(folder.FullName, defaultName)); - if (file.Exists) - return file; - - // check for manifest with incorrect capitalization - if (useCaseInsensitiveFilePaths) + if (folder.Name.StartsWith(".")) { - CaseInsensitiveFileLookup fileLookup = new(folder.FullName, SearchOption.TopDirectoryOnly); // don't use GetCachedFor, since we only need it temporarily - file = fileLookup.GetFile(defaultName); - return file.Exists - ? file - : null; + yield return new ModFolder(root, folder, ModType.Ignored, null, ModParseError.IgnoredFolder, "ignored folder because its name starts with a dot."); + yield break; } + if (!this.IsRelevant(folder)) + yield break; + } - // not found - return null; + // find mods in subfolders + if (this.IsModSearchFolder(root, folder)) + { + IEnumerable subfolders = folder.EnumerateDirectories().SelectMany(sub => this.GetModFolders(root, sub, useCaseInsensitiveFilePaths)); + if (!isRoot) + subfolders = this.TryConsolidate(root, folder, subfolders.ToArray()); + foreach (ModFolder subfolder in subfolders) + yield return subfolder; } - /// Get whether a given folder should be treated as a search folder (i.e. look for subfolders containing mods). - /// The root mod folder. - /// The folder to search for mods. - private bool IsModSearchFolder(DirectoryInfo root, DirectoryInfo folder) + // treat as mod folder + else + yield return this.ReadFolder(root, folder, useCaseInsensitiveFilePaths); + } + + /// Consolidate adjacent folders into one mod folder, if possible. + /// The folder containing both parent and subfolders. + /// The parent folder to consolidate, if possible. + /// The subfolders to consolidate, if possible. + private IEnumerable TryConsolidate(DirectoryInfo root, DirectoryInfo parentFolder, ModFolder[] subfolders) + { + if (subfolders.Length > 1) { - if (root.FullName == folder.FullName) - return true; + // a collection of empty folders + if (subfolders.All(p => p.ManifestParseError == ModParseError.EmptyFolder)) + return new[] { new ModFolder(root, parentFolder, ModType.Invalid, null, ModParseError.EmptyFolder, subfolders[0].ManifestParseErrorText) }; - DirectoryInfo[] subfolders = folder.GetDirectories().Where(this.IsRelevant).ToArray(); - FileInfo[] files = folder.GetFiles().Where(this.IsRelevant).ToArray(); - return subfolders.Any() && !files.Any(); + // an XNB mod + if (subfolders.All(p => p.Type == ModType.Xnb || p.ManifestParseError == ModParseError.EmptyFolder)) + return new[] { new ModFolder(root, parentFolder, ModType.Xnb, null, ModParseError.XnbMod, subfolders[0].ManifestParseErrorText) }; } - /// Recursively get all files in a folder. - /// The root folder to search. - private IEnumerable RecursivelyGetFiles(DirectoryInfo folder) + return subfolders; + } + + /// Find the manifest for a mod folder. + /// The folder to search. + /// Whether to match file paths case-insensitively, even on Linux. + private FileInfo? FindManifest(DirectoryInfo folder, bool useCaseInsensitiveFilePaths) + { + // check for conventional manifest in current folder + const string defaultName = "manifest.json"; + FileInfo file = new(Path.Combine(folder.FullName, defaultName)); + if (file.Exists) + return file; + + // check for manifest with incorrect capitalization + if (useCaseInsensitiveFilePaths) { - foreach (FileSystemInfo entry in folder.GetFileSystemInfos()) - { - if (entry is DirectoryInfo && !this.IsRelevant(entry)) - continue; + CaseInsensitiveFileLookup fileLookup = new(folder.FullName, SearchOption.TopDirectoryOnly); // don't use GetCachedFor, since we only need it temporarily + file = fileLookup.GetFile(defaultName); + return file.Exists + ? file + : null; + } - if (entry is FileInfo file) - yield return file; + // not found + return null; + } - if (entry is DirectoryInfo subfolder) - { - foreach (FileInfo subfolderFile in this.RecursivelyGetFiles(subfolder)) - yield return subfolderFile; - } - } - } + /// Get whether a given folder should be treated as a search folder (i.e. look for subfolders containing mods). + /// The root mod folder. + /// The folder to search for mods. + private bool IsModSearchFolder(DirectoryInfo root, DirectoryInfo folder) + { + if (root.FullName == folder.FullName) + return true; + + DirectoryInfo[] subfolders = folder.GetDirectories().Where(this.IsRelevant).ToArray(); + FileInfo[] files = folder.GetFiles().Where(this.IsRelevant).ToArray(); + return subfolders.Any() && !files.Any(); + } - /// Get whether a file or folder is relevant when deciding how to process a mod folder. - /// The file or folder. - private bool IsRelevant(FileSystemInfo entry) + /// Recursively get all files in a folder. + /// The root folder to search. + private IEnumerable RecursivelyGetFiles(DirectoryInfo folder) + { + foreach (FileSystemInfo entry in folder.GetFileSystemInfos()) { - // ignored file extensions and any files starting with "." - if ((entry is FileInfo file) && (this.IgnoreFileExtensions.Contains(file.Extension) || file.Name.StartsWith("."))) - return false; + if (entry is DirectoryInfo && !this.IsRelevant(entry)) + continue; + + if (entry is FileInfo file) + yield return file; - // ignored entry name - return !this.IgnoreFilesystemNames.Any(p => p.IsMatch(entry.Name)); + if (entry is DirectoryInfo subfolder) + { + foreach (FileInfo subfolderFile in this.RecursivelyGetFiles(subfolder)) + yield return subfolderFile; + } } + } - /// Get whether a set of files looks like an XNB mod. - /// The files in the mod. - private bool IsXnbMod(IEnumerable files) - { - bool hasXnbFile = false; + /// Get whether a file or folder is relevant when deciding how to process a mod folder. + /// The file or folder. + private bool IsRelevant(FileSystemInfo entry) + { + // ignored file extensions and any files starting with "." + if ((entry is FileInfo file) && (this.IgnoreFileExtensions.Contains(file.Extension) || file.Name.StartsWith("."))) + return false; - foreach (FileInfo file in files.Where(this.IsRelevant)) - { - if (this.StrictXnbModExtensions.Contains(file.Extension)) - { - hasXnbFile = true; - continue; - } + // ignored entry name + return !this.IgnoreFilesystemNames.Any(p => p.IsMatch(entry.Name)); + } - if (!this.PotentialXnbModExtensions.Contains(file.Extension)) - return false; + /// Get whether a set of files looks like an XNB mod. + /// The files in the mod. + private bool IsXnbMod(IEnumerable files) + { + bool hasXnbFile = false; + + foreach (FileInfo file in files.Where(this.IsRelevant)) + { + if (this.StrictXnbModExtensions.Contains(file.Extension)) + { + hasXnbFile = true; + continue; } - return hasXnbFile; + if (!this.PotentialXnbModExtensions.Contains(file.Extension)) + return false; } - /// Get whether a set of files looks like an XNB mod. - /// The files in the mod. - private bool IsEmptyVortexFolder(IEnumerable files) - { - bool hasVortexMarker = false; + return hasXnbFile; + } - foreach (FileInfo file in files) - { - if (file.Name == this.VortexMarkerFileName) - { - hasVortexMarker = true; - continue; - } + /// Get whether a set of files looks like an XNB mod. + /// The files in the mod. + private bool IsEmptyVortexFolder(IEnumerable files) + { + bool hasVortexMarker = false; - if (this.IsRelevant(file) && file.Name != this.ConfigFileName) - return false; + foreach (FileInfo file in files) + { + if (file.Name == this.VortexMarkerFileName) + { + hasVortexMarker = true; + continue; } - return hasVortexMarker; + if (this.IsRelevant(file) && file.Name != this.ConfigFileName) + return false; } + + return hasVortexMarker; } } diff --git a/src/SMAPI.Toolkit/Framework/ModScanning/ModType.cs b/src/SMAPI.Toolkit/Framework/ModScanning/ModType.cs index bc86edb6d..ef179e1a5 100644 --- a/src/SMAPI.Toolkit/Framework/ModScanning/ModType.cs +++ b/src/SMAPI.Toolkit/Framework/ModScanning/ModType.cs @@ -1,21 +1,20 @@ -namespace StardewModdingAPI.Toolkit.Framework.ModScanning +namespace StardewModdingAPI.Toolkit.Framework.ModScanning; + +/// A general mod type. +public enum ModType { - /// A general mod type. - public enum ModType - { - /// The mod is invalid and its type could not be determined. - Invalid, + /// The mod is invalid and its type could not be determined. + Invalid, - /// The folder is ignored by convention. - Ignored, + /// The folder is ignored by convention. + Ignored, - /// A mod which uses SMAPI directly. - Smapi, + /// A mod which uses SMAPI directly. + Smapi, - /// A mod which contains files loaded by a SMAPI mod. - ContentPack, + /// A mod which contains files loaded by a SMAPI mod. + ContentPack, - /// A legacy mod which replaces game files directly. - Xnb - } + /// A legacy mod which replaces game files directly. + Xnb } diff --git a/src/SMAPI.Toolkit/Framework/SemanticVersionReader.cs b/src/SMAPI.Toolkit/Framework/SemanticVersionReader.cs index fd17820b0..07371f84d 100644 --- a/src/SMAPI.Toolkit/Framework/SemanticVersionReader.cs +++ b/src/SMAPI.Toolkit/Framework/SemanticVersionReader.cs @@ -1,133 +1,132 @@ using System.Diagnostics.CodeAnalysis; -namespace StardewModdingAPI.Toolkit.Framework +namespace StardewModdingAPI.Toolkit.Framework; + +/// Reads strings into a semantic version. +internal static class SemanticVersionReader { - /// Reads strings into a semantic version. - internal static class SemanticVersionReader + /********* + ** Public methods + *********/ + /// Parse a semantic version string. + /// The version string to parse. + /// Whether to recognize non-standard semver extensions. + /// The major version incremented for major API changes. + /// The minor version incremented for backwards-compatible changes. + /// The patch version for backwards-compatible fixes. + /// The platform-specific version (if applicable). + /// An optional prerelease tag. + /// Optional build metadata. This is ignored when determining version precedence. + /// Returns whether the version was successfully parsed. + public static bool TryParse(string? versionStr, bool allowNonStandard, out int major, out int minor, out int patch, out int platformRelease, out string? prereleaseTag, out string? buildMetadata) { - /********* - ** Public methods - *********/ - /// Parse a semantic version string. - /// The version string to parse. - /// Whether to recognize non-standard semver extensions. - /// The major version incremented for major API changes. - /// The minor version incremented for backwards-compatible changes. - /// The patch version for backwards-compatible fixes. - /// The platform-specific version (if applicable). - /// An optional prerelease tag. - /// Optional build metadata. This is ignored when determining version precedence. - /// Returns whether the version was successfully parsed. - public static bool TryParse(string? versionStr, bool allowNonStandard, out int major, out int minor, out int patch, out int platformRelease, out string? prereleaseTag, out string? buildMetadata) - { - // init - major = 0; - minor = 0; - patch = 0; - platformRelease = 0; - prereleaseTag = null; - buildMetadata = null; - - // normalize - versionStr = versionStr?.Trim(); - if (string.IsNullOrWhiteSpace(versionStr)) - return false; - char[] raw = versionStr.ToCharArray(); - - // read major/minor version - int i = 0; - if (!TryParseVersionPart(raw, ref i, out major) || !TryParseLiteral(raw, ref i, '.') || !TryParseVersionPart(raw, ref i, out minor)) - return false; - - // read optional patch version - if (TryParseLiteral(raw, ref i, '.') && !TryParseVersionPart(raw, ref i, out patch)) - return false; - - // read optional non-standard platform release version - if (allowNonStandard && TryParseLiteral(raw, ref i, '.') && !TryParseVersionPart(raw, ref i, out platformRelease)) - return false; - - // read optional prerelease tag - if (TryParseLiteral(raw, ref i, '-') && !TryParseTag(raw, ref i, out prereleaseTag)) - return false; - - // read optional build tag - if (TryParseLiteral(raw, ref i, '+') && !TryParseTag(raw, ref i, out buildMetadata)) - return false; - - // validate - return i == versionStr.Length; // valid if we're at the end - } + // init + major = 0; + minor = 0; + patch = 0; + platformRelease = 0; + prereleaseTag = null; + buildMetadata = null; + + // normalize + versionStr = versionStr?.Trim(); + if (string.IsNullOrWhiteSpace(versionStr)) + return false; + char[] raw = versionStr.ToCharArray(); + + // read major/minor version + int i = 0; + if (!TryParseVersionPart(raw, ref i, out major) || !TryParseLiteral(raw, ref i, '.') || !TryParseVersionPart(raw, ref i, out minor)) + return false; + + // read optional patch version + if (TryParseLiteral(raw, ref i, '.') && !TryParseVersionPart(raw, ref i, out patch)) + return false; + + // read optional non-standard platform release version + if (allowNonStandard && TryParseLiteral(raw, ref i, '.') && !TryParseVersionPart(raw, ref i, out platformRelease)) + return false; + + // read optional prerelease tag + if (TryParseLiteral(raw, ref i, '-') && !TryParseTag(raw, ref i, out prereleaseTag)) + return false; + + // read optional build tag + if (TryParseLiteral(raw, ref i, '+') && !TryParseTag(raw, ref i, out buildMetadata)) + return false; + + // validate + return i == versionStr.Length; // valid if we're at the end + } - /********* - ** Private methods - *********/ - /// Try to parse the next characters in a queue as a numeric part. - /// The raw characters to parse. - /// The index of the next character to read. - /// The parsed part. - private static bool TryParseVersionPart(char[] raw, ref int index, out int part) - { - part = 0; - - // take digits - string str = ""; - for (int i = index; i < raw.Length && char.IsDigit(raw[i]); i++) - str += raw[i]; - - // validate - if (str.Length == 0) - return false; - if (str.Length > 1 && str[0] == '0') - return false; // can't have leading zeros - - // parse - part = int.Parse(str); - index += str.Length; - return true; - } + /********* + ** Private methods + *********/ + /// Try to parse the next characters in a queue as a numeric part. + /// The raw characters to parse. + /// The index of the next character to read. + /// The parsed part. + private static bool TryParseVersionPart(char[] raw, ref int index, out int part) + { + part = 0; + + // take digits + string str = ""; + for (int i = index; i < raw.Length && char.IsDigit(raw[i]); i++) + str += raw[i]; + + // validate + if (str.Length == 0) + return false; + if (str.Length > 1 && str[0] == '0') + return false; // can't have leading zeros + + // parse + part = int.Parse(str); + index += str.Length; + return true; + } - /// Try to parse a literal character. - /// The raw characters to parse. - /// The index of the next character to read. - /// The expected character. - private static bool TryParseLiteral(char[] raw, ref int index, char ch) - { - if (index >= raw.Length || raw[index] != ch) - return false; + /// Try to parse a literal character. + /// The raw characters to parse. + /// The index of the next character to read. + /// The expected character. + private static bool TryParseLiteral(char[] raw, ref int index, char ch) + { + if (index >= raw.Length || raw[index] != ch) + return false; - index++; - return true; - } + index++; + return true; + } - /// Try to parse a tag. - /// The raw characters to parse. - /// The index of the next character to read. - /// The parsed tag. - private static bool TryParseTag(char[] raw, ref int index, + /// Try to parse a tag. + /// The raw characters to parse. + /// The index of the next character to read. + /// The parsed tag. + private static bool TryParseTag(char[] raw, ref int index, #if NET6_0_OR_GREATER - [NotNullWhen(true)] + [NotNullWhen(true)] #endif - out string? tag - ) + out string? tag + ) + { + // read tag length + int length = 0; + for (int i = index; i < raw.Length && (char.IsLetterOrDigit(raw[i]) || raw[i] == '-' || raw[i] == '.'); i++) + length++; + + // validate + if (length == 0) { - // read tag length - int length = 0; - for (int i = index; i < raw.Length && (char.IsLetterOrDigit(raw[i]) || raw[i] == '-' || raw[i] == '.'); i++) - length++; - - // validate - if (length == 0) - { - tag = null; - return false; - } - - // parse - tag = new string(raw, index, length); - index += length; - return true; + tag = null; + return false; } + + // parse + tag = new string(raw, index, length); + index += length; + return true; } } diff --git a/src/SMAPI.Toolkit/Framework/UpdateData/ModSiteKey.cs b/src/SMAPI.Toolkit/Framework/UpdateData/ModSiteKey.cs index 195b03671..8aa3861ab 100644 --- a/src/SMAPI.Toolkit/Framework/UpdateData/ModSiteKey.cs +++ b/src/SMAPI.Toolkit/Framework/UpdateData/ModSiteKey.cs @@ -1,27 +1,26 @@ -namespace StardewModdingAPI.Toolkit.Framework.UpdateData +namespace StardewModdingAPI.Toolkit.Framework.UpdateData; + +/// A mod site which SMAPI can check for updates. +public enum ModSiteKey { - /// A mod site which SMAPI can check for updates. - public enum ModSiteKey - { - /// An unknown or invalid mod repository. - Unknown, + /// An unknown or invalid mod repository. + Unknown, - /// The Chucklefish mod repository. - Chucklefish, + /// The Chucklefish mod repository. + Chucklefish, - /// The CurseForge mod repository. - CurseForge, + /// The CurseForge mod repository. + CurseForge, - /// A GitHub project containing releases. - GitHub, + /// A GitHub project containing releases. + GitHub, - /// The ModDrop mod repository. - ModDrop, + /// The ModDrop mod repository. + ModDrop, - /// The Nexus Mods mod repository. - Nexus, + /// The Nexus Mods mod repository. + Nexus, - /// An arbitrary URL to a JSON file containing update data. - UpdateManifest - } + /// An arbitrary URL to a JSON file containing update data. + UpdateManifest } diff --git a/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs b/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs index 6cf1c6d09..87e87cf21 100644 --- a/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs +++ b/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs @@ -1,159 +1,158 @@ using System; using System.Diagnostics.CodeAnalysis; -namespace StardewModdingAPI.Toolkit.Framework.UpdateData +namespace StardewModdingAPI.Toolkit.Framework.UpdateData; + +/// A namespaced mod ID which uniquely identifies a mod within a mod repository. +public class UpdateKey : IEquatable { - /// A namespaced mod ID which uniquely identifies a mod within a mod repository. - public class UpdateKey : IEquatable - { - /********* - ** Accessors - *********/ - /// The raw update key text. - public string RawText { get; } + /********* + ** Accessors + *********/ + /// The raw update key text. + public string RawText { get; } - /// The mod site containing the mod. - public ModSiteKey Site { get; } + /// The mod site containing the mod. + public ModSiteKey Site { get; } - /// The mod ID within the repository. - public string? ID { get; } + /// The mod ID within the repository. + public string? ID { get; } - /// If specified, a substring in download names/descriptions to match. - public string? Subkey { get; } + /// If specified, a substring in download names/descriptions to match. + public string? Subkey { get; } - /// Whether the update key seems to be valid. + /// Whether the update key seems to be valid. #if NET6_0_OR_GREATER - [MemberNotNullWhen(true, nameof(UpdateKey.ID))] + [MemberNotNullWhen(true, nameof(UpdateKey.ID))] #endif - public bool LooksValid { get; } - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The raw update key text. - /// The mod site containing the mod. - /// The mod ID within the site. - /// If specified, a substring in download names/descriptions to match. - public UpdateKey(string? rawText, ModSiteKey site, string? id, string? subkey) - { - this.RawText = rawText?.Trim() ?? string.Empty; - this.Site = site; - this.ID = id?.Trim(); - this.Subkey = subkey?.Trim(); - this.LooksValid = - site != ModSiteKey.Unknown - && !string.IsNullOrWhiteSpace(id); - } + public bool LooksValid { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The raw update key text. + /// The mod site containing the mod. + /// The mod ID within the site. + /// If specified, a substring in download names/descriptions to match. + public UpdateKey(string? rawText, ModSiteKey site, string? id, string? subkey) + { + this.RawText = rawText?.Trim() ?? string.Empty; + this.Site = site; + this.ID = id?.Trim(); + this.Subkey = subkey?.Trim(); + this.LooksValid = + site != ModSiteKey.Unknown + && !string.IsNullOrWhiteSpace(id); + } - /// Construct an instance. - /// The mod site containing the mod. - /// The mod ID within the site. - /// If specified, a substring in download names/descriptions to match. - public UpdateKey(ModSiteKey site, string? id, string? subkey) - : this(UpdateKey.GetString(site, id, subkey), site, id, subkey) { } + /// Construct an instance. + /// The mod site containing the mod. + /// The mod ID within the site. + /// If specified, a substring in download names/descriptions to match. + public UpdateKey(ModSiteKey site, string? id, string? subkey) + : this(UpdateKey.GetString(site, id, subkey), site, id, subkey) { } - /// Parse a raw update key. - /// The raw update key to parse. - public static UpdateKey Parse(string? raw) - { - if (raw is null) - return new UpdateKey(raw, ModSiteKey.Unknown, null, null); - // extract site + ID - (string rawSite, string? id) = UpdateKey.SplitTwoParts(raw, ':'); - if (string.IsNullOrEmpty(id)) - id = null; - - // extract subkey - string? subkey = null; - if (id != null) - (id, subkey) = UpdateKey.SplitTwoParts(id, '@', true); - - // parse - if (!Enum.TryParse(rawSite, true, out ModSiteKey site)) - return new UpdateKey(raw, ModSiteKey.Unknown, id, subkey); - if (id == null) - return new UpdateKey(raw, site, null, subkey); - - return new UpdateKey(raw, site, id, subkey); - } + /// Parse a raw update key. + /// The raw update key to parse. + public static UpdateKey Parse(string? raw) + { + if (raw is null) + return new UpdateKey(raw, ModSiteKey.Unknown, null, null); + // extract site + ID + (string rawSite, string? id) = UpdateKey.SplitTwoParts(raw, ':'); + if (string.IsNullOrEmpty(id)) + id = null; + + // extract subkey + string? subkey = null; + if (id != null) + (id, subkey) = UpdateKey.SplitTwoParts(id, '@', true); + + // parse + if (!Enum.TryParse(rawSite, true, out ModSiteKey site)) + return new UpdateKey(raw, ModSiteKey.Unknown, id, subkey); + if (id == null) + return new UpdateKey(raw, site, null, subkey); + + return new UpdateKey(raw, site, id, subkey); + } - /// Parse a raw update key if it's valid. - /// The raw update key to parse. - /// The parsed update key, if valid. - /// Returns whether the update key was successfully parsed. - public static bool TryParse(string raw, out UpdateKey parsed) - { - parsed = UpdateKey.Parse(raw); - return parsed.LooksValid; - } + /// Parse a raw update key if it's valid. + /// The raw update key to parse. + /// The parsed update key, if valid. + /// Returns whether the update key was successfully parsed. + public static bool TryParse(string raw, out UpdateKey parsed) + { + parsed = UpdateKey.Parse(raw); + return parsed.LooksValid; + } - /// Get a string that represents the current object. - public override string ToString() - { - return this.LooksValid - ? UpdateKey.GetString(this.Site, this.ID, this.Subkey) - : this.RawText; - } + /// Get a string that represents the current object. + public override string ToString() + { + return this.LooksValid + ? UpdateKey.GetString(this.Site, this.ID, this.Subkey) + : this.RawText; + } - /// Indicates whether the current object is equal to another object of the same type. - /// An object to compare with this object. - public bool Equals(UpdateKey? other) + /// Indicates whether the current object is equal to another object of the same type. + /// An object to compare with this object. + public bool Equals(UpdateKey? other) + { + if (!this.LooksValid) { - if (!this.LooksValid) - { - return - other?.LooksValid == false - && this.RawText.Equals(other.RawText, StringComparison.OrdinalIgnoreCase); - } - return - other != null - && this.Site == other.Site - && string.Equals(this.ID, other.ID, StringComparison.OrdinalIgnoreCase) - && string.Equals(this.Subkey, other.Subkey, StringComparison.OrdinalIgnoreCase); + other?.LooksValid == false + && this.RawText.Equals(other.RawText, StringComparison.OrdinalIgnoreCase); } - /// Determines whether the specified object is equal to the current object. - /// The object to compare with the current object. - public override bool Equals(object? obj) - { - return obj is UpdateKey other && this.Equals(other); - } + return + other != null + && this.Site == other.Site + && string.Equals(this.ID, other.ID, StringComparison.OrdinalIgnoreCase) + && string.Equals(this.Subkey, other.Subkey, StringComparison.OrdinalIgnoreCase); + } - /// Serves as the default hash function. - /// A hash code for the current object. - public override int GetHashCode() - { - return this.ToString().ToLower().GetHashCode(); - } + /// Determines whether the specified object is equal to the current object. + /// The object to compare with the current object. + public override bool Equals(object? obj) + { + return obj is UpdateKey other && this.Equals(other); + } - /// Get the string representation of an update key. - /// The mod site containing the mod. - /// The mod ID within the repository. - /// If specified, a substring in download names/descriptions to match. - public static string GetString(ModSiteKey site, string? id, string? subkey = null) - { - return $"{site}:{id}{subkey}".Trim(); - } + /// Serves as the default hash function. + /// A hash code for the current object. + public override int GetHashCode() + { + return this.ToString().ToLower().GetHashCode(); + } + /// Get the string representation of an update key. + /// The mod site containing the mod. + /// The mod ID within the repository. + /// If specified, a substring in download names/descriptions to match. + public static string GetString(ModSiteKey site, string? id, string? subkey = null) + { + return $"{site}:{id}{subkey}".Trim(); + } - /********* - ** Private methods - *********/ - /// Split a string into two parts at a delimiter and trim whitespace. - /// The string to split. - /// The character on which to split. - /// Whether to include the delimiter in the second string. - /// Returns a tuple containing the two strings, with the second value null if the delimiter wasn't found. - private static (string, string?) SplitTwoParts(string str, char delimiter, bool keepDelimiter = false) - { - int splitIndex = str.IndexOf(delimiter); - return splitIndex >= 0 - ? (str.Substring(0, splitIndex).Trim(), str.Substring(splitIndex + (keepDelimiter ? 0 : 1)).Trim()) - : (str.Trim(), null); - } + /********* + ** Private methods + *********/ + /// Split a string into two parts at a delimiter and trim whitespace. + /// The string to split. + /// The character on which to split. + /// Whether to include the delimiter in the second string. + /// Returns a tuple containing the two strings, with the second value null if the delimiter wasn't found. + private static (string, string?) SplitTwoParts(string str, char delimiter, bool keepDelimiter = false) + { + int splitIndex = str.IndexOf(delimiter); + + return splitIndex >= 0 + ? (str.Substring(0, splitIndex).Trim(), str.Substring(splitIndex + (keepDelimiter ? 0 : 1)).Trim()) + : (str.Trim(), null); } } diff --git a/src/SMAPI.Toolkit/ModToolkit.cs b/src/SMAPI.Toolkit/ModToolkit.cs index 1d61ec196..6d6cf6e57 100644 --- a/src/SMAPI.Toolkit/ModToolkit.cs +++ b/src/SMAPI.Toolkit/ModToolkit.cs @@ -10,102 +10,103 @@ using StardewModdingAPI.Toolkit.Framework.UpdateData; using StardewModdingAPI.Toolkit.Serialization; -namespace StardewModdingAPI.Toolkit +namespace StardewModdingAPI.Toolkit; + +/// A convenience wrapper for the various tools. +public class ModToolkit { - /// A convenience wrapper for the various tools. - public class ModToolkit + /********* + ** Fields + *********/ + /// The default HTTP user agent for the toolkit. + private readonly string UserAgent; + + /// Maps vendor keys (like Nexus) to their mod URL template (where {0} is the mod ID). This doesn't affect update checks, which defer to the remote web API. + private readonly Dictionary VendorModUrls = new() + { + [ModSiteKey.Chucklefish] = "https://community.playstarbound.com/resources/{0}", + [ModSiteKey.CurseForge] = "https://www.curseforge.com/projects/{0}", + [ModSiteKey.GitHub] = "https://github.com/{0}/releases", + [ModSiteKey.ModDrop] = "https://www.moddrop.com/stardew-valley/mods/{0}", + [ModSiteKey.Nexus] = "https://www.nexusmods.com/stardewvalley/mods/{0}" + }; + + + /********* + ** Accessors + *********/ + /// Encapsulates SMAPI's JSON parsing. + public JsonHelper JsonHelper { get; } = new(); + + + /********* + ** Public methods + *********/ + /// Construct an instance. + public ModToolkit() { - /********* - ** Fields - *********/ - /// The default HTTP user agent for the toolkit. - private readonly string UserAgent; - - /// Maps vendor keys (like Nexus) to their mod URL template (where {0} is the mod ID). This doesn't affect update checks, which defer to the remote web API. - private readonly Dictionary VendorModUrls = new() - { - [ModSiteKey.Chucklefish] = "https://community.playstarbound.com/resources/{0}", - [ModSiteKey.GitHub] = "https://github.com/{0}/releases", - [ModSiteKey.Nexus] = "https://www.nexusmods.com/stardewvalley/mods/{0}" - }; - - - /********* - ** Accessors - *********/ - /// Encapsulates SMAPI's JSON parsing. - public JsonHelper JsonHelper { get; } = new(); - - - /********* - ** Public methods - *********/ - /// Construct an instance. - public ModToolkit() - { - ISemanticVersion version = new SemanticVersion(this.GetType().Assembly.GetName().Version!); - this.UserAgent = $"SMAPI Mod Handler Toolkit/{version}"; - } - - /// Find valid Stardew Valley install folders. - /// This checks default game locations, and on Windows checks the Windows registry for GOG/Steam install data. A folder is considered 'valid' if it contains the Stardew Valley executable for the current OS. - public IEnumerable GetGameFolders() - { - return new GameScanner().Scan(); - } - - /// Find all default Stardew Valley install folders which exist, regardless of whether they're valid. - /// This checks default game locations, and on Windows checks the Windows registry for GOG/Steam install data. - public IEnumerable<(DirectoryInfo, GameFolderType)> GetGameFoldersIncludingInvalid() - { - return new GameScanner().ScanIncludingInvalid(); - } - - /// Extract mod metadata from the wiki compatibility list. - public async Task GetWikiCompatibilityListAsync() - { - using WikiClient client = new(this.UserAgent); - return await client.FetchModsAsync(); - } - - /// Get SMAPI's internal mod database. - /// The file path for the SMAPI metadata file. - public ModDatabase GetModDatabase(string metadataPath) - { - MetadataModel metadata = JsonConvert.DeserializeObject(File.ReadAllText(metadataPath)) ?? new MetadataModel(); - ModDataRecord[] records = metadata.ModData.Select(pair => new ModDataRecord(pair.Key, pair.Value)).ToArray(); - return new ModDatabase(records, this.GetUpdateUrl); - } - - /// Extract information about all mods in the given folder. - /// The root folder containing mods. - /// Whether to match file paths case-insensitively, even on Linux. - public IEnumerable GetModFolders(string rootPath, bool useCaseInsensitiveFilePaths) - { - return new ModScanner(this.JsonHelper).GetModFolders(rootPath, useCaseInsensitiveFilePaths); - } - - /// Extract information about all mods in the given folder. - /// The root folder containing mods. Only the will be searched, but this field allows it to be treated as a potential mod folder of its own. - /// The mod path to search. - /// Whether to match file paths case-insensitively, even on Linux. - public IEnumerable GetModFolders(string rootPath, string modPath, bool useCaseInsensitiveFilePaths) - { - return new ModScanner(this.JsonHelper).GetModFolders(rootPath, modPath, useCaseInsensitiveFilePaths); - } - - /// Get an update URL for an update key (if valid). - /// The update key. - public string? GetUpdateUrl(string updateKey) - { - UpdateKey parsed = UpdateKey.Parse(updateKey); - if (!parsed.LooksValid) - return null; - - if (this.VendorModUrls.TryGetValue(parsed.Site, out string? urlTemplate)) - return string.Format(urlTemplate, parsed.ID); + ISemanticVersion version = new SemanticVersion(this.GetType().Assembly.GetName().Version!); + this.UserAgent = $"SMAPI Mod Handler Toolkit/{version}"; + } + /// Find valid Stardew Valley install folders. + /// This checks default game locations, and on Windows checks the Windows registry for GOG/Steam install data. A folder is considered 'valid' if it contains the Stardew Valley executable for the current OS. + public IEnumerable GetGameFolders() + { + return new GameScanner().Scan(); + } + + /// Find all default Stardew Valley install folders which exist, regardless of whether they're valid. + /// This checks default game locations, and on Windows checks the Windows registry for GOG/Steam install data. + public IEnumerable<(DirectoryInfo, GameFolderType)> GetGameFoldersIncludingInvalid() + { + return new GameScanner().ScanIncludingInvalid(); + } + + /// Extract mod metadata from the wiki compatibility list. + public async Task GetWikiCompatibilityListAsync() + { + using WikiClient client = new(this.UserAgent); + return await client.FetchModsAsync(); + } + + /// Get SMAPI's internal mod database. + /// The file path for the SMAPI metadata file. + public ModDatabase GetModDatabase(string metadataPath) + { + MetadataModel metadata = JsonConvert.DeserializeObject(File.ReadAllText(metadataPath)) ?? new MetadataModel(); + ModDataRecord[] records = metadata.ModData.Select(pair => new ModDataRecord(pair.Key, pair.Value)).ToArray(); + return new ModDatabase(records, this.GetUpdateUrl); + } + + /// Extract information about all mods in the given folder. + /// The root folder containing mods. + /// Whether to match file paths case-insensitively, even on Linux. + public IEnumerable GetModFolders(string rootPath, bool useCaseInsensitiveFilePaths) + { + return new ModScanner(this.JsonHelper).GetModFolders(rootPath, useCaseInsensitiveFilePaths); + } + + /// Extract information about all mods in the given folder. + /// The root folder containing mods. Only the will be searched, but this field allows it to be treated as a potential mod folder of its own. + /// The mod path to search. + /// Whether to match file paths case-insensitively, even on Linux. + public IEnumerable GetModFolders(string rootPath, string modPath, bool useCaseInsensitiveFilePaths) + { + return new ModScanner(this.JsonHelper).GetModFolders(rootPath, modPath, useCaseInsensitiveFilePaths); + } + + /// Get an update URL for an update key (if valid). + /// The update key. + public string? GetUpdateUrl(string updateKey) + { + UpdateKey parsed = UpdateKey.Parse(updateKey); + if (!parsed.LooksValid) return null; - } + + if (this.VendorModUrls.TryGetValue(parsed.Site, out string? urlTemplate)) + return string.Format(urlTemplate, parsed.ID); + + return null; } } diff --git a/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj b/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj index 7fc1f30bc..dd3bab12e 100644 --- a/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj +++ b/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj @@ -4,17 +4,19 @@ A library which encapsulates mod-handling logic for mod managers and tools. Not intended for use by mods. net6.0; netstandard2.0 true + + true - - - - - - + + + + + + diff --git a/src/SMAPI.Toolkit/SemanticVersion.cs b/src/SMAPI.Toolkit/SemanticVersion.cs index 19861cca0..d2cf0472f 100644 --- a/src/SMAPI.Toolkit/SemanticVersion.cs +++ b/src/SMAPI.Toolkit/SemanticVersion.cs @@ -3,362 +3,361 @@ using System.Text.RegularExpressions; using StardewModdingAPI.Toolkit.Framework; -namespace StardewModdingAPI.Toolkit +namespace StardewModdingAPI.Toolkit; + +/// A semantic version with an optional release tag. +/// +/// The implementation is defined by Semantic Version 2.0 (https://semver.org/), with a few deviations: +/// - short-form "x.y" versions are supported (equivalent to "x.y.0"); +/// - hyphens are synonymous with dots in prerelease tags and build metadata (like "-unofficial.3-pathoschild"); +/// - and "-unofficial" in prerelease tags is always lower-precedence (e.g. "1.0-beta" is newer than "1.0-unofficial"). +/// +/// This optionally also supports four-part versions, a non-standard extension used by Stardew Valley on ported platforms to represent platform-specific patches to a ported version, represented as a fourth number in the version string. +/// +public class SemanticVersion : ISemanticVersion { - /// A semantic version with an optional release tag. - /// - /// The implementation is defined by Semantic Version 2.0 (https://semver.org/), with a few deviations: - /// - short-form "x.y" versions are supported (equivalent to "x.y.0"); - /// - hyphens are synonymous with dots in prerelease tags and build metadata (like "-unofficial.3-pathoschild"); - /// - and "-unofficial" in prerelease tags is always lower-precedence (e.g. "1.0-beta" is newer than "1.0-unofficial"). - /// - /// This optionally also supports four-part versions, a non-standard extension used by Stardew Valley on ported platforms to represent platform-specific patches to a ported version, represented as a fourth number in the version string. - /// - public class SemanticVersion : ISemanticVersion + /********* + ** Fields + *********/ + /// A regex pattern matching a valid prerelease or build metadata tag. + private const string TagPattern = @"(?>[a-z0-9]+[\-\.]?)+"; + + + /********* + ** Accessors + *********/ + /// + public int MajorVersion { get; } + + /// + public int MinorVersion { get; } + + /// + public int PatchVersion { get; } + + /// The platform release. This is a non-standard semver extension used by Stardew Valley on ported platforms to represent platform-specific patches to a ported version, represented as a fourth number in the version string. + public int PlatformRelease { get; } + + /// + public string? PrereleaseTag { get; } + + /// + public string? BuildMetadata { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The major version incremented for major API changes. + /// The minor version incremented for backwards-compatible changes. + /// The patch version for backwards-compatible fixes. + /// The platform-specific version (if applicable). + /// An optional prerelease tag. + /// Optional build metadata. This is ignored when determining version precedence. + public SemanticVersion(int major, int minor, int patch, int platformRelease = 0, string? prereleaseTag = null, string? buildMetadata = null) { - /********* - ** Fields - *********/ - /// A regex pattern matching a valid prerelease or build metadata tag. - private const string TagPattern = @"(?>[a-z0-9]+[\-\.]?)+"; - - - /********* - ** Accessors - *********/ - /// - public int MajorVersion { get; } - - /// - public int MinorVersion { get; } - - /// - public int PatchVersion { get; } - - /// The platform release. This is a non-standard semver extension used by Stardew Valley on ported platforms to represent platform-specific patches to a ported version, represented as a fourth number in the version string. - public int PlatformRelease { get; } - - /// - public string? PrereleaseTag { get; } - - /// - public string? BuildMetadata { get; } - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The major version incremented for major API changes. - /// The minor version incremented for backwards-compatible changes. - /// The patch version for backwards-compatible fixes. - /// The platform-specific version (if applicable). - /// An optional prerelease tag. - /// Optional build metadata. This is ignored when determining version precedence. - public SemanticVersion(int major, int minor, int patch, int platformRelease = 0, string? prereleaseTag = null, string? buildMetadata = null) - { - this.MajorVersion = major; - this.MinorVersion = minor; - this.PatchVersion = patch; - this.PlatformRelease = platformRelease; - this.PrereleaseTag = this.GetNormalizedTag(prereleaseTag); - this.BuildMetadata = this.GetNormalizedTag(buildMetadata); - - this.AssertValid(); - } + this.MajorVersion = major; + this.MinorVersion = minor; + this.PatchVersion = patch; + this.PlatformRelease = platformRelease; + this.PrereleaseTag = this.GetNormalizedTag(prereleaseTag); + this.BuildMetadata = this.GetNormalizedTag(buildMetadata); + + this.AssertValid(); + } - /// Construct an instance. - /// The assembly version. - /// The is null. - public SemanticVersion(Version version) - { - if (version == null) - throw new ArgumentNullException(nameof(version), "The input version can't be null."); + /// Construct an instance. + /// The assembly version. + /// The is null. + public SemanticVersion(Version version) + { + if (version == null) + throw new ArgumentNullException(nameof(version), "The input version can't be null."); - this.MajorVersion = version.Major; - this.MinorVersion = version.Minor; - this.PatchVersion = version.Build; + this.MajorVersion = version.Major; + this.MinorVersion = version.Minor; + this.PatchVersion = version.Build; - this.AssertValid(); - } + this.AssertValid(); + } - /// Construct an instance. - /// The semantic version string. - /// Whether to recognize non-standard semver extensions. - /// The is null. - /// The is not a valid semantic version. - public SemanticVersion(string version, bool allowNonStandard = false) - { - if (version == null) - throw new ArgumentNullException(nameof(version), "The input version string can't be null."); - if (!SemanticVersionReader.TryParse(version, allowNonStandard, out int major, out int minor, out int patch, out int platformRelease, out string? prereleaseTag, out string? buildMetadata) || (!allowNonStandard && platformRelease != 0)) - throw new FormatException($"The input '{version}' isn't a valid semantic version."); - - this.MajorVersion = major; - this.MinorVersion = minor; - this.PatchVersion = patch; - this.PlatformRelease = platformRelease; - this.PrereleaseTag = prereleaseTag; - this.BuildMetadata = buildMetadata; - - this.AssertValid(); - } + /// Construct an instance. + /// The semantic version string. + /// Whether to recognize non-standard semver extensions. + /// The is null. + /// The is not a valid semantic version. + public SemanticVersion(string version, bool allowNonStandard = false) + { + if (version == null) + throw new ArgumentNullException(nameof(version), "The input version string can't be null."); + if (!SemanticVersionReader.TryParse(version, allowNonStandard, out int major, out int minor, out int patch, out int platformRelease, out string? prereleaseTag, out string? buildMetadata) || (!allowNonStandard && platformRelease != 0)) + throw new FormatException($"The input '{version}' isn't a valid semantic version."); + + this.MajorVersion = major; + this.MinorVersion = minor; + this.PatchVersion = patch; + this.PlatformRelease = platformRelease; + this.PrereleaseTag = prereleaseTag; + this.BuildMetadata = buildMetadata; + + this.AssertValid(); + } - /// - public int CompareTo(ISemanticVersion? other) - { - return other == null - ? 1 - : this.CompareTo(other.MajorVersion, other.MinorVersion, other.PatchVersion, (other as SemanticVersion)?.PlatformRelease ?? 0, other.PrereleaseTag); - } + /// + public int CompareTo(ISemanticVersion? other) + { + return other == null + ? 1 + : this.CompareTo(other.MajorVersion, other.MinorVersion, other.PatchVersion, (other as SemanticVersion)?.PlatformRelease ?? 0, other.PrereleaseTag); + } - /// - public bool Equals(ISemanticVersion? other) - { - return other != null && this.CompareTo(other) == 0; - } + /// + public bool Equals(ISemanticVersion? other) + { + return other != null && this.CompareTo(other) == 0; + } - /// + /// #if NET6_0_OR_GREATER - [MemberNotNullWhen(true, nameof(SemanticVersion.PrereleaseTag))] + [MemberNotNullWhen(true, nameof(SemanticVersion.PrereleaseTag))] #endif - public bool IsPrerelease() - { - return !string.IsNullOrWhiteSpace(this.PrereleaseTag); - } + public bool IsPrerelease() + { + return !string.IsNullOrWhiteSpace(this.PrereleaseTag); + } - /// - public bool IsOlderThan(ISemanticVersion? other) - { - return this.CompareTo(other) < 0; - } + /// + public bool IsOlderThan(ISemanticVersion? other) + { + return this.CompareTo(other) < 0; + } - /// - public bool IsOlderThan(string? other) - { - ISemanticVersion? otherVersion = other != null - ? new SemanticVersion(other, allowNonStandard: true) - : null; + /// + public bool IsOlderThan(string? other) + { + ISemanticVersion? otherVersion = other != null + ? new SemanticVersion(other, allowNonStandard: true) + : null; - return this.IsOlderThan(otherVersion); - } + return this.IsOlderThan(otherVersion); + } - /// - public bool IsNewerThan(ISemanticVersion? other) - { - return this.CompareTo(other) > 0; - } + /// + public bool IsNewerThan(ISemanticVersion? other) + { + return this.CompareTo(other) > 0; + } - /// - public bool IsNewerThan(string? other) - { - ISemanticVersion? otherVersion = other != null - ? new SemanticVersion(other, allowNonStandard: true) - : null; + /// + public bool IsNewerThan(string? other) + { + ISemanticVersion? otherVersion = other != null + ? new SemanticVersion(other, allowNonStandard: true) + : null; - return this.IsNewerThan(otherVersion); - } + return this.IsNewerThan(otherVersion); + } - /// - public bool IsBetween(ISemanticVersion? min, ISemanticVersion? max) - { - return this.CompareTo(min) >= 0 && this.CompareTo(max) <= 0; - } + /// + public bool IsBetween(ISemanticVersion? min, ISemanticVersion? max) + { + return this.CompareTo(min) >= 0 && this.CompareTo(max) <= 0; + } - /// - public bool IsBetween(string? min, string? max) - { - ISemanticVersion? minVersion = min != null - ? new SemanticVersion(min, allowNonStandard: true) - : null; - ISemanticVersion? maxVersion = max != null - ? new SemanticVersion(max, allowNonStandard: true) - : null; - - return this.IsBetween(minVersion, maxVersion); - } + /// + public bool IsBetween(string? min, string? max) + { + ISemanticVersion? minVersion = min != null + ? new SemanticVersion(min, allowNonStandard: true) + : null; + ISemanticVersion? maxVersion = max != null + ? new SemanticVersion(max, allowNonStandard: true) + : null; + + return this.IsBetween(minVersion, maxVersion); + } - /// - public override string ToString() - { - string version = $"{this.MajorVersion}.{this.MinorVersion}.{this.PatchVersion}"; - if (this.PlatformRelease != 0) - version += $".{this.PlatformRelease}"; - if (this.PrereleaseTag != null) - version += $"-{this.PrereleaseTag}"; - if (this.BuildMetadata != null) - version += $"+{this.BuildMetadata}"; - return version; - } + /// + public override string ToString() + { + string version = $"{this.MajorVersion}.{this.MinorVersion}.{this.PatchVersion}"; + if (this.PlatformRelease != 0) + version += $".{this.PlatformRelease}"; + if (this.PrereleaseTag != null) + version += $"-{this.PrereleaseTag}"; + if (this.BuildMetadata != null) + version += $"+{this.BuildMetadata}"; + return version; + } - /// - public bool IsNonStandard() - { - return this.PlatformRelease != 0; - } + /// + public bool IsNonStandard() + { + return this.PlatformRelease != 0; + } - /// Parse a version string without throwing an exception if it fails. - /// The version string. - /// The parsed representation. - /// Returns whether parsing the version succeeded. - public static bool TryParse(string? version, + /// Parse a version string without throwing an exception if it fails. + /// The version string. + /// The parsed representation. + /// Returns whether parsing the version succeeded. + public static bool TryParse(string? version, #if NET6_0_OR_GREATER - [NotNullWhen(true)] + [NotNullWhen(true)] #endif - out ISemanticVersion? parsed - ) - { - return SemanticVersion.TryParse(version, allowNonStandard: false, out parsed); - } + out ISemanticVersion? parsed + ) + { + return SemanticVersion.TryParse(version, allowNonStandard: false, out parsed); + } - /// Parse a version string without throwing an exception if it fails. - /// The version string. - /// Whether to allow non-standard extensions to semantic versioning. - /// The parsed representation. - /// Returns whether parsing the version succeeded. - public static bool TryParse(string? version, bool allowNonStandard, + /// Parse a version string without throwing an exception if it fails. + /// The version string. + /// Whether to allow non-standard extensions to semantic versioning. + /// The parsed representation. + /// Returns whether parsing the version succeeded. + public static bool TryParse(string? version, bool allowNonStandard, #if NET6_0_OR_GREATER - [NotNullWhen(true)] + [NotNullWhen(true)] #endif - out ISemanticVersion? parsed - ) + out ISemanticVersion? parsed + ) + { + if (version == null) { - if (version == null) - { - parsed = null; - return false; - } - - try - { - parsed = new SemanticVersion(version, allowNonStandard); - return true; - } - catch - { - parsed = null; - return false; - } + parsed = null; + return false; } - - /********* - ** Private methods - *********/ - /// Get a normalized prerelease or build tag. - /// The tag to normalize. - private string? GetNormalizedTag(string? tag) + try + { + parsed = new SemanticVersion(version, allowNonStandard); + return true; + } + catch { - tag = tag?.Trim(); - return !string.IsNullOrWhiteSpace(tag) ? tag : null; + parsed = null; + return false; } + } + + + /********* + ** Private methods + *********/ + /// Get a normalized prerelease or build tag. + /// The tag to normalize. + private string? GetNormalizedTag(string? tag) + { + tag = tag?.Trim(); + return !string.IsNullOrWhiteSpace(tag) ? tag : null; + } - /// Get an integer indicating whether this version precedes (less than 0), supersedes (more than 0), or is equivalent to (0) the specified version. - /// The major version to compare with this instance. - /// The minor version to compare with this instance. - /// The patch version to compare with this instance. - /// The non-standard platform release to compare with this instance. - /// The prerelease tag to compare with this instance. - private int CompareTo(int otherMajor, int otherMinor, int otherPatch, int otherPlatformRelease, string? otherTag) + /// Get an integer indicating whether this version precedes (less than 0), supersedes (more than 0), or is equivalent to (0) the specified version. + /// The major version to compare with this instance. + /// The minor version to compare with this instance. + /// The patch version to compare with this instance. + /// The non-standard platform release to compare with this instance. + /// The prerelease tag to compare with this instance. + private int CompareTo(int otherMajor, int otherMinor, int otherPatch, int otherPlatformRelease, string? otherTag) + { + const int same = 0; + const int curNewer = 1; + const int curOlder = -1; + + int CompareToRaw() { - const int same = 0; - const int curNewer = 1; - const int curOlder = -1; + // compare stable versions + if (this.MajorVersion != otherMajor) + return this.MajorVersion.CompareTo(otherMajor); + if (this.MinorVersion != otherMinor) + return this.MinorVersion.CompareTo(otherMinor); + if (this.PatchVersion != otherPatch) + return this.PatchVersion.CompareTo(otherPatch); + if (this.PlatformRelease != otherPlatformRelease) + return this.PlatformRelease.CompareTo(otherPlatformRelease); + if (this.PrereleaseTag == otherTag) + return same; + + // stable supersedes prerelease + bool curIsStable = string.IsNullOrWhiteSpace(this.PrereleaseTag); + bool otherIsStable = string.IsNullOrWhiteSpace(otherTag); + if (curIsStable) + return curNewer; + if (otherIsStable) + return curOlder; - int CompareToRaw() + // compare two prerelease tag values + string[] curParts = this.PrereleaseTag?.Split('.', '-') ?? Array.Empty(); + string[] otherParts = otherTag?.Split('.', '-') ?? Array.Empty(); + int length = Math.Max(curParts.Length, otherParts.Length); + for (int i = 0; i < length; i++) { - // compare stable versions - if (this.MajorVersion != otherMajor) - return this.MajorVersion.CompareTo(otherMajor); - if (this.MinorVersion != otherMinor) - return this.MinorVersion.CompareTo(otherMinor); - if (this.PatchVersion != otherPatch) - return this.PatchVersion.CompareTo(otherPatch); - if (this.PlatformRelease != otherPlatformRelease) - return this.PlatformRelease.CompareTo(otherPlatformRelease); - if (this.PrereleaseTag == otherTag) - return same; - - // stable supersedes prerelease - bool curIsStable = string.IsNullOrWhiteSpace(this.PrereleaseTag); - bool otherIsStable = string.IsNullOrWhiteSpace(otherTag); - if (curIsStable) + // longer prerelease tag supersedes if otherwise equal + if (curParts.Length <= i) + return curOlder; + if (otherParts.Length <= i) + return curNewer; + + // skip if same value, unless we've reached the end + if (curParts[i] == otherParts[i]) + { + if (i == length - 1) + return same; + + continue; + } + + // unofficial is always lower-precedence + if (otherParts[i].Equals("unofficial", StringComparison.OrdinalIgnoreCase)) return curNewer; - if (otherIsStable) + if (curParts[i].Equals("unofficial", StringComparison.OrdinalIgnoreCase)) return curOlder; - // compare two prerelease tag values - string[] curParts = this.PrereleaseTag?.Split('.', '-') ?? Array.Empty(); - string[] otherParts = otherTag?.Split('.', '-') ?? Array.Empty(); - int length = Math.Max(curParts.Length, otherParts.Length); - for (int i = 0; i < length; i++) + // compare numerically if possible { - // longer prerelease tag supersedes if otherwise equal - if (curParts.Length <= i) - return curOlder; - if (otherParts.Length <= i) - return curNewer; - - // skip if same value, unless we've reached the end - if (curParts[i] == otherParts[i]) - { - if (i == length - 1) - return same; - - continue; - } - - // unofficial is always lower-precedence - if (otherParts[i].Equals("unofficial", StringComparison.OrdinalIgnoreCase)) - return curNewer; - if (curParts[i].Equals("unofficial", StringComparison.OrdinalIgnoreCase)) - return curOlder; - - // compare numerically if possible - { - if (int.TryParse(curParts[i], out int curNum) && int.TryParse(otherParts[i], out int otherNum)) - return curNum.CompareTo(otherNum); - } - - // else compare lexically - return string.Compare(curParts[i], otherParts[i], StringComparison.OrdinalIgnoreCase); + if (int.TryParse(curParts[i], out int curNum) && int.TryParse(otherParts[i], out int otherNum)) + return curNum.CompareTo(otherNum); } - // fallback (this should never happen) - return string.Compare(this.ToString(), new SemanticVersion(otherMajor, otherMinor, otherPatch, otherPlatformRelease, otherTag).ToString(), StringComparison.OrdinalIgnoreCase); + // else compare lexically + return string.Compare(curParts[i], otherParts[i], StringComparison.OrdinalIgnoreCase); } - int result = CompareToRaw(); - if (result < 0) - return curOlder; - if (result > 0) - return curNewer; - return same; + // fallback (this should never happen) + return string.Compare(this.ToString(), new SemanticVersion(otherMajor, otherMinor, otherPatch, otherPlatformRelease, otherTag).ToString(), StringComparison.OrdinalIgnoreCase); } - /// Assert that the current version is valid. - private void AssertValid() - { - if (this.MajorVersion < 0 || this.MinorVersion < 0 || this.PatchVersion < 0) - throw new FormatException($"{this} isn't a valid semantic version. The major, minor, and patch numbers can't be negative."); - if (this.MajorVersion == 0 && this.MinorVersion == 0 && this.PatchVersion == 0) - throw new FormatException($"{this} isn't a valid semantic version. At least one of the major, minor, and patch numbers must be more than zero."); + int result = CompareToRaw(); + if (result < 0) + return curOlder; + if (result > 0) + return curNewer; + return same; + } - if (this.PrereleaseTag != null) - { - if (this.PrereleaseTag.Trim() == "") - throw new FormatException($"{this} isn't a valid semantic version. The prerelease tag cannot be a blank string (but may be omitted)."); - if (!Regex.IsMatch(this.PrereleaseTag, $"^{SemanticVersion.TagPattern}$", RegexOptions.IgnoreCase)) - throw new FormatException($"{this} isn't a valid semantic version. The prerelease tag is invalid."); - } + /// Assert that the current version is valid. + private void AssertValid() + { + if (this.MajorVersion < 0 || this.MinorVersion < 0 || this.PatchVersion < 0) + throw new FormatException($"{this} isn't a valid semantic version. The major, minor, and patch numbers can't be negative."); + if (this.MajorVersion == 0 && this.MinorVersion == 0 && this.PatchVersion == 0) + throw new FormatException($"{this} isn't a valid semantic version. At least one of the major, minor, and patch numbers must be more than zero."); - if (this.BuildMetadata != null) - { - if (this.BuildMetadata.Trim() == "") - throw new FormatException($"{this} isn't a valid semantic version. The build metadata cannot be a blank string (but may be omitted)."); - if (!Regex.IsMatch(this.BuildMetadata, $"^{SemanticVersion.TagPattern}$", RegexOptions.IgnoreCase)) - throw new FormatException($"{this} isn't a valid semantic version. The build metadata is invalid."); - } + if (this.PrereleaseTag != null) + { + if (this.PrereleaseTag.Trim() == "") + throw new FormatException($"{this} isn't a valid semantic version. The prerelease tag cannot be a blank string (but may be omitted)."); + if (!Regex.IsMatch(this.PrereleaseTag, $"^{SemanticVersion.TagPattern}$", RegexOptions.IgnoreCase)) + throw new FormatException($"{this} isn't a valid semantic version. The prerelease tag is invalid."); + } + + if (this.BuildMetadata != null) + { + if (this.BuildMetadata.Trim() == "") + throw new FormatException($"{this} isn't a valid semantic version. The build metadata cannot be a blank string (but may be omitted)."); + if (!Regex.IsMatch(this.BuildMetadata, $"^{SemanticVersion.TagPattern}$", RegexOptions.IgnoreCase)) + throw new FormatException($"{this} isn't a valid semantic version. The build metadata is invalid."); } } } diff --git a/src/SMAPI.Toolkit/SemanticVersionComparer.cs b/src/SMAPI.Toolkit/SemanticVersionComparer.cs index 2eca30dfc..f1efd28e6 100644 --- a/src/SMAPI.Toolkit/SemanticVersionComparer.cs +++ b/src/SMAPI.Toolkit/SemanticVersionComparer.cs @@ -1,32 +1,31 @@ using System.Collections.Generic; -namespace StardewModdingAPI.Toolkit +namespace StardewModdingAPI.Toolkit; + +/// A comparer for semantic versions based on the field. +public class SemanticVersionComparer : IComparer { - /// A comparer for semantic versions based on the field. - public class SemanticVersionComparer : IComparer - { - /********* - ** Accessors - *********/ - /// A singleton instance of the comparer. - public static SemanticVersionComparer Instance { get; } = new(); + /********* + ** Accessors + *********/ + /// A singleton instance of the comparer. + public static SemanticVersionComparer Instance { get; } = new(); - /********* - ** Public methods - *********/ - /// - public int Compare(ISemanticVersion? x, ISemanticVersion? y) - { - if (object.ReferenceEquals(x, y)) - return 0; + /********* + ** Public methods + *********/ + /// + public int Compare(ISemanticVersion? x, ISemanticVersion? y) + { + if (object.ReferenceEquals(x, y)) + return 0; - if (x is null) - return -1; - if (y is null) - return 1; + if (x is null) + return -1; + if (y is null) + return 1; - return x.CompareTo(y); - } + return x.CompareTo(y); } } diff --git a/src/SMAPI.Toolkit/Serialization/Converters/ManifestContentPackForConverter.cs b/src/SMAPI.Toolkit/Serialization/Converters/ManifestContentPackForConverter.cs index faaeedea5..f9c35614f 100644 --- a/src/SMAPI.Toolkit/Serialization/Converters/ManifestContentPackForConverter.cs +++ b/src/SMAPI.Toolkit/Serialization/Converters/ManifestContentPackForConverter.cs @@ -2,49 +2,48 @@ using Newtonsoft.Json; using StardewModdingAPI.Toolkit.Serialization.Models; -namespace StardewModdingAPI.Toolkit.Serialization.Converters +namespace StardewModdingAPI.Toolkit.Serialization.Converters; + +/// Handles deserialization of arrays. +public class ManifestContentPackForConverter : JsonConverter { - /// Handles deserialization of arrays. - public class ManifestContentPackForConverter : JsonConverter - { - /********* - ** Accessors - *********/ - /// Whether this converter can write JSON. - public override bool CanWrite => false; + /********* + ** Accessors + *********/ + /// Whether this converter can write JSON. + public override bool CanWrite => false; - /********* - ** Public methods - *********/ - /// Get whether this instance can convert the specified object type. - /// The object type. - public override bool CanConvert(Type objectType) - { - return objectType == typeof(ManifestContentPackFor[]); - } + /********* + ** Public methods + *********/ + /// Get whether this instance can convert the specified object type. + /// The object type. + public override bool CanConvert(Type objectType) + { + return objectType == typeof(ManifestContentPackFor[]); + } - /********* - ** Protected methods - *********/ - /// Read the JSON representation of the object. - /// The JSON reader. - /// The object type. - /// The object being read. - /// The calling serializer. - public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) - { - return serializer.Deserialize(reader); - } + /********* + ** Protected methods + *********/ + /// Read the JSON representation of the object. + /// The JSON reader. + /// The object type. + /// The object being read. + /// The calling serializer. + public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) + { + return serializer.Deserialize(reader); + } - /// Writes the JSON representation of the object. - /// The JSON writer. - /// The value. - /// The calling serializer. - public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) - { - throw new InvalidOperationException("This converter does not write JSON."); - } + /// Writes the JSON representation of the object. + /// The JSON writer. + /// The value. + /// The calling serializer. + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) + { + throw new InvalidOperationException("This converter does not write JSON."); } } diff --git a/src/SMAPI.Toolkit/Serialization/Converters/ManifestDependencyArrayConverter.cs b/src/SMAPI.Toolkit/Serialization/Converters/ManifestDependencyArrayConverter.cs index c499a2c68..6b5e35ffa 100644 --- a/src/SMAPI.Toolkit/Serialization/Converters/ManifestDependencyArrayConverter.cs +++ b/src/SMAPI.Toolkit/Serialization/Converters/ManifestDependencyArrayConverter.cs @@ -4,57 +4,56 @@ using Newtonsoft.Json.Linq; using StardewModdingAPI.Toolkit.Serialization.Models; -namespace StardewModdingAPI.Toolkit.Serialization.Converters +namespace StardewModdingAPI.Toolkit.Serialization.Converters; + +/// Handles deserialization of arrays. +internal class ManifestDependencyArrayConverter : JsonConverter { - /// Handles deserialization of arrays. - internal class ManifestDependencyArrayConverter : JsonConverter - { - /********* - ** Accessors - *********/ - /// Whether this converter can write JSON. - public override bool CanWrite => false; + /********* + ** Accessors + *********/ + /// Whether this converter can write JSON. + public override bool CanWrite => false; - /********* - ** Public methods - *********/ - /// Get whether this instance can convert the specified object type. - /// The object type. - public override bool CanConvert(Type objectType) - { - return objectType == typeof(ManifestDependency[]); - } + /********* + ** Public methods + *********/ + /// Get whether this instance can convert the specified object type. + /// The object type. + public override bool CanConvert(Type objectType) + { + return objectType == typeof(ManifestDependency[]); + } - /********* - ** Protected methods - *********/ - /// Read the JSON representation of the object. - /// The JSON reader. - /// The object type. - /// The object being read. - /// The calling serializer. - public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) + /********* + ** Protected methods + *********/ + /// Read the JSON representation of the object. + /// The JSON reader. + /// The object type. + /// The object being read. + /// The calling serializer. + public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) + { + List result = new List(); + foreach (JObject obj in JArray.Load(reader).Children()) { - List result = new List(); - foreach (JObject obj in JArray.Load(reader).Children()) - { - string uniqueID = obj.ValueIgnoreCase(nameof(ManifestDependency.UniqueID))!; // will be validated separately if null - string? minVersion = obj.ValueIgnoreCase(nameof(ManifestDependency.MinimumVersion)); - bool required = obj.ValueIgnoreCase(nameof(ManifestDependency.IsRequired)) ?? true; - result.Add(new ManifestDependency(uniqueID, minVersion, required)); - } - return result.ToArray(); + string uniqueID = obj.ValueIgnoreCase(nameof(ManifestDependency.UniqueID))!; // will be validated separately if null + string? minVersion = obj.ValueIgnoreCase(nameof(ManifestDependency.MinimumVersion)); + bool required = obj.ValueIgnoreCase(nameof(ManifestDependency.IsRequired)) ?? true; + result.Add(new ManifestDependency(uniqueID, minVersion, required)); } + return result.ToArray(); + } - /// Writes the JSON representation of the object. - /// The JSON writer. - /// The value. - /// The calling serializer. - public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) - { - throw new InvalidOperationException("This converter does not write JSON."); - } + /// Writes the JSON representation of the object. + /// The JSON writer. + /// The value. + /// The calling serializer. + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) + { + throw new InvalidOperationException("This converter does not write JSON."); } } diff --git a/src/SMAPI.Toolkit/Serialization/Converters/ManifestPrivateAssemblyArrayConverter.cs b/src/SMAPI.Toolkit/Serialization/Converters/ManifestPrivateAssemblyArrayConverter.cs new file mode 100644 index 000000000..5e13988d6 --- /dev/null +++ b/src/SMAPI.Toolkit/Serialization/Converters/ManifestPrivateAssemblyArrayConverter.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using StardewModdingAPI.Toolkit.Serialization.Models; + +namespace StardewModdingAPI.Toolkit.Serialization.Converters; + +/// Handles deserialization of arrays. +internal class ManifestPrivateAssemblyArrayConverter : JsonConverter +{ + /********* + ** Accessors + *********/ + /// + public override bool CanWrite => false; + + + /********* + ** Public methods + *********/ + /// + public override bool CanConvert(Type objectType) + { + return objectType == typeof(ManifestPrivateAssembly[]); + } + + + /********* + ** Protected methods + *********/ + /// + public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) + { + List result = new List(); + + foreach (JObject obj in JArray.Load(reader).Children()) + { + string name = obj.ValueIgnoreCase(nameof(ManifestPrivateAssembly.Name))!; // will be validated separately if null + bool usedDynamically = obj.ValueIgnoreCase(nameof(ManifestPrivateAssembly.UsedDynamically)) ?? false; + result.Add(new ManifestPrivateAssembly(name, usedDynamically)); + } + + return result.ToArray(); + } + + /// + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) + { + throw new InvalidOperationException("This converter does not write JSON."); + } +} diff --git a/src/SMAPI.Toolkit/Serialization/Converters/NonStandardSemanticVersionConverter.cs b/src/SMAPI.Toolkit/Serialization/Converters/NonStandardSemanticVersionConverter.cs index 6f870bcfe..be5973428 100644 --- a/src/SMAPI.Toolkit/Serialization/Converters/NonStandardSemanticVersionConverter.cs +++ b/src/SMAPI.Toolkit/Serialization/Converters/NonStandardSemanticVersionConverter.cs @@ -1,15 +1,14 @@ -namespace StardewModdingAPI.Toolkit.Serialization.Converters +namespace StardewModdingAPI.Toolkit.Serialization.Converters; + +/// Handles deserialization of , allowing for non-standard extensions. +internal class NonStandardSemanticVersionConverter : SemanticVersionConverter { - /// Handles deserialization of , allowing for non-standard extensions. - internal class NonStandardSemanticVersionConverter : SemanticVersionConverter + /********* + ** Public methods + *********/ + /// Construct an instance. + public NonStandardSemanticVersionConverter() { - /********* - ** Public methods - *********/ - /// Construct an instance. - public NonStandardSemanticVersionConverter() - { - this.AllowNonStandard = true; - } + this.AllowNonStandard = true; } } diff --git a/src/SMAPI.Toolkit/Serialization/Converters/SemanticVersionConverter.cs b/src/SMAPI.Toolkit/Serialization/Converters/SemanticVersionConverter.cs index 650815b5c..349cc80e4 100644 --- a/src/SMAPI.Toolkit/Serialization/Converters/SemanticVersionConverter.cs +++ b/src/SMAPI.Toolkit/Serialization/Converters/SemanticVersionConverter.cs @@ -2,102 +2,101 @@ using Newtonsoft.Json; using Newtonsoft.Json.Linq; -namespace StardewModdingAPI.Toolkit.Serialization.Converters +namespace StardewModdingAPI.Toolkit.Serialization.Converters; + +/// Handles deserialization of . +internal class SemanticVersionConverter : JsonConverter { - /// Handles deserialization of . - internal class SemanticVersionConverter : JsonConverter - { - /********* - ** Fields - *********/ - /// Whether to allow non-standard extensions to semantic versioning. - protected bool AllowNonStandard { get; set; } + /********* + ** Fields + *********/ + /// Whether to allow non-standard extensions to semantic versioning. + protected bool AllowNonStandard { get; set; } - /********* - ** Accessors - *********/ - /// Get whether this converter can read JSON. - public override bool CanRead { get; } = true; + /********* + ** Accessors + *********/ + /// Get whether this converter can read JSON. + public override bool CanRead { get; } = true; - /// Get whether this converter can write JSON. - public override bool CanWrite { get; } = true; + /// Get whether this converter can write JSON. + public override bool CanWrite { get; } = true; - /********* - ** Public methods - *********/ - /// Get whether this instance can convert the specified object type. - /// The object type. - public override bool CanConvert(Type objectType) - { - return typeof(ISemanticVersion).IsAssignableFrom(objectType); - } + /********* + ** Public methods + *********/ + /// Get whether this instance can convert the specified object type. + /// The object type. + public override bool CanConvert(Type objectType) + { + return typeof(ISemanticVersion).IsAssignableFrom(objectType); + } - /// Reads the JSON representation of the object. - /// The JSON reader. - /// The object type. - /// The object being read. - /// The calling serializer. - public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) + /// Reads the JSON representation of the object. + /// The JSON reader. + /// The object type. + /// The object being read. + /// The calling serializer. + public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) + { + string path = reader.Path; + switch (reader.TokenType) { - string path = reader.Path; - switch (reader.TokenType) - { - case JsonToken.Null: - return null; - - case JsonToken.StartObject: - return this.ReadObject(JObject.Load(reader)); - - case JsonToken.String: - { - string? value = JToken.Load(reader).Value(); - return value is not null - ? this.ReadString(value, path) - : null; - } - - default: - throw new SParseException($"Can't parse {nameof(ISemanticVersion)} from {reader.TokenType} node (path: {reader.Path})."); - } - } + case JsonToken.Null: + return null; - /// Writes the JSON representation of the object. - /// The JSON writer. - /// The value. - /// The calling serializer. - public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) - { - writer.WriteValue(value?.ToString()); + case JsonToken.StartObject: + return this.ReadObject(JObject.Load(reader)); + + case JsonToken.String: + { + string? value = JToken.Load(reader).Value(); + return value is not null + ? this.ReadString(value, path) + : null; + } + + default: + throw new SParseException($"Can't parse {nameof(ISemanticVersion)} from {reader.TokenType} node (path: {reader.Path})."); } + } + /// Writes the JSON representation of the object. + /// The JSON writer. + /// The value. + /// The calling serializer. + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) + { + writer.WriteValue(value?.ToString()); + } - /********* - ** Private methods - *********/ - /// Read a JSON object. - /// The JSON object to read. - private ISemanticVersion ReadObject(JObject obj) - { - int major = obj.ValueIgnoreCase(nameof(ISemanticVersion.MajorVersion)); - int minor = obj.ValueIgnoreCase(nameof(ISemanticVersion.MinorVersion)); - int patch = obj.ValueIgnoreCase(nameof(ISemanticVersion.PatchVersion)); - string? prereleaseTag = obj.ValueIgnoreCase(nameof(ISemanticVersion.PrereleaseTag)); - return new SemanticVersion(major, minor, patch, prereleaseTag: prereleaseTag); - } + /********* + ** Private methods + *********/ + /// Read a JSON object. + /// The JSON object to read. + private ISemanticVersion ReadObject(JObject obj) + { + int major = obj.ValueIgnoreCase(nameof(ISemanticVersion.MajorVersion)); + int minor = obj.ValueIgnoreCase(nameof(ISemanticVersion.MinorVersion)); + int patch = obj.ValueIgnoreCase(nameof(ISemanticVersion.PatchVersion)); + string? prereleaseTag = obj.ValueIgnoreCase(nameof(ISemanticVersion.PrereleaseTag)); - /// Read a JSON string. - /// The JSON string value. - /// The path to the current JSON node. - private ISemanticVersion? ReadString(string str, string path) - { - if (string.IsNullOrWhiteSpace(str)) - return null; - if (!SemanticVersion.TryParse(str, allowNonStandard: this.AllowNonStandard, out ISemanticVersion? version)) - throw new SParseException($"Can't parse semantic version from invalid value '{str}', should be formatted like 1.2, 1.2.30, or 1.2.30-beta (path: {path})."); - return version; - } + return new SemanticVersion(major, minor, patch, prereleaseTag: prereleaseTag); + } + + /// Read a JSON string. + /// The JSON string value. + /// The path to the current JSON node. + private ISemanticVersion? ReadString(string str, string path) + { + if (string.IsNullOrWhiteSpace(str)) + return null; + if (!SemanticVersion.TryParse(str, allowNonStandard: this.AllowNonStandard, out ISemanticVersion? version)) + throw new SParseException($"Can't parse semantic version from invalid value '{str}', should be formatted like 1.2, 1.2.30, or 1.2.30-beta (path: {path})."); + return version; } } diff --git a/src/SMAPI.Toolkit/Serialization/Converters/SimpleReadOnlyConverter.cs b/src/SMAPI.Toolkit/Serialization/Converters/SimpleReadOnlyConverter.cs index cdf2ed77f..75b6baf96 100644 --- a/src/SMAPI.Toolkit/Serialization/Converters/SimpleReadOnlyConverter.cs +++ b/src/SMAPI.Toolkit/Serialization/Converters/SimpleReadOnlyConverter.cs @@ -2,85 +2,84 @@ using Newtonsoft.Json; using Newtonsoft.Json.Linq; -namespace StardewModdingAPI.Toolkit.Serialization.Converters +namespace StardewModdingAPI.Toolkit.Serialization.Converters; + +/// The base implementation for simplified converters which deserialize without overriding serialization. +/// The type to deserialize. +internal abstract class SimpleReadOnlyConverter : JsonConverter { - /// The base implementation for simplified converters which deserialize without overriding serialization. - /// The type to deserialize. - internal abstract class SimpleReadOnlyConverter : JsonConverter - { - /********* - ** Accessors - *********/ - /// Whether this converter can write JSON. - public override bool CanWrite => false; + /********* + ** Accessors + *********/ + /// Whether this converter can write JSON. + public override bool CanWrite => false; - /********* - ** Public methods - *********/ - /// Get whether this instance can convert the specified object type. - /// The object type. - public override bool CanConvert(Type objectType) - { - return objectType == typeof(T) || Nullable.GetUnderlyingType(objectType) == typeof(T); - } + /********* + ** Public methods + *********/ + /// Get whether this instance can convert the specified object type. + /// The object type. + public override bool CanConvert(Type objectType) + { + return objectType == typeof(T) || Nullable.GetUnderlyingType(objectType) == typeof(T); + } - /// Reads the JSON representation of the object. - /// The JSON reader. - /// The object type. - /// The object being read. - /// The calling serializer. - public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) + /// Reads the JSON representation of the object. + /// The JSON reader. + /// The object type. + /// The object being read. + /// The calling serializer. + public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) + { + string path = reader.Path; + switch (reader.TokenType) { - string path = reader.Path; - switch (reader.TokenType) - { - case JsonToken.Null when Nullable.GetUnderlyingType(objectType) != null: - return null; + case JsonToken.Null when Nullable.GetUnderlyingType(objectType) != null: + return null; - case JsonToken.StartObject: - return this.ReadObject(JObject.Load(reader), path); + case JsonToken.StartObject: + return this.ReadObject(JObject.Load(reader), path); - case JsonToken.String: - { - string? value = JToken.Load(reader).Value(); - return value is not null - ? this.ReadString(value, path) - : null; - } + case JsonToken.String: + { + string? value = JToken.Load(reader).Value(); + return value is not null + ? this.ReadString(value, path) + : null; + } - default: - throw new SParseException($"Can't parse {typeof(T).Name} from {reader.TokenType} node (path: {reader.Path})."); - } + default: + throw new SParseException($"Can't parse {typeof(T).Name} from {reader.TokenType} node (path: {reader.Path})."); } + } - /// Writes the JSON representation of the object. - /// The JSON writer. - /// The value. - /// The calling serializer. - public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) - { - throw new InvalidOperationException("This converter does not write JSON."); - } + /// Writes the JSON representation of the object. + /// The JSON writer. + /// The value. + /// The calling serializer. + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) + { + throw new InvalidOperationException("This converter does not write JSON."); + } - /********* - ** Protected methods - *********/ - /// Read a JSON object. - /// The JSON object to read. - /// The path to the current JSON node. - protected virtual T ReadObject(JObject obj, string path) - { - throw new SParseException($"Can't parse {typeof(T).Name} from object node (path: {path})."); - } + /********* + ** Protected methods + *********/ + /// Read a JSON object. + /// The JSON object to read. + /// The path to the current JSON node. + protected virtual T ReadObject(JObject obj, string path) + { + throw new SParseException($"Can't parse {typeof(T).Name} from object node (path: {path})."); + } - /// Read a JSON string. - /// The JSON string value. - /// The path to the current JSON node. - protected virtual T ReadString(string str, string path) - { - throw new SParseException($"Can't parse {typeof(T).Name} from string node (path: {path})."); - } + /// Read a JSON string. + /// The JSON string value. + /// The path to the current JSON node. + protected virtual T ReadString(string str, string path) + { + throw new SParseException($"Can't parse {typeof(T).Name} from string node (path: {path})."); } } diff --git a/src/SMAPI.Toolkit/Serialization/InternalExtensions.cs b/src/SMAPI.Toolkit/Serialization/InternalExtensions.cs index 782970351..7b51fd236 100644 --- a/src/SMAPI.Toolkit/Serialization/InternalExtensions.cs +++ b/src/SMAPI.Toolkit/Serialization/InternalExtensions.cs @@ -1,21 +1,20 @@ using System; using Newtonsoft.Json.Linq; -namespace StardewModdingAPI.Toolkit.Serialization +namespace StardewModdingAPI.Toolkit.Serialization; + +/// Provides extension methods for parsing JSON. +public static class JsonExtensions { - /// Provides extension methods for parsing JSON. - public static class JsonExtensions + /// Get a JSON field value from a case-insensitive field name. This will check for an exact match first, then search without case sensitivity. + /// The value type. + /// The JSON object to search. + /// The field name. + public static T? ValueIgnoreCase(this JObject obj, string fieldName) { - /// Get a JSON field value from a case-insensitive field name. This will check for an exact match first, then search without case sensitivity. - /// The value type. - /// The JSON object to search. - /// The field name. - public static T? ValueIgnoreCase(this JObject obj, string fieldName) - { - JToken? token = obj.GetValue(fieldName, StringComparison.OrdinalIgnoreCase); - return token != null - ? token.Value() - : default; - } + JToken? token = obj.GetValue(fieldName, StringComparison.OrdinalIgnoreCase); + return token != null + ? token.Value() + : default; } } diff --git a/src/SMAPI.Toolkit/Serialization/JsonHelper.cs b/src/SMAPI.Toolkit/Serialization/JsonHelper.cs index 208cd6567..694c117bf 100644 --- a/src/SMAPI.Toolkit/Serialization/JsonHelper.cs +++ b/src/SMAPI.Toolkit/Serialization/JsonHelper.cs @@ -6,150 +6,149 @@ using Newtonsoft.Json.Converters; using StardewModdingAPI.Toolkit.Serialization.Converters; -namespace StardewModdingAPI.Toolkit.Serialization +namespace StardewModdingAPI.Toolkit.Serialization; + +/// Encapsulates SMAPI's JSON file parsing. +public class JsonHelper { - /// Encapsulates SMAPI's JSON file parsing. - public class JsonHelper - { - /********* - ** Accessors - *********/ - /// The JSON settings to use when serializing and deserializing files. - public JsonSerializerSettings JsonSettings { get; } = JsonHelper.CreateDefaultSettings(); + /********* + ** Accessors + *********/ + /// The JSON settings to use when serializing and deserializing files. + public JsonSerializerSettings JsonSettings { get; } = JsonHelper.CreateDefaultSettings(); - /********* - ** Public methods - *********/ - /// Create an instance of the default JSON serializer settings. - public static JsonSerializerSettings CreateDefaultSettings() + /********* + ** Public methods + *********/ + /// Create an instance of the default JSON serializer settings. + public static JsonSerializerSettings CreateDefaultSettings() + { + return new() { - return new() - { - Formatting = Formatting.Indented, - ObjectCreationHandling = ObjectCreationHandling.Replace, // avoid issue where default ICollection values are duplicated each time the config is loaded - Converters = new List + Formatting = Formatting.Indented, + ObjectCreationHandling = ObjectCreationHandling.Replace, // avoid issue where default ICollection values are duplicated each time the config is loaded + Converters = new List { new SemanticVersionConverter(), new StringEnumConverter() } - }; - } + }; + } - /// Read a JSON file. - /// The model type. - /// The absolute file path. - /// The parsed content model. - /// Returns false if the file doesn't exist, else true. - /// The given is empty or invalid. - /// The file contains invalid JSON. - public bool ReadJsonFileIfExists(string fullPath, + /// Read a JSON file. + /// The model type. + /// The absolute file path. + /// The parsed content model. + /// Returns false if the file doesn't exist, else true. + /// The given is empty or invalid. + /// The file contains invalid JSON. + public bool ReadJsonFileIfExists(string fullPath, #if NET6_0_OR_GREATER - [NotNullWhen(true)] + [NotNullWhen(true)] #endif - out TModel? result - ) + out TModel? result + ) + { + // validate + if (string.IsNullOrWhiteSpace(fullPath)) + throw new ArgumentException("The file path is empty or invalid.", nameof(fullPath)); + + // read file + string json; + try + { + json = File.ReadAllText(fullPath); + } + catch (Exception ex) when (ex is DirectoryNotFoundException or FileNotFoundException) { - // validate - if (string.IsNullOrWhiteSpace(fullPath)) - throw new ArgumentException("The file path is empty or invalid.", nameof(fullPath)); + result = default; + return false; + } - // read file - string json; - try - { - json = File.ReadAllText(fullPath); - } - catch (Exception ex) when (ex is DirectoryNotFoundException or FileNotFoundException) - { - result = default; - return false; - } + // deserialize model + try + { + result = this.Deserialize(json); + return result != null; + } + catch (Exception ex) + { + string error = $"Can't parse JSON file at {fullPath}."; - // deserialize model - try + if (ex is JsonReaderException) { - result = this.Deserialize(json); - return result != null; - } - catch (Exception ex) - { - string error = $"Can't parse JSON file at {fullPath}."; - - if (ex is JsonReaderException) - { - error += " This doesn't seem to be valid JSON."; - if (json.Contains("“") || json.Contains("”")) - error += " Found curly quotes in the text; note that only straight quotes are allowed in JSON."; - } - error += $"\nTechnical details: {ex.Message}"; - throw new JsonReaderException(error); + error += " This doesn't seem to be valid JSON."; + if (json.Contains("“") || json.Contains("”")) + error += " Found curly quotes in the text; note that only straight quotes are allowed in JSON."; } + error += $"\nTechnical details: {ex.Message}"; + throw new JsonReaderException(error); } + } - /// Save to a JSON file. - /// The model type. - /// The absolute file path. - /// The model to save. - /// The given path is empty or invalid. - public void WriteJsonFile(string fullPath, TModel model) - where TModel : class - { - // validate - if (string.IsNullOrWhiteSpace(fullPath)) - throw new ArgumentException("The file path is empty or invalid.", nameof(fullPath)); + /// Save to a JSON file. + /// The model type. + /// The absolute file path. + /// The model to save. + /// The given path is empty or invalid. + public void WriteJsonFile(string fullPath, TModel model) + where TModel : class + { + // validate + if (string.IsNullOrWhiteSpace(fullPath)) + throw new ArgumentException("The file path is empty or invalid.", nameof(fullPath)); - // create directory if needed - string dir = Path.GetDirectoryName(fullPath)!; - if (dir == null) - throw new ArgumentException("The file path is invalid.", nameof(fullPath)); - if (!Directory.Exists(dir)) - Directory.CreateDirectory(dir); + // create directory if needed + string dir = Path.GetDirectoryName(fullPath)!; + if (dir == null) + throw new ArgumentException("The file path is invalid.", nameof(fullPath)); + if (!Directory.Exists(dir)) + Directory.CreateDirectory(dir); - // write file - string json = this.Serialize(model); - File.WriteAllText(fullPath, json); - } + // write file + string json = this.Serialize(model); + File.WriteAllText(fullPath, json); + } - /// Deserialize JSON text if possible. - /// The model type. - /// The raw JSON text. - public TModel? Deserialize(string json) + /// Deserialize JSON text if possible. + /// The model type. + /// The raw JSON text. + public TModel? Deserialize(string json) + { + try { - try - { - return JsonConvert.DeserializeObject(json, this.JsonSettings); - } - catch (JsonReaderException) + return JsonConvert.DeserializeObject(json, this.JsonSettings); + } + catch (JsonReaderException) + { + // try replacing curly quotes + if (json.Contains("“") || json.Contains("”")) { - // try replacing curly quotes - if (json.Contains("“") || json.Contains("”")) + try { - try - { - return JsonConvert.DeserializeObject(json.Replace('“', '"').Replace('”', '"'), this.JsonSettings) - ?? throw new InvalidOperationException($"Couldn't deserialize model type '{typeof(TModel)}' from empty or null JSON."); - } - catch { /* rethrow original error */ } + return JsonConvert.DeserializeObject(json.Replace('“', '"').Replace('”', '"'), this.JsonSettings) + ?? throw new InvalidOperationException($"Couldn't deserialize model type '{typeof(TModel)}' from empty or null JSON."); } - - throw; + catch { /* rethrow original error */ } } - } - /// Serialize a model to JSON text. - /// The model type. - /// The model to serialize. - /// The formatting to apply. - public string Serialize(TModel model, Formatting formatting = Formatting.Indented) - { - return JsonConvert.SerializeObject(model, formatting, this.JsonSettings); + throw; } + } - /// Get a low-level JSON serializer matching the . - public JsonSerializer GetSerializer() - { - return JsonSerializer.CreateDefault(this.JsonSettings); - } + /// Serialize a model to JSON text. + /// The model type. + /// The model to serialize. + /// The formatting to apply. + public string Serialize(TModel model, Formatting formatting = Formatting.Indented) + { + return JsonConvert.SerializeObject(model, formatting, this.JsonSettings); + } + + /// Get a low-level JSON serializer matching the . + public JsonSerializer GetSerializer() + { + return JsonSerializer.CreateDefault(this.JsonSettings); } } diff --git a/src/SMAPI.Toolkit/Serialization/Models/Manifest.cs b/src/SMAPI.Toolkit/Serialization/Models/Manifest.cs index 63bbc5d2c..6db05d532 100644 --- a/src/SMAPI.Toolkit/Serialization/Models/Manifest.cs +++ b/src/SMAPI.Toolkit/Serialization/Models/Manifest.cs @@ -5,162 +5,178 @@ using Newtonsoft.Json; using StardewModdingAPI.Toolkit.Serialization.Converters; -namespace StardewModdingAPI.Toolkit.Serialization.Models +namespace StardewModdingAPI.Toolkit.Serialization.Models; + +/// +public class Manifest : IManifest { - /// - public class Manifest : IManifest + /********* + ** Accessors + *********/ + /// + public string Name { get; } + + /// + public string Description { get; } + + /// + public string Author { get; } + + /// + public ISemanticVersion Version { get; } + + /// + public ISemanticVersion? MinimumApiVersion { get; } + + /// + public ISemanticVersion? MinimumGameVersion { get; } + + /// + public string? EntryDll { get; } + + /// + [JsonConverter(typeof(ManifestContentPackForConverter))] + public IManifestContentPackFor? ContentPackFor { get; } + + /// + [JsonConverter(typeof(ManifestDependencyArrayConverter))] + public IManifestDependency[] Dependencies { get; } + + /// + [JsonConverter(typeof(ManifestPrivateAssemblyArrayConverter))] + public IManifestPrivateAssembly[] PrivateAssemblies { get; } + + /// + public string[] UpdateKeys { get; private set; } + + /// + public string UniqueID { get; } + + /// + [JsonExtensionData] + public IDictionary ExtraFields { get; } = new Dictionary(); + + + /********* + ** Public methods + *********/ + /// Construct an instance for a transitional content pack. + /// The unique mod ID. + /// The mod name. + /// The mod author's name. + /// A brief description of the mod. + /// The mod version. + /// The modID which will read this as a content pack. + public Manifest(string uniqueID, string name, string author, string description, ISemanticVersion version, string? contentPackFor = null) + : this( + uniqueId: uniqueID, + name: name, + author: author, + description: description, + version: version, + minimumApiVersion: null, + minimumGameVersion: null, + entryDll: null, + contentPackFor: contentPackFor != null + ? new ManifestContentPackFor(contentPackFor, null) + : null, + privateAssemblies: null, + dependencies: null, + updateKeys: null + ) + { } + + /// Construct an instance for a transitional content pack. + /// The unique mod ID. + /// The mod name. + /// The mod author's name. + /// A brief description of the mod. + /// The mod version. + /// The minimum SMAPI version required by this mod, if any. + /// The minimum Stardew Valley version required by this mod, if any. + /// The name of the DLL in the directory that has the Entry method. Mutually exclusive with . + /// The modID which will read this as a content pack. + /// The other mods that must be loaded before this mod. + /// The names of assemblies that should be private to this mod. These assemblies will not be directly accessible by other mods and will be ignored when a mod tries to use an assembly with the same name in a public manner. + /// The namespaced mod IDs to query for updates (like Nexus:541). + [JsonConstructor] + public Manifest(string uniqueId, string name, string author, string description, ISemanticVersion version, ISemanticVersion? minimumApiVersion, ISemanticVersion? minimumGameVersion, string? entryDll, IManifestContentPackFor? contentPackFor, IManifestDependency[]? dependencies, IManifestPrivateAssembly[]? privateAssemblies, string[]? updateKeys) { - /********* - ** Accessors - *********/ - /// - public string Name { get; } - - /// - public string Description { get; } - - /// - public string Author { get; } - - /// - public ISemanticVersion Version { get; } - - /// - public ISemanticVersion? MinimumApiVersion { get; } - - /// - public ISemanticVersion? MinimumGameVersion { get; } - - /// - public string? EntryDll { get; } - - /// - [JsonConverter(typeof(ManifestContentPackForConverter))] - public IManifestContentPackFor? ContentPackFor { get; } - - /// - [JsonConverter(typeof(ManifestDependencyArrayConverter))] - public IManifestDependency[] Dependencies { get; } - - /// - public string[] UpdateKeys { get; private set; } - - /// - public string UniqueID { get; } - - /// - [JsonExtensionData] - public IDictionary ExtraFields { get; } = new Dictionary(); - - - /********* - ** Public methods - *********/ - /// Construct an instance for a transitional content pack. - /// The unique mod ID. - /// The mod name. - /// The mod author's name. - /// A brief description of the mod. - /// The mod version. - /// The modID which will read this as a content pack. - public Manifest(string uniqueID, string name, string author, string description, ISemanticVersion version, string? contentPackFor = null) - : this( - uniqueId: uniqueID, - name: name, - author: author, - description: description, - version: version, - minimumApiVersion: null, - minimumGameVersion: null, - entryDll: null, - contentPackFor: contentPackFor != null - ? new ManifestContentPackFor(contentPackFor, null) - : null, - dependencies: null, - updateKeys: null - ) - { } - - /// Construct an instance for a transitional content pack. - /// The unique mod ID. - /// The mod name. - /// The mod author's name. - /// A brief description of the mod. - /// The mod version. - /// The minimum SMAPI version required by this mod, if any. - /// The minimum Stardew Valley version required by this mod, if any. - /// The name of the DLL in the directory that has the Entry method. Mutually exclusive with . - /// The modID which will read this as a content pack. - /// The other mods that must be loaded before this mod. - /// The namespaced mod IDs to query for updates (like Nexus:541). - [JsonConstructor] - public Manifest(string uniqueId, string name, string author, string description, ISemanticVersion version, ISemanticVersion? minimumApiVersion, ISemanticVersion? minimumGameVersion, string? entryDll, IManifestContentPackFor? contentPackFor, IManifestDependency[]? dependencies, string[]? updateKeys) - { - this.UniqueID = this.NormalizeField(uniqueId); - this.Name = this.NormalizeField(name, replaceSquareBrackets: true); - this.Author = this.NormalizeField(author); - this.Description = this.NormalizeField(description); - this.Version = version; - this.MinimumApiVersion = minimumApiVersion; - this.MinimumGameVersion = minimumGameVersion; - this.EntryDll = this.NormalizeField(entryDll); - this.ContentPackFor = contentPackFor; - this.Dependencies = dependencies ?? Array.Empty(); - this.UpdateKeys = updateKeys ?? Array.Empty(); - } + this.UniqueID = this.NormalizeField(uniqueId); + this.Name = this.NormalizeField(name, replaceSquareBrackets: true); + this.Author = this.NormalizeField(author); + this.Description = this.NormalizeField(description); + this.Version = version; + this.MinimumApiVersion = minimumApiVersion; + this.MinimumGameVersion = minimumGameVersion; + this.EntryDll = this.NormalizeField(entryDll); + this.ContentPackFor = contentPackFor; + this.Dependencies = dependencies ?? Array.Empty(); + this.PrivateAssemblies = privateAssemblies ?? Array.Empty(); + this.UpdateKeys = updateKeys ?? Array.Empty(); + } - /// Override the update keys loaded from the mod info. - /// The new update keys to set. - internal void OverrideUpdateKeys(params string[] updateKeys) - { - this.UpdateKeys = updateKeys; - } + /// Override the update keys loaded from the mod info. + /// The new update keys to set. + internal void OverrideUpdateKeys(params string[] updateKeys) + { + this.UpdateKeys = updateKeys; + } - /********* - ** Private methods - *********/ - /// Normalize a manifest field to strip newlines, trim whitespace, and optionally strip square brackets. - /// The input to strip. - /// Whether to replace square brackets with round ones. This is used in the mod name to avoid breaking the log format. + /********* + ** Private methods + *********/ + /// Normalize a manifest field to strip newlines, trim whitespace, and optionally strip square brackets. + /// The input to strip. + /// Whether to replace square brackets with round ones. This is used in the mod name to avoid breaking the log format. #if NET6_0_OR_GREATER - [return: NotNullIfNotNull("input")] + [return: NotNullIfNotNull("input")] #endif - private string? NormalizeField(string? input, bool replaceSquareBrackets = false) + private string? NormalizeField(string? input, bool replaceSquareBrackets = false) + { + input = input?.Trim(); + + if (!string.IsNullOrEmpty(input)) { - input = input?.Trim(); + StringBuilder? builder = null; - if (!string.IsNullOrEmpty(input)) + for (int i = 0; i < input.Length; i++) { - StringBuilder? builder = null; - - for (int i = 0; i < input.Length; i++) + switch (input[i]) { - switch (input[i]) - { - case '\r': - case '\n': - builder ??= new StringBuilder(input); - builder[i] = ' '; - break; - - case '[' when replaceSquareBrackets: - builder ??= new StringBuilder(input); - builder[i] = '('; - break; - - case ']' when replaceSquareBrackets: - builder ??= new StringBuilder(input); - builder[i] = ')'; - break; - } + case '\r': + case '\n': + builder ??= new StringBuilder(input); + builder[i] = ' '; + break; + + case '[' when replaceSquareBrackets: + builder ??= new StringBuilder(input); + builder[i] = '('; + break; + + case ']' when replaceSquareBrackets: + builder ??= new StringBuilder(input); + builder[i] = ')'; + break; } - - if (builder != null) - input = builder.ToString(); } - return input; + if (builder != null) + input = builder.ToString(); } + + return input; + } + + /// Normalize whitespace in a raw string. + /// The input to strip. +#if NET6_0_OR_GREATER + [return: NotNullIfNotNull("input")] +#endif + internal static string? NormalizeWhitespace(string? input) + { + return input?.Trim(); } } diff --git a/src/SMAPI.Toolkit/Serialization/Models/ManifestContentPackFor.cs b/src/SMAPI.Toolkit/Serialization/Models/ManifestContentPackFor.cs index fe425d3c5..282ab4017 100644 --- a/src/SMAPI.Toolkit/Serialization/Models/ManifestContentPackFor.cs +++ b/src/SMAPI.Toolkit/Serialization/Models/ManifestContentPackFor.cs @@ -1,44 +1,27 @@ -using System.Diagnostics.CodeAnalysis; +namespace StardewModdingAPI.Toolkit.Serialization.Models; -namespace StardewModdingAPI.Toolkit.Serialization.Models +/// Indicates which mod can read the content pack represented by the containing manifest. +public class ManifestContentPackFor : IManifestContentPackFor { - /// Indicates which mod can read the content pack represented by the containing manifest. - public class ManifestContentPackFor : IManifestContentPackFor - { - /********* - ** Accessors - *********/ - /// The unique ID of the mod which can read this content pack. - public string UniqueID { get; } - - /// The minimum required version (if any). - public ISemanticVersion? MinimumVersion { get; } + /********* + ** Accessors + *********/ + /// The unique ID of the mod which can read this content pack. + public string UniqueID { get; } + /// The minimum required version (if any). + public ISemanticVersion? MinimumVersion { get; } - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The unique ID of the mod which can read this content pack. - /// The minimum required version (if any). - public ManifestContentPackFor(string uniqueId, ISemanticVersion? minimumVersion) - { - this.UniqueID = this.NormalizeWhitespace(uniqueId); - this.MinimumVersion = minimumVersion; - } - - /********* - ** Private methods - *********/ - /// Normalize whitespace in a raw string. - /// The input to strip. -#if NET6_0_OR_GREATER - [return: NotNullIfNotNull("input")] -#endif - private string? NormalizeWhitespace(string? input) - { - return input?.Trim(); - } + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The unique ID of the mod which can read this content pack. + /// The minimum required version (if any). + public ManifestContentPackFor(string uniqueId, ISemanticVersion? minimumVersion) + { + this.UniqueID = Manifest.NormalizeWhitespace(uniqueId); + this.MinimumVersion = minimumVersion; } } diff --git a/src/SMAPI.Toolkit/Serialization/Models/ManifestDependency.cs b/src/SMAPI.Toolkit/Serialization/Models/ManifestDependency.cs index 9d108a789..cf6094b95 100644 --- a/src/SMAPI.Toolkit/Serialization/Models/ManifestDependency.cs +++ b/src/SMAPI.Toolkit/Serialization/Models/ManifestDependency.cs @@ -1,65 +1,49 @@ -using System.Diagnostics.CodeAnalysis; using Newtonsoft.Json; -namespace StardewModdingAPI.Toolkit.Serialization.Models +namespace StardewModdingAPI.Toolkit.Serialization.Models; + +/// A mod dependency listed in a mod manifest. +public class ManifestDependency : IManifestDependency { - /// A mod dependency listed in a mod manifest. - public class ManifestDependency : IManifestDependency + /********* + ** Accessors + *********/ + /// The unique mod ID to require. + public string UniqueID { get; } + + /// The minimum required version (if any). + public ISemanticVersion? MinimumVersion { get; } + + /// Whether the dependency must be installed to use the mod. + public bool IsRequired { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The unique mod ID to require. + /// The minimum required version (if any). + /// Whether the dependency must be installed to use the mod. + public ManifestDependency(string uniqueID, string? minimumVersion, bool required = true) + : this( + uniqueID: uniqueID, + minimumVersion: !string.IsNullOrWhiteSpace(minimumVersion) + ? new SemanticVersion(minimumVersion) + : null, + required: required + ) + { } + + /// Construct an instance. + /// The unique mod ID to require. + /// The minimum required version (if any). + /// Whether the dependency must be installed to use the mod. + [JsonConstructor] + public ManifestDependency(string uniqueID, ISemanticVersion? minimumVersion, bool required = true) { - /********* - ** Accessors - *********/ - /// The unique mod ID to require. - public string UniqueID { get; } - - /// The minimum required version (if any). - public ISemanticVersion? MinimumVersion { get; } - - /// Whether the dependency must be installed to use the mod. - public bool IsRequired { get; } - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The unique mod ID to require. - /// The minimum required version (if any). - /// Whether the dependency must be installed to use the mod. - public ManifestDependency(string uniqueID, string? minimumVersion, bool required = true) - : this( - uniqueID: uniqueID, - minimumVersion: !string.IsNullOrWhiteSpace(minimumVersion) - ? new SemanticVersion(minimumVersion) - : null, - required: required - ) - { } - - /// Construct an instance. - /// The unique mod ID to require. - /// The minimum required version (if any). - /// Whether the dependency must be installed to use the mod. - [JsonConstructor] - public ManifestDependency(string uniqueID, ISemanticVersion? minimumVersion, bool required = true) - { - this.UniqueID = this.NormalizeWhitespace(uniqueID); - this.MinimumVersion = minimumVersion; - this.IsRequired = required; - } - - - /********* - ** Private methods - *********/ - /// Normalize whitespace in a raw string. - /// The input to strip. -#if NET6_0_OR_GREATER - [return: NotNullIfNotNull("input")] -#endif - private string? NormalizeWhitespace(string? input) - { - return input?.Trim(); - } + this.UniqueID = Manifest.NormalizeWhitespace(uniqueID); + this.MinimumVersion = minimumVersion; + this.IsRequired = required; } } diff --git a/src/SMAPI.Toolkit/Serialization/Models/ManifestPrivateAssembly.cs b/src/SMAPI.Toolkit/Serialization/Models/ManifestPrivateAssembly.cs new file mode 100644 index 000000000..6323837af --- /dev/null +++ b/src/SMAPI.Toolkit/Serialization/Models/ManifestPrivateAssembly.cs @@ -0,0 +1,27 @@ +namespace StardewModdingAPI.Toolkit.Serialization.Models; + +/// +public class ManifestPrivateAssembly : IManifestPrivateAssembly +{ + /********* + ** Accessors + *********/ + /// + public string Name { get; } + + /// + public bool UsedDynamically { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The assembly name. + /// Whether to disable warnings that an assembly appears to be unused, e.g. because it's accessed via reflection. + public ManifestPrivateAssembly(string name, bool usedDynamically) + { + this.Name = Manifest.NormalizeWhitespace(name); + this.UsedDynamically = usedDynamically; + } +} diff --git a/src/SMAPI.Toolkit/Serialization/SParseException.cs b/src/SMAPI.Toolkit/Serialization/SParseException.cs index c2b3f68ee..3ce1bee88 100644 --- a/src/SMAPI.Toolkit/Serialization/SParseException.cs +++ b/src/SMAPI.Toolkit/Serialization/SParseException.cs @@ -1,17 +1,16 @@ using System; -namespace StardewModdingAPI.Toolkit.Serialization +namespace StardewModdingAPI.Toolkit.Serialization; + +/// A format exception which provides a user-facing error message. +internal class SParseException : FormatException { - /// A format exception which provides a user-facing error message. - internal class SParseException : FormatException - { - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The error message. - /// The underlying exception, if any. - public SParseException(string message, Exception? ex = null) - : base(message, ex) { } - } + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The error message. + /// The underlying exception, if any. + public SParseException(string message, Exception? ex = null) + : base(message, ex) { } } diff --git a/src/SMAPI.Toolkit/Utilities/EnvironmentUtility.cs b/src/SMAPI.Toolkit/Utilities/EnvironmentUtility.cs index 1791c5b3c..e1b70f90f 100644 --- a/src/SMAPI.Toolkit/Utilities/EnvironmentUtility.cs +++ b/src/SMAPI.Toolkit/Utilities/EnvironmentUtility.cs @@ -1,48 +1,47 @@ using System; using StardewModdingAPI.Toolkit.Framework; -namespace StardewModdingAPI.Toolkit.Utilities +namespace StardewModdingAPI.Toolkit.Utilities; + +/// Provides methods for fetching environment information. +public static class EnvironmentUtility { - /// Provides methods for fetching environment information. - public static class EnvironmentUtility + /********* + ** Fields + *********/ + /// The cached platform. + private static Platform? CachedPlatform; + + + /********* + ** Public methods + *********/ + /// Detect the current OS. + public static Platform DetectPlatform() { - /********* - ** Fields - *********/ - /// The cached platform. - private static Platform? CachedPlatform; - - - /********* - ** Public methods - *********/ - /// Detect the current OS. - public static Platform DetectPlatform() - { - Platform? platform = EnvironmentUtility.CachedPlatform; + Platform? platform = EnvironmentUtility.CachedPlatform; - if (platform == null) - { - string rawPlatform = LowLevelEnvironmentUtility.DetectPlatform(); - EnvironmentUtility.CachedPlatform = platform = (Platform)Enum.Parse(typeof(Platform), rawPlatform, ignoreCase: true); - } - - return platform.Value; + if (platform == null) + { + string rawPlatform = LowLevelEnvironmentUtility.DetectPlatform(); + EnvironmentUtility.CachedPlatform = platform = (Platform)Enum.Parse(typeof(Platform), rawPlatform, ignoreCase: true); } + return platform.Value; + } - /// Get the human-readable OS name and version. - /// The current platform. - public static string GetFriendlyPlatformName(Platform platform) - { - return LowLevelEnvironmentUtility.GetFriendlyPlatformName(platform.ToString()); - } - /// Get whether an executable is 64-bit. - /// The absolute path to the assembly file. - public static bool Is64BitAssembly(string path) - { - return LowLevelEnvironmentUtility.Is64BitAssembly(path); - } + /// Get the human-readable OS name and version. + /// The current platform. + public static string GetFriendlyPlatformName(Platform platform) + { + return LowLevelEnvironmentUtility.GetFriendlyPlatformName(platform.ToString()); + } + + /// Get whether an executable is 64-bit. + /// The absolute path to the assembly file. + public static bool Is64BitAssembly(string path) + { + return LowLevelEnvironmentUtility.Is64BitAssembly(path); } } diff --git a/src/SMAPI.Toolkit/Utilities/FileUtilities.cs b/src/SMAPI.Toolkit/Utilities/FileUtilities.cs index a6bf59296..e8a4e95e0 100644 --- a/src/SMAPI.Toolkit/Utilities/FileUtilities.cs +++ b/src/SMAPI.Toolkit/Utilities/FileUtilities.cs @@ -3,57 +3,63 @@ using System.Security.Cryptography; using System.Threading; -namespace StardewModdingAPI.Toolkit.Utilities +namespace StardewModdingAPI.Toolkit.Utilities; + +/// Provides utilities for dealing with files. +public static class FileUtilities { - /// Provides utilities for dealing with files. - public static class FileUtilities + /********* + ** Public methods + *********/ + /// Delete a file or folder regardless of file permissions, and block until deletion completes. + /// The file or folder to reset. + public static void ForceDelete(FileSystemInfo entry) { - /********* - ** Public methods - *********/ - /// Delete a file or folder regardless of file permissions, and block until deletion completes. - /// The file or folder to reset. - public static void ForceDelete(FileSystemInfo entry) + // ignore if already deleted + entry.Refresh(); + if (!entry.Exists) + return; + + // delete children + if (entry is DirectoryInfo folder) + { + foreach (FileSystemInfo child in folder.GetFileSystemInfos()) + FileUtilities.ForceDelete(child); + } + + // reset permissions & delete + entry.Attributes = FileAttributes.Normal; + entry.Delete(); + + // wait for deletion to finish + for (int i = 0; i < 10; i++) { - // ignore if already deleted - entry.Refresh(); - if (!entry.Exists) - return; - - // delete children - if (entry is DirectoryInfo folder) - { - foreach (FileSystemInfo child in folder.GetFileSystemInfos()) - FileUtilities.ForceDelete(child); - } - - // reset permissions & delete - entry.Attributes = FileAttributes.Normal; - entry.Delete(); - - // wait for deletion to finish - for (int i = 0; i < 10; i++) - { - entry.Refresh(); - if (entry.Exists) - Thread.Sleep(500); - } - - // throw exception if deletion didn't happen before timeout entry.Refresh(); if (entry.Exists) - throw new IOException($"Timed out trying to delete {entry.FullName}"); + Thread.Sleep(500); } - /// Get the MD5 hash for a file. - /// The absolute file path. - public static string GetFileHash(string absolutePath) - { - using FileStream stream = File.OpenRead(absolutePath); - using MD5 md5 = MD5.Create(); + // throw exception if deletion didn't happen before timeout + entry.Refresh(); + if (entry.Exists) + throw new IOException($"Timed out trying to delete {entry.FullName}"); + } - byte[] hash = md5.ComputeHash(stream); - return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); - } + /// Get the MD5 hash for a file. + /// The absolute file path. + public static string GetFileHash(string absolutePath) + { + using MD5 md5 = MD5.Create(); + return FileUtilities.GetFileHash(md5, absolutePath); + } + + /// Get the MD5 hash for a file. + /// The MD5 implementation to use. + /// The absolute file path. + public static string GetFileHash(MD5 md5, string absolutePath) + { + using FileStream stream = File.OpenRead(absolutePath); + byte[] hash = md5.ComputeHash(stream); + return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); } } diff --git a/src/SMAPI.Toolkit/Utilities/PathLookups/CaseInsensitiveFileLookup.cs b/src/SMAPI.Toolkit/Utilities/PathLookups/CaseInsensitiveFileLookup.cs index 496d54c38..1191733af 100644 --- a/src/SMAPI.Toolkit/Utilities/PathLookups/CaseInsensitiveFileLookup.cs +++ b/src/SMAPI.Toolkit/Utilities/PathLookups/CaseInsensitiveFileLookup.cs @@ -2,103 +2,102 @@ using System.Collections.Generic; using System.IO; -namespace StardewModdingAPI.Toolkit.Utilities.PathLookups +namespace StardewModdingAPI.Toolkit.Utilities.PathLookups; + +/// An API for case-insensitive file lookups within a root directory. +internal class CaseInsensitiveFileLookup : IFileLookup { - /// An API for case-insensitive file lookups within a root directory. - internal class CaseInsensitiveFileLookup : IFileLookup + /********* + ** Fields + *********/ + /// The root directory path for relative paths. + private readonly string RootPath; + + /// A case-insensitive lookup of file paths within the . Each path is listed in both file path and asset name format, so it's usable in both contexts without needing to re-parse paths. + private readonly Lazy> RelativePathCache; + + /// The case-insensitive file lookups by root path. + private static readonly Dictionary CachedRoots = new(StringComparer.OrdinalIgnoreCase); + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The root directory path for relative paths. + /// Which directories to scan from the root. + public CaseInsensitiveFileLookup(string rootPath, SearchOption searchOption = SearchOption.AllDirectories) { - /********* - ** Fields - *********/ - /// The root directory path for relative paths. - private readonly string RootPath; - - /// A case-insensitive lookup of file paths within the . Each path is listed in both file path and asset name format, so it's usable in both contexts without needing to re-parse paths. - private readonly Lazy> RelativePathCache; - - /// The case-insensitive file lookups by root path. - private static readonly Dictionary CachedRoots = new(StringComparer.OrdinalIgnoreCase); - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The root directory path for relative paths. - /// Which directories to scan from the root. - public CaseInsensitiveFileLookup(string rootPath, SearchOption searchOption = SearchOption.AllDirectories) - { - this.RootPath = PathUtilities.NormalizePath(rootPath); - this.RelativePathCache = new(() => this.GetRelativePathCache(searchOption)); - } + this.RootPath = PathUtilities.NormalizePath(rootPath); + this.RelativePathCache = new(() => this.GetRelativePathCache(searchOption)); + } - /// - public FileInfo GetFile(string relativePath) - { - // invalid path - if (string.IsNullOrWhiteSpace(relativePath)) - throw new InvalidOperationException("Can't get a file from an empty relative path."); - - // already cached - if (this.RelativePathCache.Value.TryGetValue(relativePath, out string? resolved)) - return new(Path.Combine(this.RootPath, resolved)); - - // keep capitalization as-is - FileInfo file = new(Path.Combine(this.RootPath, relativePath)); - if (file.Exists) - this.RelativePathCache.Value[relativePath] = relativePath; - return file; - } + /// + public FileInfo GetFile(string relativePath) + { + // invalid path + if (string.IsNullOrWhiteSpace(relativePath)) + throw new InvalidOperationException("Can't get a file from an empty relative path."); - /// - public void Add(string relativePath) - { - // skip if cache isn't created yet (no need to add files manually in that case) - if (!this.RelativePathCache.IsValueCreated) - return; + // already cached + if (this.RelativePathCache.Value.TryGetValue(relativePath, out string? resolved)) + return new(Path.Combine(this.RootPath, resolved)); - // skip if already cached - if (this.RelativePathCache.Value.ContainsKey(relativePath)) - return; + // keep capitalization as-is + FileInfo file = new(Path.Combine(this.RootPath, relativePath)); + if (file.Exists) + this.RelativePathCache.Value[relativePath] = relativePath; + return file; + } - // make sure path exists - relativePath = PathUtilities.NormalizePath(relativePath); - if (!File.Exists(Path.Combine(this.RootPath, relativePath))) - throw new InvalidOperationException($"Can't add relative path '{relativePath}' to the case-insensitive cache for '{this.RootPath}' because that file doesn't exist."); + /// + public void Add(string relativePath) + { + // skip if cache isn't created yet (no need to add files manually in that case) + if (!this.RelativePathCache.IsValueCreated) + return; - // cache path - this.RelativePathCache.Value[relativePath] = relativePath; - } + // skip if already cached + if (this.RelativePathCache.Value.ContainsKey(relativePath)) + return; - /// Get a cached dictionary of relative paths within a root path, for case-insensitive file lookups. - /// The root path to scan. - public static CaseInsensitiveFileLookup GetCachedFor(string rootPath) - { - rootPath = PathUtilities.NormalizePath(rootPath); + // make sure path exists + relativePath = PathUtilities.NormalizePath(relativePath); + if (!File.Exists(Path.Combine(this.RootPath, relativePath))) + throw new InvalidOperationException($"Can't add relative path '{relativePath}' to the case-insensitive cache for '{this.RootPath}' because that file doesn't exist."); - if (!CaseInsensitiveFileLookup.CachedRoots.TryGetValue(rootPath, out CaseInsensitiveFileLookup? cache)) - CaseInsensitiveFileLookup.CachedRoots[rootPath] = cache = new CaseInsensitiveFileLookup(rootPath); + // cache path + this.RelativePathCache.Value[relativePath] = relativePath; + } - return cache; - } + /// Get a cached dictionary of relative paths within a root path, for case-insensitive file lookups. + /// The root path to scan. + public static CaseInsensitiveFileLookup GetCachedFor(string rootPath) + { + rootPath = PathUtilities.NormalizePath(rootPath); + if (!CaseInsensitiveFileLookup.CachedRoots.TryGetValue(rootPath, out CaseInsensitiveFileLookup? cache)) + CaseInsensitiveFileLookup.CachedRoots[rootPath] = cache = new CaseInsensitiveFileLookup(rootPath); + + return cache; + } - /********* - ** Private methods - *********/ - /// Get a case-insensitive lookup of file paths (see ). - /// Which directories to scan from the root. - private Dictionary GetRelativePathCache(SearchOption searchOption) - { - Dictionary cache = new(StringComparer.OrdinalIgnoreCase); - foreach (string path in Directory.EnumerateFiles(this.RootPath, "*", searchOption)) - { - string relativePath = path.Substring(this.RootPath.Length + 1); - cache[relativePath] = relativePath; - } + /********* + ** Private methods + *********/ + /// Get a case-insensitive lookup of file paths (see ). + /// Which directories to scan from the root. + private Dictionary GetRelativePathCache(SearchOption searchOption) + { + Dictionary cache = new(StringComparer.OrdinalIgnoreCase); - return cache; + foreach (string path in Directory.EnumerateFiles(this.RootPath, "*", searchOption)) + { + string relativePath = path.Substring(this.RootPath.Length + 1); + cache[relativePath] = relativePath; } + + return cache; } } diff --git a/src/SMAPI.Toolkit/Utilities/PathLookups/IFileLookup.cs b/src/SMAPI.Toolkit/Utilities/PathLookups/IFileLookup.cs index d43b5141b..1e15f4d6f 100644 --- a/src/SMAPI.Toolkit/Utilities/PathLookups/IFileLookup.cs +++ b/src/SMAPI.Toolkit/Utilities/PathLookups/IFileLookup.cs @@ -1,16 +1,15 @@ using System.IO; -namespace StardewModdingAPI.Toolkit.Utilities.PathLookups +namespace StardewModdingAPI.Toolkit.Utilities.PathLookups; + +/// An API for file lookups within a root directory. +internal interface IFileLookup { - /// An API for file lookups within a root directory. - internal interface IFileLookup - { - /// Get the file for a given relative file path, if it exists. - /// The relative path. - FileInfo GetFile(string relativePath); + /// Get the file for a given relative file path, if it exists. + /// The relative path. + FileInfo GetFile(string relativePath); - /// Add a relative path that was just created by a SMAPI API. - /// The relative path. - void Add(string relativePath); - } + /// Add a relative path that was just created by a SMAPI API. + /// The relative path. + void Add(string relativePath); } diff --git a/src/SMAPI.Toolkit/Utilities/PathLookups/MinimalFileLookup.cs b/src/SMAPI.Toolkit/Utilities/PathLookups/MinimalFileLookup.cs index 414b569be..27603e2e8 100644 --- a/src/SMAPI.Toolkit/Utilities/PathLookups/MinimalFileLookup.cs +++ b/src/SMAPI.Toolkit/Utilities/PathLookups/MinimalFileLookup.cs @@ -1,52 +1,51 @@ using System.Collections.Generic; using System.IO; -namespace StardewModdingAPI.Toolkit.Utilities.PathLookups +namespace StardewModdingAPI.Toolkit.Utilities.PathLookups; + +/// An API for file lookups within a root directory with minimal preprocessing. +internal class MinimalFileLookup : IFileLookup { - /// An API for file lookups within a root directory with minimal preprocessing. - internal class MinimalFileLookup : IFileLookup + /********* + ** Accessors + *********/ + /// The file lookups by root path. + private static readonly Dictionary CachedRoots = new(); + + /// The root directory path for relative paths. + private readonly string RootPath; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The root directory path for relative paths. + public MinimalFileLookup(string rootPath) + { + this.RootPath = rootPath; + } + + /// + public FileInfo GetFile(string relativePath) + { + return new( + Path.Combine(this.RootPath, PathUtilities.NormalizePath(relativePath)) + ); + } + + /// + public void Add(string relativePath) { } + + /// Get a cached dictionary of relative paths within a root path, for case-insensitive file lookups. + /// The root path to scan. + public static MinimalFileLookup GetCachedFor(string rootPath) { - /********* - ** Accessors - *********/ - /// The file lookups by root path. - private static readonly Dictionary CachedRoots = new(); - - /// The root directory path for relative paths. - private readonly string RootPath; - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The root directory path for relative paths. - public MinimalFileLookup(string rootPath) - { - this.RootPath = rootPath; - } - - /// - public FileInfo GetFile(string relativePath) - { - return new( - Path.Combine(this.RootPath, PathUtilities.NormalizePath(relativePath)) - ); - } - - /// - public void Add(string relativePath) { } - - /// Get a cached dictionary of relative paths within a root path, for case-insensitive file lookups. - /// The root path to scan. - public static MinimalFileLookup GetCachedFor(string rootPath) - { - rootPath = PathUtilities.NormalizePath(rootPath); - - if (!MinimalFileLookup.CachedRoots.TryGetValue(rootPath, out MinimalFileLookup? lookup)) - MinimalFileLookup.CachedRoots[rootPath] = lookup = new MinimalFileLookup(rootPath); - - return lookup; - } + rootPath = PathUtilities.NormalizePath(rootPath); + + if (!MinimalFileLookup.CachedRoots.TryGetValue(rootPath, out MinimalFileLookup? lookup)) + MinimalFileLookup.CachedRoots[rootPath] = lookup = new MinimalFileLookup(rootPath); + + return lookup; } } diff --git a/src/SMAPI.Toolkit/Utilities/PathUtilities.cs b/src/SMAPI.Toolkit/Utilities/PathUtilities.cs index f397ffcd5..94a942d96 100644 --- a/src/SMAPI.Toolkit/Utilities/PathUtilities.cs +++ b/src/SMAPI.Toolkit/Utilities/PathUtilities.cs @@ -5,166 +5,184 @@ using System.Linq; using System.Text.RegularExpressions; -namespace StardewModdingAPI.Toolkit.Utilities +namespace StardewModdingAPI.Toolkit.Utilities; + +/// Provides utilities for normalizing file paths. +public static class PathUtilities { - /// Provides utilities for normalizing file paths. - public static class PathUtilities + /********* + ** Fields + *********/ + /// The root prefix for a Windows UNC path. + private const string WindowsUncRoot = @"\\"; + + + /********* + ** Accessors + *********/ + /// The possible directory separator characters in a file path. + public static readonly char[] PossiblePathSeparators = new[] { '/', '\\', Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }.Distinct().ToArray(); + + /// The preferred directory separator character in a file path. + public static readonly char PreferredPathSeparator = Path.DirectorySeparatorChar; + + /// The preferred directory separator character in an asset key. + public static readonly char PreferredAssetSeparator = '/'; + + + /********* + ** Public methods + *********/ + /// Get the segments from a path (e.g. /usr/bin/example => usr, bin, and example). + /// The path to split. + /// The number of segments to match. Any additional segments will be merged into the last returned part. + [Pure] + public static string[] GetSegments(string? path, int? limit = null) { - /********* - ** Fields - *********/ - /// The root prefix for a Windows UNC path. - private const string WindowsUncRoot = @"\\"; - - - /********* - ** Accessors - *********/ - /// The possible directory separator characters in a file path. - public static readonly char[] PossiblePathSeparators = new[] { '/', '\\', Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }.Distinct().ToArray(); - - /// The preferred directory separator character in a file path. - public static readonly char PreferredPathSeparator = Path.DirectorySeparatorChar; - - /// The preferred directory separator character in an asset key. - public static readonly char PreferredAssetSeparator = '/'; - - - /********* - ** Public methods - *********/ - /// Get the segments from a path (e.g. /usr/bin/example => usr, bin, and example). - /// The path to split. - /// The number of segments to match. Any additional segments will be merged into the last returned part. - [Pure] - public static string[] GetSegments(string? path, int? limit = null) - { - if (path == null) - return Array.Empty(); + if (path == null) + return Array.Empty(); - return limit.HasValue - ? path.Split(PathUtilities.PossiblePathSeparators, limit.Value, StringSplitOptions.RemoveEmptyEntries) - : path.Split(PathUtilities.PossiblePathSeparators, StringSplitOptions.RemoveEmptyEntries); - } + return limit.HasValue + ? path.Split(PathUtilities.PossiblePathSeparators, limit.Value, StringSplitOptions.RemoveEmptyEntries) + : path.Split(PathUtilities.PossiblePathSeparators, StringSplitOptions.RemoveEmptyEntries); + } - /// Normalize an asset name to match how MonoGame's content APIs would normalize and cache it. - /// The asset name to normalize. - [Pure] + /// Normalize an asset name to match how MonoGame's content APIs would normalize and cache it. + /// The asset name to normalize. + [Pure] #if NET6_0_OR_GREATER - [return: NotNullIfNotNull("assetName")] + [return: NotNullIfNotNull("assetName")] #endif - public static string? NormalizeAssetName(string? assetName) - { - assetName = assetName?.Trim(); - if (string.IsNullOrEmpty(assetName)) - return assetName; + public static string? NormalizeAssetName(string? assetName) + { + assetName = assetName?.Trim(); + if (string.IsNullOrEmpty(assetName)) + return assetName; - return string.Join(PathUtilities.PreferredAssetSeparator.ToString(), PathUtilities.GetSegments(assetName)); // based on MonoGame's ContentManager.Load logic - } + return string.Join(PathUtilities.PreferredAssetSeparator.ToString(), PathUtilities.GetSegments(assetName)); // based on MonoGame's ContentManager.Load logic + } - /// Normalize separators in a file path for the current platform. - /// The file path to normalize. - /// This should only be used for file paths. For asset names, use instead. - [Pure] + /// Normalize separators in a file path for the current platform. + /// The file path to normalize. + /// This should only be used for file paths. For asset names, use instead. + [Pure] #if NET6_0_OR_GREATER - [return: NotNullIfNotNull("path")] + [return: NotNullIfNotNull("path")] #endif - public static string? NormalizePath(string? path) + public static string? NormalizePath(string? path) + { + path = path?.Trim(); + if (string.IsNullOrEmpty(path)) + return path; + + // get basic path format (e.g. /some/asset\\path/ => some\asset\path) + string[] segments = PathUtilities.GetSegments(path); + string newPath = string.Join(PathUtilities.PreferredPathSeparator.ToString(), segments); + + // keep root prefix + bool hasRoot = false; + if (path.StartsWith(PathUtilities.WindowsUncRoot)) { - path = path?.Trim(); - if (string.IsNullOrEmpty(path)) - return path; - - // get basic path format (e.g. /some/asset\\path/ => some\asset\path) - string[] segments = PathUtilities.GetSegments(path); - string newPath = string.Join(PathUtilities.PreferredPathSeparator.ToString(), segments); - - // keep root prefix - bool hasRoot = false; - if (path.StartsWith(PathUtilities.WindowsUncRoot)) - { - newPath = PathUtilities.WindowsUncRoot + newPath; - hasRoot = true; - } - else if (PathUtilities.PossiblePathSeparators.Contains(path[0])) - { - newPath = PathUtilities.PreferredPathSeparator + newPath; - hasRoot = true; - } - - // keep trailing separator - if ((!hasRoot || segments.Any()) && PathUtilities.PossiblePathSeparators.Contains(path[path.Length - 1])) - newPath += PathUtilities.PreferredPathSeparator; - - return newPath; + newPath = PathUtilities.WindowsUncRoot + newPath; + hasRoot = true; } - - /// Get a directory or file path relative to a given source path. If no relative path is possible (e.g. the paths are on different drives), an absolute path is returned. - /// The source folder path. - /// The target folder or file path. - [Pure] - public static string GetRelativePath(string sourceDir, string targetPath) + else if (PathUtilities.PossiblePathSeparators.Contains(path[0])) { -#if NET6_0_OR_GREATER - return Path.GetRelativePath(sourceDir, targetPath); -#else - // NOTE: - // this is a heuristic implementation that works in the cases SMAPI needs it for, but it - // doesn't handle all edge cases (e.g. case-sensitivity on Linux, or traversing between - // UNC paths on Windows). SMAPI and mods will use the more robust .NET 5 version anyway - // though, this is only for compatibility with the mod build package. - - // convert to URIs - Uri from = new(sourceDir.TrimEnd(PathUtilities.PossiblePathSeparators) + "/"); - Uri to = new(targetPath.TrimEnd(PathUtilities.PossiblePathSeparators) + "/"); - if (from.Scheme != to.Scheme) - throw new InvalidOperationException($"Can't get path for '{targetPath}' relative to '{sourceDir}'."); - - // get relative path - string rawUrl = Uri.UnescapeDataString(from.MakeRelativeUri(to).ToString()); - if (rawUrl.StartsWith("file://")) - rawUrl = PathUtilities.WindowsUncRoot + rawUrl.Substring("file://".Length); - string relative = PathUtilities.NormalizePath(rawUrl); - - // normalize - if (relative == "") - relative = "."; - else - { - // trim trailing slash from URL - if (relative.EndsWith(PathUtilities.PreferredPathSeparator.ToString())) - relative = relative.Substring(0, relative.Length - 1); - - // fix root - if (relative.StartsWith("file:") && !targetPath.Contains("file:")) - relative = relative.Substring("file:".Length); - } - - return relative; -#endif + newPath = PathUtilities.PreferredPathSeparator + newPath; + hasRoot = true; } - /// Get whether a path is relative and doesn't try to climb out of its containing folder (e.g. doesn't contain ../). - /// The path to check. - [Pure] - public static bool IsSafeRelativePath(string? path) - { - if (string.IsNullOrWhiteSpace(path)) - return true; + // keep trailing separator + if ((!hasRoot || segments.Any()) && PathUtilities.PossiblePathSeparators.Contains(path[path.Length - 1])) + newPath += PathUtilities.PreferredPathSeparator; + + return newPath; + } - return - !Path.IsPathRooted(path) - && PathUtilities.GetSegments(path).All(segment => segment.Trim() != ".."); + /// Get a path with the home directory path replaced with ~ (like C:\Users\Admin\Game to ~\Game), if applicable. + /// The path to anonymize. + [Pure] + public static string AnonymizePathForDisplay(string path) + { + string? homePath = PathUtilities.NormalizePath(Environment.GetEnvironmentVariable("HOME") ?? Environment.GetEnvironmentVariable("USERPROFILE")); + path = PathUtilities.NormalizePath(path); + + if (homePath != null) + { + if (path.Equals(homePath, StringComparison.OrdinalIgnoreCase)) + path = homePath; + else if (path.StartsWith(homePath + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + path = "~" + path.Substring(homePath.Length); } - /// Get whether a string is a valid 'slug', containing only basic characters that are safe in all contexts (e.g. filenames, URLs, etc). - /// The string to check. - [Pure] - public static bool IsSlug(string? str) + return path; + } + + /// Get a directory or file path relative to a given source path. If no relative path is possible (e.g. the paths are on different drives), an absolute path is returned. + /// The source folder path. + /// The target folder or file path. + [Pure] + public static string GetRelativePath(string sourceDir, string targetPath) + { +#if NET6_0_OR_GREATER + return Path.GetRelativePath(sourceDir, targetPath); +#else + // NOTE: + // this is a heuristic implementation that works in the cases SMAPI needs it for, but it + // doesn't handle all edge cases (e.g. case-sensitivity on Linux, or traversing between + // UNC paths on Windows). SMAPI and mods will use the more robust .NET 5 version anyway + // though, this is only for compatibility with the mod build package. + + // convert to URIs + Uri from = new(sourceDir.TrimEnd(PathUtilities.PossiblePathSeparators) + "/"); + Uri to = new(targetPath.TrimEnd(PathUtilities.PossiblePathSeparators) + "/"); + if (from.Scheme != to.Scheme) + throw new InvalidOperationException($"Can't get path for '{targetPath}' relative to '{sourceDir}'."); + + // get relative path + string rawUrl = Uri.UnescapeDataString(from.MakeRelativeUri(to).ToString()); + if (rawUrl.StartsWith("file://")) + rawUrl = PathUtilities.WindowsUncRoot + rawUrl.Substring("file://".Length); + string relative = PathUtilities.NormalizePath(rawUrl); + + // normalize + if (relative == "") + relative = "."; + else { - return - string.IsNullOrWhiteSpace(str) - || !Regex.IsMatch(str, "[^a-z0-9_.-]", RegexOptions.IgnoreCase); + // trim trailing slash from URL + if (relative.EndsWith(PathUtilities.PreferredPathSeparator.ToString())) + relative = relative.Substring(0, relative.Length - 1); + + // fix root + if (relative.StartsWith("file:") && !targetPath.Contains("file:")) + relative = relative.Substring("file:".Length); } + + return relative; +#endif + } + + /// Get whether a path is relative and doesn't try to climb out of its containing folder (e.g. doesn't contain ../). + /// The path to check. + [Pure] + public static bool IsSafeRelativePath(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + return true; + + return + !Path.IsPathRooted(path) + && PathUtilities.GetSegments(path).All(segment => segment.Trim() != ".."); + } + + /// Get whether a string is a valid 'slug', containing only basic characters that are safe in all contexts (e.g. filenames, URLs, etc). + /// The string to check. + [Pure] + public static bool IsSlug(string? str) + { + return + string.IsNullOrWhiteSpace(str) + || !Regex.IsMatch(str, "[^a-z0-9_.-]", RegexOptions.IgnoreCase); } } diff --git a/src/SMAPI.Toolkit/Utilities/Platform.cs b/src/SMAPI.Toolkit/Utilities/Platform.cs index 563d32507..e912a9a23 100644 --- a/src/SMAPI.Toolkit/Utilities/Platform.cs +++ b/src/SMAPI.Toolkit/Utilities/Platform.cs @@ -1,18 +1,17 @@ -namespace StardewModdingAPI.Toolkit.Utilities +namespace StardewModdingAPI.Toolkit.Utilities; + +/// The game's platform version. +public enum Platform { - /// The game's platform version. - public enum Platform - { - /// The Android version of the game. - Android, + /// The Android version of the game. + Android, - /// The Linux version of the game. - Linux, + /// The Linux version of the game. + Linux, - /// The macOS version of the game. - Mac, + /// The macOS version of the game. + Mac, - /// The Windows version of the game. - Windows - } + /// The Windows version of the game. + Windows } diff --git a/src/SMAPI.Web/BackgroundService.cs b/src/SMAPI.Web/BackgroundService.cs index d876e9428..bb8692f32 100644 --- a/src/SMAPI.Web/BackgroundService.cs +++ b/src/SMAPI.Web/BackgroundService.cs @@ -3,178 +3,345 @@ using System.Threading; using System.Threading.Tasks; using Hangfire; +using Hangfire.Console; +using Hangfire.Server; +using Humanizer; +using Humanizer.Localisation; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; using StardewModdingAPI.Toolkit; +using StardewModdingAPI.Toolkit.Framework.Clients; +using StardewModdingAPI.Toolkit.Framework.Clients.CurseForgeExport; +using StardewModdingAPI.Toolkit.Framework.Clients.ModDropExport; using StardewModdingAPI.Toolkit.Framework.Clients.NexusExport; -using StardewModdingAPI.Toolkit.Framework.Clients.NexusExport.ResponseModels; using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; +using StardewModdingAPI.Web.Framework.Caching; +using StardewModdingAPI.Web.Framework.Caching.CurseForgeExport; +using StardewModdingAPI.Web.Framework.Caching.ModDropExport; using StardewModdingAPI.Web.Framework.Caching.Mods; using StardewModdingAPI.Web.Framework.Caching.NexusExport; using StardewModdingAPI.Web.Framework.Caching.Wiki; +using StardewModdingAPI.Web.Framework.Clients.CurseForge; +using StardewModdingAPI.Web.Framework.Clients.ModDrop; using StardewModdingAPI.Web.Framework.Clients.Nexus; using StardewModdingAPI.Web.Framework.ConfigModels; -namespace StardewModdingAPI.Web +namespace StardewModdingAPI.Web; + +/// A hosted service which runs background data updates. +/// Task methods need to be static, since otherwise Hangfire will try to serialize the entire instance. +internal class BackgroundService : IHostedService, IDisposable { - /// A hosted service which runs background data updates. - /// Task methods need to be static, since otherwise Hangfire will try to serialize the entire instance. - internal class BackgroundService : IHostedService, IDisposable + /********* + ** Fields + *********/ + /// The background task server. + private static BackgroundJobServer? JobServer; + + /// The cache in which to store wiki metadata. + private static IWikiCacheRepository? WikiCache; + + /// The cache in which to store mod data. + private static IModCacheRepository? ModCache; + + /// The HTTP client for fetching the mod export from the CurseForge export API. + private static ICurseForgeExportApiClient? CurseForgeExportApiClient; + + /// The cache in which to store the mod data from the CurseForge export API. + private static ICurseForgeExportCacheRepository? CurseForgeExportCache; + + /// The HTTP client for fetching the mod export from the ModDrop export API. + private static IModDropExportApiClient? ModDropExportApiClient; + + /// The cache in which to store the mod data from the ModDrop export API. + private static IModDropExportCacheRepository? ModDropExportCache; + + /// The cache in which to store mod data from the Nexus export API. + private static INexusExportCacheRepository? NexusExportCache; + + /// The HTTP client for fetching the mod export from the Nexus Mods export API. + private static INexusExportApiClient? NexusExportApiClient; + + /// The config settings for mod update checks. + private static IOptions? UpdateCheckConfig; + + /// Whether the service has been started. + [MemberNotNullWhen(true, + nameof(BackgroundService.JobServer), + nameof(BackgroundService.ModCache), + nameof(BackgroundService.CurseForgeExportApiClient), + nameof(BackgroundService.CurseForgeExportCache), + nameof(BackgroundService.ModDropExportApiClient), + nameof(BackgroundService.ModDropExportCache), + nameof(BackgroundService.NexusExportApiClient), + nameof(BackgroundService.NexusExportCache), + nameof(BackgroundService.UpdateCheckConfig), + nameof(BackgroundService.WikiCache) + )] + private static bool IsStarted { get; set; } + + /// The number of minutes a site export should be considered valid based on its last-updated date before it's ignored. + private static int ExportStaleAge => (BackgroundService.UpdateCheckConfig?.Value.SuccessCacheMinutes ?? 0) + 10; + + + /********* + ** Public methods + *********/ + /**** + ** Hosted service + ****/ + /// Construct an instance. + /// The cache in which to store wiki metadata. + /// The cache in which to store mod data. + /// The cache in which to store mod data from the CurseForge export API. + /// The HTTP client for fetching the mod export from the CurseForge export API. + /// /// The cache in which to store mod data from the ModDrop export API. + /// The HTTP client for fetching the mod export from the ModDrop export API. + /// The cache in which to store mod data from the Nexus export API. + /// The HTTP client for fetching the mod export from the Nexus Mods export API. + /// The Hangfire storage implementation. + /// The config settings for mod update checks. + [SuppressMessage("ReSharper", "UnusedParameter.Local", Justification = "The Hangfire reference forces it to initialize first, since it's needed by the background service.")] + public BackgroundService( + IWikiCacheRepository wikiCache, + IModCacheRepository modCache, + ICurseForgeExportCacheRepository curseForgeExportCache, + ICurseForgeExportApiClient curseForgeExportApiClient, + IModDropExportCacheRepository modDropExportCache, + IModDropExportApiClient modDropExportApiClient, + INexusExportCacheRepository nexusExportCache, + INexusExportApiClient nexusExportApiClient, + JobStorage hangfireStorage, + IOptions updateCheckConfig + ) { - /********* - ** Fields - *********/ - /// The background task server. - private static BackgroundJobServer? JobServer; - - /// The cache in which to store wiki metadata. - private static IWikiCacheRepository? WikiCache; - - /// The cache in which to store mod data. - private static IModCacheRepository? ModCache; - - /// The cache in which to store mod data from the Nexus export API. - private static INexusExportCacheRepository? NexusExportCache; - - /// The HTTP client for fetching the mod export from the Nexus Mods export API. - private static INexusExportApiClient? NexusExportApiClient; - - /// The config settings for mod update checks. - private static IOptions? UpdateCheckConfig; - - /// Whether the service has been started. - [MemberNotNullWhen(true, nameof(BackgroundService.JobServer), nameof(BackgroundService.ModCache), nameof(NexusExportApiClient), nameof(NexusExportCache), nameof(BackgroundService.UpdateCheckConfig), nameof(BackgroundService.WikiCache))] - private static bool IsStarted { get; set; } - - /// The number of minutes the Nexus export should be considered valid based on its last-updated date before it's ignored. - private static int NexusExportStaleAge => (BackgroundService.UpdateCheckConfig?.Value.SuccessCacheMinutes ?? 0) + 10; - - - /********* - ** Public methods - *********/ - /**** - ** Hosted service - ****/ - /// Construct an instance. - /// The cache in which to store wiki metadata. - /// The cache in which to store mod data. - /// The cache in which to store mod data from the Nexus export API. - /// The HTTP client for fetching the mod export from the Nexus Mods export API. - /// The Hangfire storage implementation. - /// The config settings for mod update checks. - [SuppressMessage("ReSharper", "UnusedParameter.Local", Justification = "The Hangfire reference forces it to initialize first, since it's needed by the background service.")] - public BackgroundService(IWikiCacheRepository wikiCache, IModCacheRepository modCache, INexusExportCacheRepository nexusExportCache, INexusExportApiClient nexusExportApiClient, JobStorage hangfireStorage, IOptions updateCheckConfig) - { - BackgroundService.WikiCache = wikiCache; - BackgroundService.ModCache = modCache; - BackgroundService.NexusExportCache = nexusExportCache; - BackgroundService.NexusExportApiClient = nexusExportApiClient; - BackgroundService.UpdateCheckConfig = updateCheckConfig; + BackgroundService.WikiCache = wikiCache; + BackgroundService.ModCache = modCache; + BackgroundService.CurseForgeExportApiClient = curseForgeExportApiClient; + BackgroundService.CurseForgeExportCache = curseForgeExportCache; + BackgroundService.ModDropExportApiClient = modDropExportApiClient; + BackgroundService.ModDropExportCache = modDropExportCache; + BackgroundService.NexusExportCache = nexusExportCache; + BackgroundService.NexusExportApiClient = nexusExportApiClient; + BackgroundService.UpdateCheckConfig = updateCheckConfig; + + _ = hangfireStorage; // parameter is only received to initialize it before the background service + } - _ = hangfireStorage; // this parameter is only received so it's initialized before the background service - } + /// Start the service. + /// Tracks whether the start process has been aborted. + public Task StartAsync(CancellationToken cancellationToken) + { + this.TryInit(); + + bool enableCurseForgeExport = BackgroundService.CurseForgeExportApiClient is not DisabledCurseForgeExportApiClient; + bool enableModDropExport = BackgroundService.ModDropExportApiClient is not DisabledModDropExportApiClient; + bool enableNexusExport = BackgroundService.NexusExportApiClient is not DisabledNexusExportApiClient; + + // set startup tasks + BackgroundJob.Enqueue(() => BackgroundService.UpdateWikiAsync(null)); + if (enableCurseForgeExport) + BackgroundJob.Enqueue(() => BackgroundService.UpdateCurseForgeExportAsync(null)); + if (enableModDropExport) + BackgroundJob.Enqueue(() => BackgroundService.UpdateModDropExportAsync(null)); + if (enableNexusExport) + BackgroundJob.Enqueue(() => BackgroundService.UpdateNexusExportAsync(null)); + BackgroundJob.Enqueue(() => BackgroundService.RemoveStaleModsAsync()); + + // set recurring tasks + RecurringJob.AddOrUpdate("update wiki data", () => BackgroundService.UpdateWikiAsync(null), "*/10 * * * *"); // every 10 minutes + if (enableCurseForgeExport) + RecurringJob.AddOrUpdate("update CurseForge export", () => BackgroundService.UpdateCurseForgeExportAsync(null), "*/10 * * * *"); + if (enableModDropExport) + RecurringJob.AddOrUpdate("update ModDrop export", () => BackgroundService.UpdateModDropExportAsync(null), "*/10 * * * *"); + if (enableNexusExport) + RecurringJob.AddOrUpdate("update Nexus export", () => BackgroundService.UpdateNexusExportAsync(null), "*/10 * * * *"); + RecurringJob.AddOrUpdate("remove stale mods", () => BackgroundService.RemoveStaleModsAsync(), "2/10 * * * *"); // offset by 2 minutes so it runs after updates (e.g. 00:02, 00:12, etc) + + BackgroundService.IsStarted = true; + + return Task.CompletedTask; + } - /// Start the service. - /// Tracks whether the start process has been aborted. - public Task StartAsync(CancellationToken cancellationToken) - { - this.TryInit(); + /// Triggered when the application host is performing a graceful shutdown. + /// Tracks whether the shutdown process should no longer be graceful. + public async Task StopAsync(CancellationToken cancellationToken) + { + BackgroundService.IsStarted = false; - bool enableNexusExport = BackgroundService.NexusExportApiClient is not DisabledNexusExportApiClient; + if (BackgroundService.JobServer != null) + await BackgroundService.JobServer.WaitForShutdownAsync(cancellationToken); + } - // set startup tasks - BackgroundJob.Enqueue(() => BackgroundService.UpdateWikiAsync()); - if (enableNexusExport) - BackgroundJob.Enqueue(() => BackgroundService.UpdateNexusExportAsync()); - BackgroundJob.Enqueue(() => BackgroundService.RemoveStaleModsAsync()); + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + public void Dispose() + { + BackgroundService.IsStarted = false; - // set recurring tasks - RecurringJob.AddOrUpdate("update wiki data", () => BackgroundService.UpdateWikiAsync(), "*/10 * * * *"); // every 10 minutes - if (enableNexusExport) - RecurringJob.AddOrUpdate("update Nexus export", () => BackgroundService.UpdateNexusExportAsync(), "*/10 * * * *"); - RecurringJob.AddOrUpdate("remove stale mods", () => BackgroundService.RemoveStaleModsAsync(), "2/10 * * * *"); // offset by 2 minutes so it runs after updates (e.g. 00:02, 00:12, etc) + BackgroundService.JobServer?.Dispose(); + } - BackgroundService.IsStarted = true; + /**** + ** Tasks + ****/ + /// Update the cached wiki metadata. + /// Information about the context in which the job is performed. This is injected automatically by Hangfire. + [AutomaticRetry(Attempts = 3, DelaysInSeconds = [30, 60, 120])] + public static async Task UpdateWikiAsync(PerformContext? context) + { + if (!BackgroundService.IsStarted) + throw new InvalidOperationException($"Must call {nameof(BackgroundService.StartAsync)} before scheduling tasks."); - return Task.CompletedTask; - } + context.WriteLine("Fetching data from wiki..."); + WikiModList wikiCompatList = await new ModToolkit().GetWikiCompatibilityListAsync(); - /// Triggered when the application host is performing a graceful shutdown. - /// Tracks whether the shutdown process should no longer be graceful. - public async Task StopAsync(CancellationToken cancellationToken) - { - BackgroundService.IsStarted = false; + context.WriteLine("Saving data..."); + BackgroundService.WikiCache.SaveWikiData(wikiCompatList.StableVersion, wikiCompatList.BetaVersion, wikiCompatList.Mods); - if (BackgroundService.JobServer != null) - await BackgroundService.JobServer.WaitForShutdownAsync(cancellationToken); - } + context.WriteLine("Done!"); + } - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. - public void Dispose() - { - BackgroundService.IsStarted = false; + /// Update the cached CurseForge mod export. + /// Information about the context in which the job is performed. This is injected automatically by Hangfire. + [AutomaticRetry(Attempts = 3, DelaysInSeconds = [30, 60, 120])] + public static async Task UpdateCurseForgeExportAsync(PerformContext? context) + { + await UpdateExportAsync( + context, + BackgroundService.CurseForgeExportCache!, + BackgroundService.CurseForgeExportApiClient!, + fetchCacheHeadersAsync: client => client.FetchCacheHeadersAsync(), + fetchDataAsync: async (cache, client) => cache.SetData(await client.FetchExportAsync()) + ); + } - BackgroundService.JobServer?.Dispose(); - } + /// Update the cached ModDrop mod export. + /// Information about the context in which the job is performed. This is injected automatically by Hangfire. + [AutomaticRetry(Attempts = 3, DelaysInSeconds = [30, 60, 120])] + public static async Task UpdateModDropExportAsync(PerformContext? context) + { + await UpdateExportAsync( + context, + BackgroundService.ModDropExportCache!, + BackgroundService.ModDropExportApiClient!, + fetchCacheHeadersAsync: client => client.FetchCacheHeadersAsync(), + fetchDataAsync: async (cache, client) => cache.SetData(await client.FetchExportAsync()) + ); + } - /**** - ** Tasks - ****/ - /// Update the cached wiki metadata. - [AutomaticRetry(Attempts = 3, DelaysInSeconds = new[] { 30, 60, 120 })] - public static async Task UpdateWikiAsync() - { - if (!BackgroundService.IsStarted) - throw new InvalidOperationException($"Must call {nameof(BackgroundService.StartAsync)} before scheduling tasks."); + /// Update the cached Nexus mod export. + /// Information about the context in which the job is performed. This is injected automatically by Hangfire. + [AutomaticRetry(Attempts = 3, DelaysInSeconds = [30, 60, 120])] + public static async Task UpdateNexusExportAsync(PerformContext? context) + { + await UpdateExportAsync( + context, + BackgroundService.NexusExportCache!, + BackgroundService.NexusExportApiClient!, + fetchCacheHeadersAsync: client => client.FetchCacheHeadersAsync(), + fetchDataAsync: async (cache, client) => cache.SetData(await client.FetchExportAsync()) + ); + } - WikiModList wikiCompatList = await new ModToolkit().GetWikiCompatibilityListAsync(); - BackgroundService.WikiCache.SaveWikiData(wikiCompatList.StableVersion, wikiCompatList.BetaVersion, wikiCompatList.Mods); - } + /// Remove mods which haven't been requested in over 48 hours. + public static Task RemoveStaleModsAsync() + { + if (!BackgroundService.IsStarted) + throw new InvalidOperationException($"Must call {nameof(BackgroundService.StartAsync)} before scheduling tasks."); - /// Update the cached Nexus mod dump. - [AutomaticRetry(Attempts = 3, DelaysInSeconds = new[] { 30, 60, 120 })] - public static async Task UpdateNexusExportAsync() - { - if (!BackgroundService.IsStarted) - throw new InvalidOperationException($"Must call {nameof(BackgroundService.StartAsync)} before scheduling tasks."); + // remove mods in mod cache + BackgroundService.ModCache.RemoveStaleMods(TimeSpan.FromHours(48)); - NexusFullExport data = await BackgroundService.NexusExportApiClient.FetchExportAsync(); + return Task.CompletedTask; + } - var cache = BackgroundService.NexusExportCache; - cache.SetData(data); - if (cache.IsStale(BackgroundService.NexusExportStaleAge)) - cache.SetData(null); // if the export is too old, fetch fresh mod data from the site/API instead - } - /// Remove mods which haven't been requested in over 48 hours. - public static Task RemoveStaleModsAsync() - { - if (!BackgroundService.IsStarted) - throw new InvalidOperationException($"Must call {nameof(BackgroundService.StartAsync)} before scheduling tasks."); + /********* + ** Private method + *********/ + /// Initialize the background service if it's not already initialized. + /// The background service is already initialized. + private void TryInit() + { + if (BackgroundService.JobServer != null) + throw new InvalidOperationException("The scheduler service is already started."); - // remove mods in mod cache - BackgroundService.ModCache.RemoveStaleMods(TimeSpan.FromHours(48)); + BackgroundService.JobServer = new BackgroundJobServer(); + } - // remove stale export cache - if (BackgroundService.NexusExportCache.IsStale(BackgroundService.NexusExportStaleAge)) - BackgroundService.NexusExportCache.SetData(null); + /// Update the cached mods export for a site. + /// The export cache repository type. + /// The export API client. + /// Information about the context in which the job is performed. This is injected automatically by Hangfire. + /// The export cache to update. + /// The export API with which to fetch data from the remote API. + /// Fetch the HTTP cache headers set by the remote API. + /// Fetch the latest export file from the Nexus Mods export API. + /// The method wasn't called before running this task. + private static async Task UpdateExportAsync(PerformContext? context, TCacheRepository cache, TExportApiClient client, Func> fetchCacheHeadersAsync, Func fetchDataAsync) + where TCacheRepository : IExportCacheRepository + { + if (!BackgroundService.IsStarted) + throw new InvalidOperationException($"Must call {nameof(BackgroundService.StartAsync)} before scheduling tasks."); + + // log initial state + context.WriteLine(cache.IsLoaded + ? $"The previous export is cached with data from {BackgroundService.FormatDateModified(cache.CacheHeaders.LastModified)}." + : "No previous export is cached." + ); + + // fetch cache headers + context.WriteLine("Fetching cache headers..."); + ApiCacheHeaders serverCacheHeaders = await fetchCacheHeadersAsync(client); + DateTimeOffset serverModified = serverCacheHeaders.LastModified; + string? serverEntityTag = serverCacheHeaders.EntityTag; + + // update data + { + // skip if no update needed + if (cache.IsStale(serverModified, BackgroundService.ExportStaleAge)) + context.WriteLine($"Skipped data fetch: server was last modified {BackgroundService.FormatDateModified(serverModified)}, which exceeds the {BackgroundService.ExportStaleAge}-minute-stale limit."); + else if (cache.IsLoaded && cache.CacheHeaders.LastModified >= serverModified) + context.WriteLine($"Skipped data fetch: server was last modified {BackgroundService.FormatDateModified(serverModified)}, which {(serverModified == cache.CacheHeaders.LastModified ? "matches" : "is older than")} our cached data."); + + // update cache headers if data unchanged + else if (cache.IsLoaded && cache.CacheHeaders.EntityTag != null && cache.CacheHeaders.EntityTag == serverEntityTag) + { + context.WriteLine($"Skipped data fetch: server provided entity tag '{serverEntityTag}', which already matches the data we have."); + cache.SetCacheHeaders(serverCacheHeaders); + } + + // else update data + else + { + context.WriteLine("Fetching data..."); + await fetchDataAsync(cache, client); + } + } - return Task.CompletedTask; + // clear if stale + if (cache.IsStale(BackgroundService.ExportStaleAge)) + { + context.WriteLine("The cached data is stale, clearing cache..."); + cache.Clear(); } + // log final result + context.WriteLine(cache.IsLoaded + ? $"Done! The export is currently cached with data from {BackgroundService.FormatDateModified(cache.CacheHeaders.LastModified)}." + : "Done! The export cache is currently disabled." + ); + } - /********* - ** Private method - *********/ - /// Initialize the background service if it's not already initialized. - /// The background service is already initialized. - private void TryInit() - { - if (BackgroundService.JobServer != null) - throw new InvalidOperationException("The scheduler service is already started."); + /// Format a 'date modified' value for the task logs. + /// The date to log. + private static string FormatDateModified(DateTimeOffset? date) + { + if (!date.HasValue) + return ""; - BackgroundService.JobServer = new BackgroundJobServer(); - } + string ageLabel = (DateTimeOffset.UtcNow - date.Value).Humanize(precision: 2, minUnit: TimeUnit.Minute, maxUnit: TimeUnit.Hour); + + return $"{date.Value:O} (age: {ageLabel})"; } } diff --git a/src/SMAPI.Web/Controllers/IndexController.cs b/src/SMAPI.Web/Controllers/IndexController.cs index bea118874..39385c2c1 100644 --- a/src/SMAPI.Web/Controllers/IndexController.cs +++ b/src/SMAPI.Web/Controllers/IndexController.cs @@ -12,158 +12,159 @@ using StardewModdingAPI.Web.Framework.ConfigModels; using StardewModdingAPI.Web.ViewModels; -namespace StardewModdingAPI.Web.Controllers +namespace StardewModdingAPI.Web.Controllers; + +/// Provides an info/download page about SMAPI. +[Route("")] +internal class IndexController : Controller { - /// Provides an info/download page about SMAPI. - [Route("")] - internal class IndexController : Controller - { - /********* - ** Fields - *********/ - /// The site config settings. - private readonly SiteConfig SiteConfig; + /********* + ** Fields + *********/ + /// The site config settings. + private readonly SiteConfig SiteConfig; - /// The cache in which to store release data. - private readonly IMemoryCache Cache; + /// The cache in which to store release data. + private readonly IMemoryCache Cache; - /// The GitHub API client. - private readonly IGitHubClient GitHub; + /// The GitHub API client. + private readonly IGitHubClient GitHub; - /// The cache time for release info. - private readonly TimeSpan CacheTime = TimeSpan.FromMinutes(10); + /// The cache time for release info. + private readonly TimeSpan CacheTime = TimeSpan.FromMinutes(10); - /// The GitHub repository name to check for update. - private readonly string RepositoryName = "Pathoschild/SMAPI"; + /// The GitHub repository name to check for update. + private readonly string RepositoryName = "Pathoschild/SMAPI"; - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The cache in which to store release data. - /// The GitHub API client. - /// The context config settings. - public IndexController(IMemoryCache cache, IGitHubClient github, IOptions siteConfig) - { - this.Cache = cache; - this.GitHub = github; - this.SiteConfig = siteConfig.Value; - } + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The cache in which to store release data. + /// The GitHub API client. + /// The context config settings. + public IndexController(IMemoryCache cache, IGitHubClient github, IOptions siteConfig) + { + this.Cache = cache; + this.GitHub = github; + this.SiteConfig = siteConfig.Value; + } - /// Display the index page. - [HttpGet] - public async Task Index() + /// Display the index page. + [HttpGet] + public async Task Index() + { + // choose versions + ReleaseVersion[] versions = await this.GetReleaseVersionsAsync(); + ReleaseVersion? stableVersion = versions.LastOrDefault(version => !version.IsForDevs); + ReleaseVersion? stableVersionForDevs = versions.LastOrDefault(version => version.IsForDevs); + + // render view + IndexVersionModel stableVersionModel = stableVersion != null + ? new IndexVersionModel(stableVersion.Version.ToString(), stableVersion.Release.Body, stableVersion.Release.WebUrl, stableVersion.Asset.DownloadUrl, stableVersionForDevs?.Asset.DownloadUrl) + : new IndexVersionModel("unknown", "", "https://github.com/Pathoschild/SMAPI/releases", "https://github.com/Pathoschild/SMAPI/releases", null); // just in case something goes wrong + + // render view + var model = new IndexModel(stableVersionModel, this.SiteConfig.OtherBlurb, this.SiteConfig.SupporterList); + return this.View(model); + } + + /// Display the index page. + [HttpGet("/privacy")] + public ViewResult Privacy() + { + return this.View(); + } + + + /********* + ** Private methods + *********/ + /// Get a sorted, parsed list of SMAPI downloads for the latest releases. + private async Task GetReleaseVersionsAsync() + { + ReleaseVersion[]? versions = await this.Cache.GetOrCreateAsync("available-versions", async entry => { - // choose versions - ReleaseVersion[] versions = await this.GetReleaseVersionsAsync(); - ReleaseVersion? stableVersion = versions.LastOrDefault(version => !version.IsForDevs); - ReleaseVersion? stableVersionForDevs = versions.LastOrDefault(version => version.IsForDevs); - - // render view - IndexVersionModel stableVersionModel = stableVersion != null - ? new IndexVersionModel(stableVersion.Version.ToString(), stableVersion.Release.Body, stableVersion.Release.WebUrl, stableVersion.Asset.DownloadUrl, stableVersionForDevs?.Asset.DownloadUrl) - : new IndexVersionModel("unknown", "", "https://github.com/Pathoschild/SMAPI/releases", "https://github.com/Pathoschild/SMAPI/releases", null); // just in case something goes wrong - - // render view - var model = new IndexModel(stableVersionModel, this.SiteConfig.OtherBlurb, this.SiteConfig.SupporterList); - return this.View(model); - } + entry.AbsoluteExpiration = DateTimeOffset.UtcNow.Add(this.CacheTime); + + // get latest stable release + GitRelease? release = await this.GitHub.GetLatestReleaseAsync(this.RepositoryName, includePrerelease: false); + + // strip 'noinclude' blocks from release description + if (release != null) + { + HtmlDocument doc = new(); + doc.LoadHtml(release.Body); + foreach (HtmlNode node in doc.DocumentNode.SelectNodes("//*[@class='noinclude']")?.ToArray() ?? Array.Empty()) + node.Remove(); + release.Body = doc.DocumentNode.InnerHtml.Trim(); + } + + // get versions + return this + .ParseReleaseVersions(release) + .OrderBy(p => p.Version) + .ToArray(); + }); + + return versions!; // GetOrCreateAsync doesn't return null unless we provide null in the callback + } + + /// Get a parsed list of SMAPI downloads for a release. + /// The GitHub release. + private IEnumerable ParseReleaseVersions(GitRelease? release) + { + if (release?.Assets == null) + yield break; - /// Display the index page. - [HttpGet("/privacy")] - public ViewResult Privacy() + foreach (GitAsset asset in release.Assets) { - return this.View(); - } + if (asset.FileName.StartsWith("Z_")) + continue; + + Match match = Regex.Match(asset.FileName, @"SMAPI-(?[\d\.]+(?:-.+)?)-installer(?-for-developers)?.zip"); + if (!match.Success || !SemanticVersion.TryParse(match.Groups["version"].Value, out ISemanticVersion? version)) + continue; + bool isForDevs = match.Groups["forDevs"].Success; + yield return new ReleaseVersion(release, asset, version, isForDevs); + } + } + /// A parsed release download. + private class ReleaseVersion + { /********* - ** Private methods + ** Accessors *********/ - /// Get a sorted, parsed list of SMAPI downloads for the latest releases. - private async Task GetReleaseVersionsAsync() - { - return await this.Cache.GetOrCreateAsync("available-versions", async entry => - { - entry.AbsoluteExpiration = DateTimeOffset.UtcNow.Add(this.CacheTime); - - // get latest stable release - GitRelease? release = await this.GitHub.GetLatestReleaseAsync(this.RepositoryName, includePrerelease: false); - - // strip 'noinclude' blocks from release description - if (release != null) - { - HtmlDocument doc = new(); - doc.LoadHtml(release.Body); - foreach (HtmlNode node in doc.DocumentNode.SelectNodes("//*[@class='noinclude']")?.ToArray() ?? Array.Empty()) - node.Remove(); - release.Body = doc.DocumentNode.InnerHtml.Trim(); - } - - // get versions - return this - .ParseReleaseVersions(release) - .OrderBy(p => p.Version) - .ToArray(); - }); - } + /// The underlying GitHub release. + public GitRelease Release { get; } - /// Get a parsed list of SMAPI downloads for a release. - /// The GitHub release. - private IEnumerable ParseReleaseVersions(GitRelease? release) - { - if (release?.Assets == null) - yield break; + /// The underlying download asset. + public GitAsset Asset { get; } - foreach (GitAsset asset in release.Assets) - { - if (asset.FileName.StartsWith("Z_")) - continue; + /// The SMAPI version. + public ISemanticVersion Version { get; } - Match match = Regex.Match(asset.FileName, @"SMAPI-(?[\d\.]+(?:-.+)?)-installer(?-for-developers)?.zip"); - if (!match.Success || !SemanticVersion.TryParse(match.Groups["version"].Value, out ISemanticVersion? version)) - continue; - bool isForDevs = match.Groups["forDevs"].Success; + /// Whether this is a 'for developers' download. + public bool IsForDevs { get; } - yield return new ReleaseVersion(release, asset, version, isForDevs); - } - } - /// A parsed release download. - private class ReleaseVersion + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The underlying GitHub release. + /// The underlying download asset. + /// The SMAPI version. + /// Whether this is a 'for developers' download. + public ReleaseVersion(GitRelease release, GitAsset asset, ISemanticVersion version, bool isForDevs) { - /********* - ** Accessors - *********/ - /// The underlying GitHub release. - public GitRelease Release { get; } - - /// The underlying download asset. - public GitAsset Asset { get; } - - /// The SMAPI version. - public ISemanticVersion Version { get; } - - /// Whether this is a 'for developers' download. - public bool IsForDevs { get; } - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The underlying GitHub release. - /// The underlying download asset. - /// The SMAPI version. - /// Whether this is a 'for developers' download. - public ReleaseVersion(GitRelease release, GitAsset asset, ISemanticVersion version, bool isForDevs) - { - this.Release = release; - this.Asset = asset; - this.Version = version; - this.IsForDevs = isForDevs; - } + this.Release = release; + this.Asset = asset; + this.Version = version; + this.IsForDevs = isForDevs; } } } diff --git a/src/SMAPI.Web/Controllers/JsonValidatorController.cs b/src/SMAPI.Web/Controllers/JsonValidatorController.cs index aff84c416..ddfbaf759 100644 --- a/src/SMAPI.Web/Controllers/JsonValidatorController.cs +++ b/src/SMAPI.Web/Controllers/JsonValidatorController.cs @@ -12,329 +12,328 @@ using StardewModdingAPI.Web.Framework.Storage; using StardewModdingAPI.Web.ViewModels.JsonValidator; -namespace StardewModdingAPI.Web.Controllers +namespace StardewModdingAPI.Web.Controllers; + +/// Provides a web UI for validating JSON schemas. +internal class JsonValidatorController : Controller { - /// Provides a web UI for validating JSON schemas. - internal class JsonValidatorController : Controller + /********* + ** Fields + *********/ + /// Provides access to raw data storage. + private readonly IStorageProvider Storage; + + /// The supported JSON schemas (names indexed by ID). + private readonly IDictionary SchemaFormats = new Dictionary { - /********* - ** Fields - *********/ - /// Provides access to raw data storage. - private readonly IStorageProvider Storage; - - /// The supported JSON schemas (names indexed by ID). - private readonly IDictionary SchemaFormats = new Dictionary - { - ["none"] = "None", - ["manifest"] = "SMAPI: manifest", - ["i18n"] = "SMAPI: translations (i18n)", - ["content-patcher"] = "Content Patcher" - }; + ["none"] = "None", + ["manifest"] = "SMAPI: manifest", + ["i18n"] = "SMAPI: translations (i18n)", + ["content-patcher"] = "Content Patcher" + }; + + /// The schema ID to use if none was specified. + private readonly string DefaultSchemaID = "none"; + + /// A token in an error message which indicates that the child errors should be displayed instead. + private readonly string TransparentToken = "$transparent"; + + + /********* + ** Public methods + *********/ + /*** + ** Constructor + ***/ + /// Construct an instance. + /// Provides access to raw data storage. + public JsonValidatorController(IStorageProvider storage) + { + this.Storage = storage; + } - /// The schema ID to use if none was specified. - private readonly string DefaultSchemaID = "none"; + /*** + ** Web UI + ***/ + /// Render the schema validator UI. + /// The schema name with which to validate the JSON, or 'edit' to return to the edit screen. + /// The stored file ID. + /// The operation to perform for the selected log ID. This can be 'edit', 'renew', or any other value to view. + [HttpGet] + [Route("json")] + [Route("json/{schemaName}")] + [Route("json/{schemaName}/{id}")] + [Route("json/{schemaName}/{id}/{operation}")] + public async Task Index(string? schemaName = null, string? id = null, string? operation = null) + { + // parse arguments + schemaName = this.NormalizeSchemaName(schemaName); + operation = operation?.Trim().ToLower(); + bool hasId = !string.IsNullOrWhiteSpace(id); + bool isEditView = !hasId || operation == "edit"; + bool renew = operation == "renew"; + + // build result model + var result = this.GetModel(id, schemaName, isEditView); + if (!hasId) + return this.View("Index", result); + + // fetch raw JSON + StoredFileInfo file = await this.Storage.GetAsync(id!, renew); + if (string.IsNullOrWhiteSpace(file.Content)) + return this.View("Index", result.SetUploadError("The JSON file seems to be empty.")); + result.SetContent(file.Content, oldExpiry: file.OldExpiry, newExpiry: file.NewExpiry, uploadWarning: file.Warning); + + // skip parsing if we're going to the edit screen + if (isEditView) + return this.View("Index", result); + + // parse JSON + JToken parsed; + { + // load raw JSON + var settings = new JsonLoadSettings + { + DuplicatePropertyNameHandling = DuplicatePropertyNameHandling.Error, + CommentHandling = CommentHandling.Load + }; + try + { + parsed = JToken.Parse(file.Content, settings); + } + catch (JsonReaderException ex) + { + return this.View("Index", result.AddErrors(new JsonValidatorErrorModel(ex.LineNumber, ex.Path!, ex.Message, ErrorType.None))); + } - /// A token in an error message which indicates that the child errors should be displayed instead. - private readonly string TransparentToken = "$transparent"; + // format JSON + string formatted = parsed.ToString(Formatting.Indented); + result.SetContent(formatted, oldExpiry: file.OldExpiry, newExpiry: file.NewExpiry, uploadWarning: file.Warning); + parsed = JToken.Parse(formatted); // update line number references + } + // skip if no schema selected + if (schemaName == "none") + return this.View("Index", result); - /********* - ** Public methods - *********/ - /*** - ** Constructor - ***/ - /// Construct an instance. - /// Provides access to raw data storage. - public JsonValidatorController(IStorageProvider storage) + // load schema + JSchema schema; { - this.Storage = storage; + FileInfo? schemaFile = this.FindSchemaFile(schemaName); + if (schemaFile == null) + return this.View("Index", result.SetParseError($"Invalid schema '{schemaName}'.")); + schema = JSchema.Parse(await System.IO.File.ReadAllTextAsync(schemaFile.FullName)); } - /*** - ** Web UI - ***/ - /// Render the schema validator UI. - /// The schema name with which to validate the JSON, or 'edit' to return to the edit screen. - /// The stored file ID. - /// The operation to perform for the selected log ID. This can be 'edit', 'renew', or any other value to view. - [HttpGet] - [Route("json")] - [Route("json/{schemaName}")] - [Route("json/{schemaName}/{id}")] - [Route("json/{schemaName}/{id}/{operation}")] - public async Task Index(string? schemaName = null, string? id = null, string? operation = null) - { - // parse arguments - schemaName = this.NormalizeSchemaName(schemaName); - operation = operation?.Trim().ToLower(); - bool hasId = !string.IsNullOrWhiteSpace(id); - bool isEditView = !hasId || operation == "edit"; - bool renew = operation == "renew"; - - // build result model - var result = this.GetModel(id, schemaName, isEditView); - if (!hasId) - return this.View("Index", result); - - // fetch raw JSON - StoredFileInfo file = await this.Storage.GetAsync(id!, renew); - if (string.IsNullOrWhiteSpace(file.Content)) - return this.View("Index", result.SetUploadError("The JSON file seems to be empty.")); - result.SetContent(file.Content, oldExpiry: file.OldExpiry, newExpiry: file.NewExpiry, uploadWarning: file.Warning); - - // skip parsing if we're going to the edit screen - if (isEditView) - return this.View("Index", result); - - // parse JSON - JToken parsed; - { - // load raw JSON - var settings = new JsonLoadSettings - { - DuplicatePropertyNameHandling = DuplicatePropertyNameHandling.Error, - CommentHandling = CommentHandling.Load - }; - try - { - parsed = JToken.Parse(file.Content, settings); - } - catch (JsonReaderException ex) - { - return this.View("Index", result.AddErrors(new JsonValidatorErrorModel(ex.LineNumber, ex.Path!, ex.Message, ErrorType.None))); - } - - // format JSON - string formatted = parsed.ToString(Formatting.Indented); - result.SetContent(formatted, oldExpiry: file.OldExpiry, newExpiry: file.NewExpiry, uploadWarning: file.Warning); - parsed = JToken.Parse(formatted); // update line number references - } + // get format doc URL + result.FormatUrl = this.GetExtensionField(schema, "@documentationUrl"); - // skip if no schema selected - if (schemaName == "none") - return this.View("Index", result); + // validate JSON + parsed.IsValid(schema, out IList rawErrors); + var errors = rawErrors + .SelectMany(this.GetErrorModels) + .ToArray(); + return this.View("Index", result.AddErrors(errors)); + } - // load schema - JSchema schema; - { - FileInfo? schemaFile = this.FindSchemaFile(schemaName); - if (schemaFile == null) - return this.View("Index", result.SetParseError($"Invalid schema '{schemaName}'.")); - schema = JSchema.Parse(await System.IO.File.ReadAllTextAsync(schemaFile.FullName)); - } + /*** + ** JSON + ***/ + /// Save raw JSON data. + [HttpPost, AllowLargePosts] + [Route("json")] + public async Task PostAsync(JsonValidatorRequestModel? request) + { + if (request == null) + return this.View("Index", this.GetModel(null, null, isEditView: true).SetUploadError("The request seems to be invalid.")); - // get format doc URL - result.FormatUrl = this.GetExtensionField(schema, "@documentationUrl"); + // normalize schema name + string schemaName = this.NormalizeSchemaName(request.SchemaName); - // validate JSON - parsed.IsValid(schema, out IList rawErrors); - var errors = rawErrors - .SelectMany(this.GetErrorModels) - .ToArray(); - return this.View("Index", result.AddErrors(errors)); - } + // get raw text + string? input = request.Content; + if (string.IsNullOrWhiteSpace(input)) + return this.View("Index", this.GetModel(null, schemaName, isEditView: true).SetUploadError("The JSON file seems to be empty.")); - /*** - ** JSON - ***/ - /// Save raw JSON data. - [HttpPost, AllowLargePosts] - [Route("json")] - public async Task PostAsync(JsonValidatorRequestModel? request) - { - if (request == null) - return this.View("Index", this.GetModel(null, null, isEditView: true).SetUploadError("The request seems to be invalid.")); + // upload file + UploadResult result = await this.Storage.SaveAsync(input); + if (!result.Succeeded) + return this.View("Index", this.GetModel(result.ID, schemaName, isEditView: true).SetContent(input, null, null).SetUploadError(result.UploadError)); - // normalize schema name - string schemaName = this.NormalizeSchemaName(request.SchemaName); + // redirect to view + return this.Redirect(this.Url.PlainAction("Index", "JsonValidator", new { schemaName, id = result.ID })!); + } - // get raw text - string? input = request.Content; - if (string.IsNullOrWhiteSpace(input)) - return this.View("Index", this.GetModel(null, schemaName, isEditView: true).SetUploadError("The JSON file seems to be empty.")); - // upload file - UploadResult result = await this.Storage.SaveAsync(input); - if (!result.Succeeded) - return this.View("Index", this.GetModel(result.ID, schemaName, isEditView: true).SetContent(input, null, null).SetUploadError(result.UploadError)); + /********* + ** Private methods + *********/ + /// Build a JSON validator model. + /// The stored file ID. + /// The schema name with which the JSON was validated. + /// Whether to show the edit view. + private JsonValidatorModel GetModel(string? pasteID, string? schemaName, bool isEditView) + { + return new JsonValidatorModel(pasteID, schemaName, this.SchemaFormats, isEditView); + } - // redirect to view - return this.Redirect(this.Url.PlainAction("Index", "JsonValidator", new { schemaName, id = result.ID })!); - } + /// Get a normalized schema name, or the if blank. + /// The raw schema name to normalize. + private string NormalizeSchemaName(string? schemaName) + { + schemaName = schemaName?.Trim().ToLower(); + return !string.IsNullOrWhiteSpace(schemaName) + ? schemaName + : this.DefaultSchemaID; + } + /// Get the schema file given its unique ID. + /// The schema ID. + private FileInfo? FindSchemaFile(string? id) + { + // normalize ID + id = id?.Trim().ToLower(); + if (string.IsNullOrWhiteSpace(id)) + return null; - /********* - ** Private methods - *********/ - /// Build a JSON validator model. - /// The stored file ID. - /// The schema name with which the JSON was validated. - /// Whether to show the edit view. - private JsonValidatorModel GetModel(string? pasteID, string? schemaName, bool isEditView) + // get matching file + DirectoryInfo schemaDir = new(Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "schemas")); + foreach (FileInfo file in schemaDir.EnumerateFiles("*.json")) { - return new JsonValidatorModel(pasteID, schemaName, this.SchemaFormats, isEditView); + if (file.Name.Equals($"{id}.json")) + return file; } - /// Get a normalized schema name, or the if blank. - /// The raw schema name to normalize. - private string NormalizeSchemaName(string? schemaName) + return null; + } + + /// Get view models representing a schema validation error and any child errors. + /// The error to represent. + private IEnumerable GetErrorModels(ValidationError error) + { + // skip through transparent errors + if (this.IsTransparentError(error)) { - schemaName = schemaName?.Trim().ToLower(); - return !string.IsNullOrWhiteSpace(schemaName) - ? schemaName - : this.DefaultSchemaID; + foreach (JsonValidatorErrorModel model in error.ChildErrors.SelectMany(this.GetErrorModels)) + yield return model; + yield break; } - /// Get the schema file given its unique ID. - /// The schema ID. - private FileInfo? FindSchemaFile(string? id) - { - // normalize ID - id = id?.Trim().ToLower(); - if (string.IsNullOrWhiteSpace(id)) - return null; + // get message + string? message = this.GetOverrideError(error); + if (message == null || message == this.TransparentToken) + message = this.FlattenErrorMessage(error); - // get matching file - DirectoryInfo schemaDir = new(Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "schemas")); - foreach (FileInfo file in schemaDir.EnumerateFiles("*.json")) - { - if (file.Name.Equals($"{id}.json")) - return file; - } + // build model + yield return new JsonValidatorErrorModel(error.LineNumber, error.Path, message, error.ErrorType); + } - return null; - } + /// Get a flattened, human-readable message for a schema validation error and any child errors. + /// The error to represent. + /// The indentation level to apply for inner errors. + private string FlattenErrorMessage(ValidationError error, int indent = 0) + { + // get override + string? message = this.GetOverrideError(error); + if (message != null && message != this.TransparentToken) + return message; + + // skip through transparent errors + if (this.IsTransparentError(error)) + error = error.ChildErrors[0]; - /// Get view models representing a schema validation error and any child errors. - /// The error to represent. - private IEnumerable GetErrorModels(ValidationError error) + // get friendly representation of main error + message = error.Message; + switch (error.ErrorType) { - // skip through transparent errors - if (this.IsTransparentError(error)) - { - foreach (JsonValidatorErrorModel model in error.ChildErrors.SelectMany(this.GetErrorModels)) - yield return model; - yield break; - } + case ErrorType.Const: + message = $"Invalid value. Found '{error.Value}', but expected '{error.Schema.Const}'."; + break; - // get message - string? message = this.GetOverrideError(error); - if (message == null || message == this.TransparentToken) - message = this.FlattenErrorMessage(error); + case ErrorType.Enum: + message = $"Invalid value. Found '{error.Value}', but expected one of '{string.Join("', '", error.Schema.Enum)}'."; + break; - // build model - yield return new JsonValidatorErrorModel(error.LineNumber, error.Path, message, error.ErrorType); + case ErrorType.Required: + message = $"Missing required fields: {string.Join(", ", (List)error.Value!)}."; + break; } - /// Get a flattened, human-readable message for a schema validation error and any child errors. - /// The error to represent. - /// The indentation level to apply for inner errors. - private string FlattenErrorMessage(ValidationError error, int indent = 0) - { - // get override - string? message = this.GetOverrideError(error); - if (message != null && message != this.TransparentToken) - return message; - - // skip through transparent errors - if (this.IsTransparentError(error)) - error = error.ChildErrors[0]; - - // get friendly representation of main error - message = error.Message; - switch (error.ErrorType) - { - case ErrorType.Const: - message = $"Invalid value. Found '{error.Value}', but expected '{error.Schema.Const}'."; - break; - - case ErrorType.Enum: - message = $"Invalid value. Found '{error.Value}', but expected one of '{string.Join("', '", error.Schema.Enum)}'."; - break; + // add inner errors + foreach (ValidationError childError in error.ChildErrors) + message += "\n" + "".PadLeft(indent * 2, ' ') + $"==> {childError.Path}: " + this.FlattenErrorMessage(childError, indent + 1); + return message; + } - case ErrorType.Required: - message = $"Missing required fields: {string.Join(", ", (List)error.Value!)}."; - break; - } + /// Get whether a validation error should be omitted in favor of its child errors in user-facing error messages. + /// The error to check. + private bool IsTransparentError(ValidationError error) + { + if (!error.ChildErrors.Any()) + return false; - // add inner errors - foreach (ValidationError childError in error.ChildErrors) - message += "\n" + "".PadLeft(indent * 2, ' ') + $"==> {childError.Path}: " + this.FlattenErrorMessage(childError, indent + 1); - return message; - } + string? @override = this.GetOverrideError(error); + return + @override == this.TransparentToken + || (error.ErrorType == ErrorType.Then && @override == null); + } - /// Get whether a validation error should be omitted in favor of its child errors in user-facing error messages. - /// The error to check. - private bool IsTransparentError(ValidationError error) + /// Get an override error from the JSON schema, if any. + /// The schema validation error. + private string? GetOverrideError(ValidationError error) + { + string? GetRawOverrideError() { - if (!error.ChildErrors.Any()) - return false; - - string? @override = this.GetOverrideError(error); - return - @override == this.TransparentToken - || (error.ErrorType == ErrorType.Then && @override == null); - } + // get override errors + IDictionary? errors = this.GetExtensionField>(error.Schema, "@errorMessages"); + if (errors == null) + return null; + errors = new Dictionary(errors, StringComparer.OrdinalIgnoreCase); - /// Get an override error from the JSON schema, if any. - /// The schema validation error. - private string? GetOverrideError(ValidationError error) - { - string? GetRawOverrideError() + // match error by type and message + foreach ((string target, string? errorMessage) in errors) { - // get override errors - IDictionary? errors = this.GetExtensionField>(error.Schema, "@errorMessages"); - if (errors == null) - return null; - errors = new Dictionary(errors, StringComparer.OrdinalIgnoreCase); - - // match error by type and message - foreach ((string target, string? errorMessage) in errors) - { - if (!target.Contains(":")) - continue; - - string[] parts = target.Split(':', 2); - if (parts[0].Equals(error.ErrorType.ToString(), StringComparison.OrdinalIgnoreCase) && Regex.IsMatch(error.Message, parts[1])) - return errorMessage?.Trim(); - } - - // match by type - return errors.TryGetValue(error.ErrorType.ToString(), out string? message) - ? message?.Trim() - : null; + if (!target.Contains(':')) + continue; + + string[] parts = target.Split(':', 2); + if (parts[0].Equals(error.ErrorType.ToString(), StringComparison.OrdinalIgnoreCase) && Regex.IsMatch(error.Message, parts[1])) + return errorMessage?.Trim(); } - return GetRawOverrideError() - ?.Replace("@value", this.FormatValue(error.Value)); + // match by type + return errors.TryGetValue(error.ErrorType.ToString(), out string? message) + ? message?.Trim() + : null; } - /// Get an extension field from a JSON schema. - /// The field type. - /// The schema whose extension fields to search. - /// The case-insensitive field key. - private T? GetExtensionField(JSchema schema, string key) - { - foreach ((string curKey, JToken value) in schema.ExtensionData) - { - if (curKey.Equals(key, StringComparison.OrdinalIgnoreCase)) - return value.ToObject(); - } + return GetRawOverrideError() + ?.Replace("@value", this.FormatValue(error.Value)); + } - return default; + /// Get an extension field from a JSON schema. + /// The field type. + /// The schema whose extension fields to search. + /// The case-insensitive field key. + private T? GetExtensionField(JSchema schema, string key) + { + foreach ((string curKey, JToken value) in schema.ExtensionData) + { + if (curKey.Equals(key, StringComparison.OrdinalIgnoreCase)) + return value.ToObject(); } - /// Format a schema value for display. - /// The value to format. - private string FormatValue(object? value) + return default; + } + + /// Format a schema value for display. + /// The value to format. + private string FormatValue(object? value) + { + return value switch { - return value switch - { - List list => string.Join(", ", list), - _ => value?.ToString() ?? "null" - }; - } + List list => string.Join(", ", list), + _ => value?.ToString() ?? "null" + }; } } diff --git a/src/SMAPI.Web/Controllers/LogParserController.cs b/src/SMAPI.Web/Controllers/LogParserController.cs index aca609881..17b70af58 100644 --- a/src/SMAPI.Web/Controllers/LogParserController.cs +++ b/src/SMAPI.Web/Controllers/LogParserController.cs @@ -12,147 +12,146 @@ using StardewModdingAPI.Web.Framework.Storage; using StardewModdingAPI.Web.ViewModels; -namespace StardewModdingAPI.Web.Controllers +namespace StardewModdingAPI.Web.Controllers; + +/// Provides a web UI and API for parsing SMAPI log files. +internal class LogParserController : Controller { - /// Provides a web UI and API for parsing SMAPI log files. - internal class LogParserController : Controller + /********* + ** Fields + *********/ + /// Provides access to raw data storage. + private readonly IStorageProvider Storage; + + + /********* + ** Public methods + *********/ + /*** + ** Constructor + ***/ + /// Construct an instance. + /// Provides access to raw data storage. + public LogParserController(IStorageProvider storage) { - /********* - ** Fields - *********/ - /// Provides access to raw data storage. - private readonly IStorageProvider Storage; - - - /********* - ** Public methods - *********/ - /*** - ** Constructor - ***/ - /// Construct an instance. - /// Provides access to raw data storage. - public LogParserController(IStorageProvider storage) - { - this.Storage = storage; - } + this.Storage = storage; + } + + /*** + ** Web UI + ***/ + /// Render the log parser UI. + /// The stored file ID. + /// How to render the log view. + /// Whether to reset the log expiry. + [HttpGet] + [Route("log")] + [Route("log/{id}")] + public async Task Index(string? id = null, LogViewFormat format = LogViewFormat.Default, bool renew = false) + { + // fresh page + if (string.IsNullOrWhiteSpace(id)) + return this.View("Index", this.GetModel(id)); + + // fetch log + StoredFileInfo file = await this.Storage.GetAsync(id, renew); - /*** - ** Web UI - ***/ - /// Render the log parser UI. - /// The stored file ID. - /// How to render the log view. - /// Whether to reset the log expiry. - [HttpGet] - [Route("log")] - [Route("log/{id}")] - public async Task Index(string? id = null, LogViewFormat format = LogViewFormat.Default, bool renew = false) + // render view + switch (format) { - // fresh page - if (string.IsNullOrWhiteSpace(id)) - return this.View("Index", this.GetModel(id)); - - // fetch log - StoredFileInfo file = await this.Storage.GetAsync(id, renew); - - // render view - switch (format) - { - case LogViewFormat.Default: - case LogViewFormat.RawView: - { - ParsedLog log = file.Success - ? new LogParser().Parse(file.Content) - : new ParsedLog { IsValid = false, Error = file.Error }; - - return this.View("Index", this.GetModel(id, uploadWarning: file.Warning, oldExpiry: file.OldExpiry, newExpiry: file.NewExpiry).SetResult(log, showRaw: format == LogViewFormat.RawView)); - } - - case LogViewFormat.RawDownload: - { - string content = file.Error ?? file.Content ?? string.Empty; - return this.File(Encoding.UTF8.GetBytes(content), "plain/text", $"SMAPI log ({id}).txt"); - } - - default: - throw new InvalidOperationException($"Unknown log view format '{format}'."); - } + case LogViewFormat.Default: + case LogViewFormat.RawView: + { + ParsedLog log = file.Success + ? new LogParser().Parse(file.Content) + : new ParsedLog { IsValid = false, Error = file.Error }; + + return this.View("Index", this.GetModel(id, uploadWarning: file.Warning, oldExpiry: file.OldExpiry, newExpiry: file.NewExpiry).SetResult(log, showRaw: format == LogViewFormat.RawView)); + } + + case LogViewFormat.RawDownload: + { + string content = file.Error ?? file.Content ?? string.Empty; + return this.File(Encoding.UTF8.GetBytes(content), "plain/text", $"SMAPI log ({id}).txt"); + } + + default: + throw new InvalidOperationException($"Unknown log view format '{format}'."); } + } - /*** - ** JSON - ***/ - /// Save raw log data. - [HttpPost, AllowLargePosts] - [Route("log")] - public async Task PostAsync() + /*** + ** JSON + ***/ + /// Save raw log data. + [HttpPost, AllowLargePosts] + [Route("log")] + public async Task PostAsync() + { + // get raw log text + // note: avoid this.Request.Form, which fails if any mod logged a null character. + string? input; { - // get raw log text - // note: avoid this.Request.Form, which fails if any mod logged a null character. - string? input; - { - using StreamReader reader = new StreamReader(this.Request.Body); - NameValueCollection parsed = HttpUtility.ParseQueryString(await reader.ReadToEndAsync()); - input = parsed["input"]; - if (string.IsNullOrWhiteSpace(input)) - return this.View("Index", this.GetModel(null, uploadError: "The log file seems to be empty.")); - } - - // upload log - UploadResult uploadResult = await this.Storage.SaveAsync(input); - if (!uploadResult.Succeeded) - return this.View("Index", this.GetModel(null, uploadError: uploadResult.UploadError)); - - // redirect to view - return this.Redirect(this.Url.PlainAction("Index", "LogParser", new { id = uploadResult.ID })!); + using StreamReader reader = new StreamReader(this.Request.Body); + NameValueCollection parsed = HttpUtility.ParseQueryString(await reader.ReadToEndAsync()); + input = parsed["input"]; + if (string.IsNullOrWhiteSpace(input)) + return this.View("Index", this.GetModel(null, uploadError: "The log file seems to be empty.")); } + // upload log + UploadResult uploadResult = await this.Storage.SaveAsync(input); + if (!uploadResult.Succeeded) + return this.View("Index", this.GetModel(null, uploadError: uploadResult.UploadError)); - /********* - ** Private methods - *********/ - /// Build a log parser model. - /// The stored file ID. - /// When the uploaded file would no longer have been available, before any renewal applied in this request - /// When the file will no longer be available, after any renewal applied in this request. - /// A non-blocking warning while uploading the log. - /// An error which occurred while uploading the log. - private LogParserModel GetModel(string? pasteID, DateTimeOffset? oldExpiry = null, DateTimeOffset? newExpiry = null, string? uploadWarning = null, string? uploadError = null) - { - Platform? platform = this.DetectClientPlatform(); - - return new LogParserModel(pasteID, platform) - { - UploadWarning = uploadWarning, - UploadError = uploadError, - OldExpiry = oldExpiry, - NewExpiry = newExpiry - }; - } + // redirect to view + return this.Redirect(this.Url.PlainAction("Index", "LogParser", new { id = uploadResult.ID })!); + } + + + /********* + ** Private methods + *********/ + /// Build a log parser model. + /// The stored file ID. + /// When the uploaded file would no longer have been available, before any renewal applied in this request + /// When the file will no longer be available, after any renewal applied in this request. + /// A non-blocking warning while uploading the log. + /// An error which occurred while uploading the log. + private LogParserModel GetModel(string? pasteID, DateTimeOffset? oldExpiry = null, DateTimeOffset? newExpiry = null, string? uploadWarning = null, string? uploadError = null) + { + Platform? platform = this.DetectClientPlatform(); - /// Detect the viewer's OS. - /// Returns the viewer OS if known, else null. - private Platform? DetectClientPlatform() + return new LogParserModel(pasteID, platform) { - string? userAgent = this.Request.Headers["User-Agent"]; + UploadWarning = uploadWarning, + UploadError = uploadError, + OldExpiry = oldExpiry, + NewExpiry = newExpiry + }; + } - if (userAgent != null) - { - if (userAgent.Contains("Windows")) - return Platform.Windows; + /// Detect the viewer's OS. + /// Returns the viewer OS if known, else null. + private Platform? DetectClientPlatform() + { + string? userAgent = this.Request.Headers["User-Agent"]; - if (userAgent.Contains("Android")) - return Platform.Android; + if (userAgent != null) + { + if (userAgent.Contains("Windows")) + return Platform.Windows; - if (userAgent.Contains("Linux")) - return Platform.Linux; + if (userAgent.Contains("Android")) + return Platform.Android; - if (userAgent.Contains("Mac")) - return Platform.Mac; - } + if (userAgent.Contains("Linux")) + return Platform.Linux; - return null; + if (userAgent.Contains("Mac")) + return Platform.Mac; } + + return null; } } diff --git a/src/SMAPI.Web/Controllers/ModsApiController.cs b/src/SMAPI.Web/Controllers/ModsApiController.cs index f56c1102f..eeae13bc4 100644 --- a/src/SMAPI.Web/Controllers/ModsApiController.cs +++ b/src/SMAPI.Web/Controllers/ModsApiController.cs @@ -26,369 +26,363 @@ using StardewModdingAPI.Web.Framework.ConfigModels; using StardewModdingAPI.Web.Framework.Metrics; -namespace StardewModdingAPI.Web.Controllers +namespace StardewModdingAPI.Web.Controllers; + +/// Provides an API to perform mod update checks. +[Produces("application/json")] +[Route("api/v{version:semanticVersion}/mods")] +internal class ModsApiController : Controller { - /// Provides an API to perform mod update checks. - [Produces("application/json")] - [Route("api/v{version:semanticVersion}/mods")] - internal class ModsApiController : Controller + /********* + ** Fields + *********/ + /// The mod sites which provide mod metadata. + private readonly ModSiteManager ModSites; + + /// The cache in which to store wiki data. + private readonly IWikiCacheRepository WikiCache; + + /// The cache in which to store mod data. + private readonly IModCacheRepository ModCache; + + /// The config settings for mod update checks. + private readonly IOptions Config; + + /// The internal mod metadata list. + private readonly ModDatabase ModDatabase; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The web hosting environment. + /// The cache in which to store wiki data. + /// The cache in which to store mod metadata. + /// The config settings for mod update checks. + /// The Chucklefish API client. + /// The CurseForge API client. + /// The GitHub API client. + /// The ModDrop API client. + /// The Nexus API client. + /// The API client for arbitrary update manifest URLs. + public ModsApiController(IWebHostEnvironment environment, IWikiCacheRepository wikiCache, IModCacheRepository modCache, IOptions config, IChucklefishClient chucklefish, ICurseForgeClient curseForge, IGitHubClient github, IModDropClient modDrop, INexusClient nexus, IUpdateManifestClient updateManifest) { - /********* - ** Fields - *********/ - /// The mod sites which provide mod metadata. - private readonly ModSiteManager ModSites; - - /// The cache in which to store wiki data. - private readonly IWikiCacheRepository WikiCache; - - /// The cache in which to store mod data. - private readonly IModCacheRepository ModCache; - - /// The config settings for mod update checks. - private readonly IOptions Config; - - /// The internal mod metadata list. - private readonly ModDatabase ModDatabase; - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The web hosting environment. - /// The cache in which to store wiki data. - /// The cache in which to store mod metadata. - /// The config settings for mod update checks. - /// The Chucklefish API client. - /// The CurseForge API client. - /// The GitHub API client. - /// The ModDrop API client. - /// The Nexus API client. - /// The API client for arbitrary update manifest URLs. - public ModsApiController(IWebHostEnvironment environment, IWikiCacheRepository wikiCache, IModCacheRepository modCache, IOptions config, IChucklefishClient chucklefish, ICurseForgeClient curseForge, IGitHubClient github, IModDropClient modDrop, INexusClient nexus, IUpdateManifestClient updateManifest) - { - this.ModDatabase = new ModToolkit().GetModDatabase(Path.Combine(environment.WebRootPath, "SMAPI.metadata.json")); + this.ModDatabase = new ModToolkit().GetModDatabase(Path.Combine(environment.WebRootPath, "SMAPI.metadata.json")); - this.WikiCache = wikiCache; - this.ModCache = modCache; - this.Config = config; - this.ModSites = new ModSiteManager(new IModSiteClient[] { chucklefish, curseForge, github, modDrop, nexus, updateManifest }); - } - - /// Fetch version metadata for the given mods. - /// The mod search criteria. - /// The requested API version. - [HttpPost] - public async Task> PostAsync([FromBody] ModSearchModel? model, [FromRoute] string version) - { - ApiMetricsModel metrics = MetricsManager.GetMetricsForNow(); - metrics.TrackRequest(); + this.WikiCache = wikiCache; + this.ModCache = modCache; + this.Config = config; + this.ModSites = new ModSiteManager(new IModSiteClient[] { chucklefish, curseForge, github, modDrop, nexus, updateManifest }); + } - if (model?.Mods == null) - return Array.Empty(); + /// Fetch version metadata for the given mods. + /// The mod search criteria. + /// The requested API version. + [HttpPost] + public async Task> PostAsync([FromBody] ModSearchModel? model, [FromRoute] string version) + { + ApiMetricsModel metrics = MetricsManager.GetMetricsForNow(); + metrics.TrackRequest(model?.ApiVersion, model?.GameVersion); - ModUpdateCheckConfig config = this.Config.Value; + if (model?.Mods == null) + return Array.Empty(); - // fetch wiki data - WikiModEntry[] wikiData = this.WikiCache.GetWikiMods().Select(p => p.Data).ToArray(); - IDictionary mods = new Dictionary(StringComparer.CurrentCultureIgnoreCase); - foreach (ModSearchEntryModel mod in model.Mods) - { - if (string.IsNullOrWhiteSpace(mod.ID)) - continue; + ModUpdateCheckConfig config = this.Config.Value; - // special case: if this is an update check for the official SMAPI repo, check the Nexus mod page for beta versions - if (mod.ID == config.SmapiInfo.ID && mod.UpdateKeys.Any(key => key == config.SmapiInfo.DefaultUpdateKey) && mod.InstalledVersion?.IsPrerelease() == true) - mod.AddUpdateKeys(config.SmapiInfo.AddBetaUpdateKeys); + // fetch wiki data + WikiModEntry[] wikiData = this.WikiCache.GetWikiMods().Select(p => p.Data).ToArray(); + IDictionary mods = new Dictionary(StringComparer.CurrentCultureIgnoreCase); + foreach (ModSearchEntryModel mod in model.Mods) + { + if (string.IsNullOrWhiteSpace(mod.ID)) + continue; - // fetch result - ModEntryModel result = await this.GetModData(mod, wikiData, model.IncludeExtendedMetadata, model.ApiVersion, metrics); - if (!model.IncludeExtendedMetadata && (model.ApiVersion == null || mod.InstalledVersion == null)) - { - result.Errors = result.Errors - .Concat(new[] { $"This API can't suggest an update because {nameof(model.ApiVersion)} or {nameof(mod.InstalledVersion)} are null, and you didn't specify {nameof(model.IncludeExtendedMetadata)} to get other info. See the SMAPI technical docs for usage." }) - .ToArray(); - } + // special case: if this is an update check for the official SMAPI repo, check the Nexus mod page for beta versions + if (mod.ID == config.SmapiInfo.ID && mod.UpdateKeys.Any(key => key == config.SmapiInfo.DefaultUpdateKey) && mod.InstalledVersion?.IsPrerelease() == true) + mod.AddUpdateKeys(config.SmapiInfo.AddBetaUpdateKeys); - mods[mod.ID] = result; + // fetch result + ModEntryModel result = await this.GetModData(mod, wikiData, model.IncludeExtendedMetadata, model.ApiVersion, metrics); + if (!model.IncludeExtendedMetadata && (model.ApiVersion == null || mod.InstalledVersion == null)) + { + result.Errors = result.Errors + .Concat(new[] { $"This API can't suggest an update because {nameof(model.ApiVersion)} or {nameof(mod.InstalledVersion)} are null, and you didn't specify {nameof(model.IncludeExtendedMetadata)} to get other info. See the SMAPI technical docs for usage." }) + .ToArray(); } - // return data - return mods.Values; + mods[mod.ID] = result; } - /// Fetch a summary of update-check metrics since the server was last deployed or restarted. - [HttpGet("metrics")] - public MetricsSummary GetMetrics() - { - return MetricsManager.GetSummary(this.Config.Value); - } + // return data + return mods.Values; + } + + /// Fetch a summary of update-check metrics since the server was last deployed or restarted. + [HttpGet("metrics")] + public MetricsSummary GetMetrics() + { + return MetricsManager.GetSummary(this.Config.Value); + } - /********* - ** Private methods - *********/ - /// Get the metadata for a mod. - /// The mod data to match. - /// The wiki data. - /// Whether to include extended metadata for each mod. - /// The SMAPI version installed by the player. - /// The metrics to update with update-check results. - /// Returns the mod data if found, else null. - private async Task GetModData(ModSearchEntryModel search, WikiModEntry[] wikiData, bool includeExtendedMetadata, ISemanticVersion? apiVersion, ApiMetricsModel metrics) + /********* + ** Private methods + *********/ + /// Get the metadata for a mod. + /// The mod data to match. + /// The wiki data. + /// Whether to include extended metadata for each mod. + /// The SMAPI version installed by the player. + /// The metrics to update with update-check results. + /// Returns the mod data if found, else null. + private async Task GetModData(ModSearchEntryModel search, WikiModEntry[] wikiData, bool includeExtendedMetadata, ISemanticVersion? apiVersion, ApiMetricsModel metrics) + { + // cross-reference data + ModDataRecord? record = this.ModDatabase.Get(search.ID); + WikiModEntry? wikiEntry = wikiData.FirstOrDefault(entry => entry.ID.Contains(search.ID.Trim(), StringComparer.OrdinalIgnoreCase)); + UpdateKey[] updateKeys = this.GetUpdateKeys(search.UpdateKeys, record, wikiEntry).ToArray(); + ModOverrideConfig? overrides = this.Config.Value.ModOverrides.FirstOrDefault(p => p.ID.Equals(search.ID.Trim(), StringComparison.OrdinalIgnoreCase)); + bool allowNonStandardVersions = overrides?.AllowNonStandardVersions ?? false; + + // SMAPI versions with a '-beta' tag indicate major changes that may need beta mod versions. + // This doesn't apply to normal prerelease versions which have an '-alpha' tag. + bool isSmapiBeta = apiVersion != null && apiVersion.IsPrerelease() && apiVersion.PrereleaseTag.StartsWith("beta"); + + // get latest versions + ModEntryModel result = new(search.ID); + IList errors = new List(); + ModEntryVersionModel? main = null; + ModEntryVersionModel? optional = null; + ModEntryVersionModel? unofficial = null; + ModEntryVersionModel? unofficialForBeta = null; + foreach (UpdateKey updateKey in updateKeys) { - // cross-reference data - ModDataRecord? record = this.ModDatabase.Get(search.ID); - WikiModEntry? wikiEntry = wikiData.FirstOrDefault(entry => entry.ID.Contains(search.ID.Trim(), StringComparer.OrdinalIgnoreCase)); - UpdateKey[] updateKeys = this.GetUpdateKeys(search.UpdateKeys, record, wikiEntry).ToArray(); - ModOverrideConfig? overrides = this.Config.Value.ModOverrides.FirstOrDefault(p => p.ID.Equals(search.ID.Trim(), StringComparison.OrdinalIgnoreCase)); - bool allowNonStandardVersions = overrides?.AllowNonStandardVersions ?? false; - - // SMAPI versions with a '-beta' tag indicate major changes that may need beta mod versions. - // This doesn't apply to normal prerelease versions which have an '-alpha' tag. - bool isSmapiBeta = apiVersion != null && apiVersion.IsPrerelease() && apiVersion.PrereleaseTag.StartsWith("beta"); - - // get latest versions - ModEntryModel result = new(search.ID); - IList errors = new List(); - ModEntryVersionModel? main = null; - ModEntryVersionModel? optional = null; - ModEntryVersionModel? unofficial = null; - ModEntryVersionModel? unofficialForBeta = null; - foreach (UpdateKey updateKey in updateKeys) + // validate update key + if (!updateKey.LooksValid) { - // validate update key - if ( - !updateKey.LooksValid -#if SMAPI_DEPRECATED - || (updateKey.Site == ModSiteKey.UpdateManifest && apiVersion?.IsNewerThan("4.0.0-alpha") != true) // 4.0-alpha feature, don't make available to released mods in case it changes before release -#endif - ) - { - errors.Add($"The update key '{updateKey}' isn't in a valid format. It should contain the site key and mod ID like 'Nexus:541', with an optional subkey like 'Nexus:541@subkey'."); - continue; - } - - // fetch data - ModInfoModel data = await this.GetInfoForUpdateKeyAsync(updateKey, allowNonStandardVersions, wikiEntry?.Overrides?.ChangeRemoteVersions, metrics); - if (data.Status != RemoteModStatus.Ok) - { - errors.Add(data.Error ?? data.Status.ToString()); - continue; - } - - // if there's only a prerelease version (e.g. from GitHub), don't override the main version - ISemanticVersion? curMain = data.Version; - ISemanticVersion? curPreview = data.PreviewVersion; - string? curMainUrl = data.MainModPageUrl; - string? curPreviewUrl = data.PreviewModPageUrl; - if (curPreview == null && curMain?.IsPrerelease() == true) - { - curPreview = curMain; - curPreviewUrl = curMainUrl; - curMain = null; - curMainUrl = null; - } - - // handle versions - if (this.IsNewer(curMain, main?.Version)) - main = new ModEntryVersionModel(curMain, curMainUrl ?? data.Url!); - if (this.IsNewer(curPreview, optional?.Version)) - optional = new ModEntryVersionModel(curPreview, curPreviewUrl ?? data.Url!); + errors.Add($"The update key '{updateKey}' isn't in a valid format. It should contain the site key and mod ID like 'Nexus:541', with an optional subkey like 'Nexus:541@subkey'."); + continue; } - // get unofficial version - if (wikiEntry?.Compatibility.UnofficialVersion != null && this.IsNewer(wikiEntry.Compatibility.UnofficialVersion, main?.Version) && this.IsNewer(wikiEntry.Compatibility.UnofficialVersion, optional?.Version)) - unofficial = new ModEntryVersionModel(wikiEntry.Compatibility.UnofficialVersion, $"{this.Url.PlainAction("Index", "Mods", absoluteUrl: true)}#{wikiEntry.Anchor}"); - - // get unofficial version for beta - if (wikiEntry is { HasBetaInfo: true }) + // fetch data + ModInfoModel data = await this.GetInfoForUpdateKeyAsync(updateKey, allowNonStandardVersions, wikiEntry?.Overrides?.ChangeRemoteVersions, metrics); + if (data.Status != RemoteModStatus.Ok) { - if (wikiEntry.BetaCompatibility.Status == WikiCompatibilityStatus.Unofficial) - { - if (wikiEntry.BetaCompatibility.UnofficialVersion != null) - { - unofficialForBeta = (wikiEntry.BetaCompatibility.UnofficialVersion != null && this.IsNewer(wikiEntry.BetaCompatibility.UnofficialVersion, main?.Version) && this.IsNewer(wikiEntry.BetaCompatibility.UnofficialVersion, optional?.Version)) - ? new ModEntryVersionModel(wikiEntry.BetaCompatibility.UnofficialVersion, $"{this.Url.PlainAction("Index", "Mods", absoluteUrl: true)}#{wikiEntry.Anchor}") - : null; - } - else - unofficialForBeta = unofficial; - } + errors.Add(data.Error ?? data.Status.ToString()); + continue; } - // fallback to preview if latest is invalid - if (main == null && optional != null) + // if there's only a prerelease version (e.g. from GitHub), don't override the main version + ISemanticVersion? curMain = data.Version; + ISemanticVersion? curPreview = data.PreviewVersion; + string? curMainUrl = data.MainModPageUrl; + string? curPreviewUrl = data.PreviewModPageUrl; + if (curPreview == null && curMain?.IsPrerelease() == true) { - main = optional; - optional = null; + curPreview = curMain; + curPreviewUrl = curMainUrl; + curMain = null; + curMainUrl = null; } - // special cases - if (overrides?.SetUrl != null) - { - if (main != null) - main = new(main.Version, overrides.SetUrl); - if (optional != null) - optional = new(optional.Version, overrides.SetUrl); - } + // handle versions + if (this.IsNewer(curMain, main?.Version)) + main = new ModEntryVersionModel(curMain, curMainUrl ?? data.Url!); + if (this.IsNewer(curPreview, optional?.Version)) + optional = new ModEntryVersionModel(curPreview, curPreviewUrl ?? data.Url!); + } - // get recommended update (if any) - ISemanticVersion? installedVersion = this.ModSites.GetMappedVersion(search.InstalledVersion?.ToString(), wikiEntry?.Overrides?.ChangeLocalVersions, allowNonStandard: allowNonStandardVersions); - if (apiVersion != null && installedVersion != null) + // get unofficial version + if (wikiEntry?.Compatibility.UnofficialVersion != null && this.IsNewer(wikiEntry.Compatibility.UnofficialVersion, main?.Version) && this.IsNewer(wikiEntry.Compatibility.UnofficialVersion, optional?.Version)) + unofficial = new ModEntryVersionModel(wikiEntry.Compatibility.UnofficialVersion, $"{this.Url.PlainAction("Index", "Mods", absoluteUrl: true)}#{wikiEntry.Anchor}"); + + // get unofficial version for beta + if (wikiEntry is { HasBetaInfo: true }) + { + if (wikiEntry.BetaCompatibility.Status == WikiCompatibilityStatus.Unofficial) { - // get newer versions - List updates = new List(); - if (this.IsRecommendedUpdate(installedVersion, main?.Version, useBetaChannel: true)) - updates.Add(main); - if (this.IsRecommendedUpdate(installedVersion, optional?.Version, useBetaChannel: isSmapiBeta || installedVersion.IsPrerelease() || search.IsBroken)) - updates.Add(optional); - if (this.IsRecommendedUpdate(installedVersion, unofficial?.Version, useBetaChannel: true)) - updates.Add(unofficial); - if (this.IsRecommendedUpdate(installedVersion, unofficialForBeta?.Version, useBetaChannel: apiVersion.IsPrerelease())) - updates.Add(unofficialForBeta); - - // get newest version - ModEntryVersionModel? newest = null; - foreach (ModEntryVersionModel update in updates) + if (wikiEntry.BetaCompatibility.UnofficialVersion != null) { - if (newest == null || update.Version.IsNewerThan(newest.Version)) - newest = update; + unofficialForBeta = (wikiEntry.BetaCompatibility.UnofficialVersion != null && this.IsNewer(wikiEntry.BetaCompatibility.UnofficialVersion, main?.Version) && this.IsNewer(wikiEntry.BetaCompatibility.UnofficialVersion, optional?.Version)) + ? new ModEntryVersionModel(wikiEntry.BetaCompatibility.UnofficialVersion, $"{this.Url.PlainAction("Index", "Mods", absoluteUrl: true)}#{wikiEntry.Anchor}") + : null; } - - // set field - result.SuggestedUpdate = newest != null - ? new ModEntryVersionModel(newest.Version, newest.Url) - : null; + else + unofficialForBeta = unofficial; } - - // add extended metadata - if (includeExtendedMetadata) - result.Metadata = new ModExtendedMetadataModel(wikiEntry, record, main: main, optional: optional, unofficial: unofficial, unofficialForBeta: unofficialForBeta); - - // add result - result.Errors = errors.ToArray(); - return result; } - /// Get whether a given version should be offered to the user as an update. - /// The current semantic version. - /// The target semantic version. - /// Whether the user enabled the beta channel and should be offered prerelease updates. - private bool IsRecommendedUpdate(ISemanticVersion currentVersion, [NotNullWhen(true)] ISemanticVersion? newVersion, bool useBetaChannel) + // fallback to preview if latest is invalid + if (main == null && optional != null) { - return - newVersion != null - && newVersion.IsNewerThan(currentVersion) - && (useBetaChannel || !newVersion.IsPrerelease()); + main = optional; + optional = null; } - /// Get whether a version is newer than an version. - /// The current version. - /// The other version. - private bool IsNewer([NotNullWhen(true)] ISemanticVersion? current, ISemanticVersion? other) + // special cases + if (overrides?.SetUrl != null) { - return current != null && (other == null || other.IsOlderThan(current)); + if (main != null) + main = new(main.Version, overrides.SetUrl); + if (optional != null) + optional = new(optional.Version, overrides.SetUrl); } - /// Get the mod info for an update key. - /// The namespaced update key. - /// Whether to allow non-standard versions. - /// The changes to apply to remote versions for update checks. - /// The metrics to update with update-check results. - private async Task GetInfoForUpdateKeyAsync(UpdateKey updateKey, bool allowNonStandardVersions, ChangeDescriptor? mapRemoteVersions, ApiMetricsModel metrics) + // get recommended update (if any) + ISemanticVersion? installedVersion = this.ModSites.GetMappedVersion(search.InstalledVersion?.ToString(), wikiEntry?.Overrides?.ChangeLocalVersions, allowNonStandard: allowNonStandardVersions); + if (apiVersion != null && installedVersion != null) { - if (!updateKey.LooksValid) - return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, $"Invalid update key '{updateKey}'."); - - // get mod page - bool wasCached = - this.ModCache.TryGetMod(updateKey.Site, updateKey.ID, out Cached? cachedMod) - && !this.ModCache.IsStale(cachedMod.LastUpdated, cachedMod.Data.Status == RemoteModStatus.TemporaryError ? this.Config.Value.ErrorCacheMinutes : this.Config.Value.SuccessCacheMinutes); - IModPage page; - if (wasCached) - page = cachedMod!.Data; - else + // get newer versions + List updates = new List(); + if (this.IsRecommendedUpdate(installedVersion, main?.Version, useBetaChannel: true)) + updates.Add(main); + if (this.IsRecommendedUpdate(installedVersion, optional?.Version, useBetaChannel: isSmapiBeta || installedVersion.IsPrerelease() || search.IsBroken)) + updates.Add(optional); + if (this.IsRecommendedUpdate(installedVersion, unofficial?.Version, useBetaChannel: true)) + updates.Add(unofficial); + if (this.IsRecommendedUpdate(installedVersion, unofficialForBeta?.Version, useBetaChannel: apiVersion.IsPrerelease())) + updates.Add(unofficialForBeta); + + // get newest version + ModEntryVersionModel? newest = null; + foreach (ModEntryVersionModel update in updates) { - page = await this.ModSites.GetModPageAsync(updateKey); - this.ModCache.SaveMod(updateKey.Site, updateKey.ID, page); + if (newest == null || update.Version.IsNewerThan(newest.Version)) + newest = update; } - // update metrics - metrics.TrackUpdateKey(updateKey, wasCached, page.IsValid); - - // get version info - return this.ModSites.GetPageVersions(page, updateKey, allowNonStandardVersions, mapRemoteVersions); + // set field + result.SuggestedUpdate = newest != null + ? new ModEntryVersionModel(newest.Version, newest.Url) + : null; } - /// Get update keys based on the available mod metadata, while maintaining the precedence order. - /// The specified update keys. - /// The mod's entry in SMAPI's internal database. - /// The mod's entry in the wiki list. - private IEnumerable GetUpdateKeys(string[]? specifiedKeys, ModDataRecord? record, WikiModEntry? entry) + // add extended metadata + if (includeExtendedMetadata) + result.Metadata = new ModExtendedMetadataModel(wikiEntry, record, main: main, optional: optional, unofficial: unofficial, unofficialForBeta: unofficialForBeta); + + // add result + result.Errors = errors.ToArray(); + return result; + } + + /// Get whether a given version should be offered to the user as an update. + /// The current semantic version. + /// The target semantic version. + /// Whether the user enabled the beta channel and should be offered prerelease updates. + private bool IsRecommendedUpdate(ISemanticVersion currentVersion, [NotNullWhen(true)] ISemanticVersion? newVersion, bool useBetaChannel) + { + return + newVersion != null + && newVersion.IsNewerThan(currentVersion) + && (useBetaChannel || !newVersion.IsPrerelease()); + } + + /// Get whether a version is newer than an version. + /// The current version. + /// The other version. + private bool IsNewer([NotNullWhen(true)] ISemanticVersion? current, ISemanticVersion? other) + { + return current != null && (other == null || other.IsOlderThan(current)); + } + + /// Get the mod info for an update key. + /// The namespaced update key. + /// Whether to allow non-standard versions. + /// The changes to apply to remote versions for update checks. + /// The metrics to update with update-check results. + private async Task GetInfoForUpdateKeyAsync(UpdateKey updateKey, bool allowNonStandardVersions, ChangeDescriptor? mapRemoteVersions, ApiMetricsModel metrics) + { + if (!updateKey.LooksValid) + return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, $"Invalid update key '{updateKey}'."); + + // get mod page + bool wasCached = + this.ModCache.TryGetMod(updateKey.Site, updateKey.ID, out Cached? cachedMod) + && !this.ModCache.IsStale(cachedMod.LastUpdated, cachedMod.Data.Status == RemoteModStatus.TemporaryError ? this.Config.Value.ErrorCacheMinutes : this.Config.Value.SuccessCacheMinutes); + IModPage page; + if (wasCached) + page = cachedMod!.Data; + else { - // get unique update keys - List updateKeys = this.GetUnfilteredUpdateKeys(specifiedKeys, record, entry) - .Select(UpdateKey.Parse) - .Distinct() - .ToList(); - - // apply overrides from wiki - if (entry?.Overrides?.ChangeUpdateKeys?.HasChanges == true) - { - List newKeys = updateKeys.Select(p => p.ToString()).ToList(); - entry.Overrides.ChangeUpdateKeys.Apply(newKeys); - updateKeys = newKeys.Select(UpdateKey.Parse).ToList(); - } + page = await this.ModSites.GetModPageAsync(updateKey); + this.ModCache.SaveMod(updateKey.Site, updateKey.ID, page); + } - // if the list has both an update key (like "Nexus:2400") and subkey (like "Nexus:2400@subkey") for the same page, the subkey takes priority - { - var removeKeys = new HashSet(); - foreach (UpdateKey key in updateKeys) - { - if (key.Subkey != null) - removeKeys.Add(new UpdateKey(key.Site, key.ID, null)); - } - if (removeKeys.Any()) - updateKeys.RemoveAll(removeKeys.Contains); - } + // update metrics + metrics.TrackUpdateKey(updateKey, wasCached, page.IsValid); + + // get version info + return this.ModSites.GetPageVersions(page, updateKey, allowNonStandardVersions, mapRemoteVersions); + } - return updateKeys; + /// Get update keys based on the available mod metadata, while maintaining the precedence order. + /// The specified update keys. + /// The mod's entry in SMAPI's internal database. + /// The mod's entry in the wiki list. + private IEnumerable GetUpdateKeys(string[]? specifiedKeys, ModDataRecord? record, WikiModEntry? entry) + { + // get unique update keys + List updateKeys = this.GetUnfilteredUpdateKeys(specifiedKeys, record, entry) + .Select(UpdateKey.Parse) + .Distinct() + .ToList(); + + // apply overrides from wiki + if (entry?.Overrides?.ChangeUpdateKeys?.HasChanges == true) + { + List newKeys = updateKeys.Select(p => p.ToString()).ToList(); + entry.Overrides.ChangeUpdateKeys.Apply(newKeys); + updateKeys = newKeys.Select(UpdateKey.Parse).ToList(); } - /// Get every available update key based on the available mod metadata, including duplicates and keys which should be filtered. - /// The specified update keys. - /// The mod's entry in SMAPI's internal database. - /// The mod's entry in the wiki list. - private IEnumerable GetUnfilteredUpdateKeys(string[]? specifiedKeys, ModDataRecord? record, WikiModEntry? entry) + // if the list has both an update key (like "Nexus:2400") and subkey (like "Nexus:2400@subkey") for the same page, the subkey takes priority { - // specified update keys - foreach (string key in specifiedKeys ?? Array.Empty()) + var removeKeys = new HashSet(); + foreach (UpdateKey key in updateKeys) { - if (!string.IsNullOrWhiteSpace(key)) - yield return key.Trim(); + if (key.Subkey != null) + removeKeys.Add(new UpdateKey(key.Site, key.ID, null)); } + if (removeKeys.Any()) + updateKeys.RemoveAll(removeKeys.Contains); + } - // default update key - { - string? defaultKey = record?.GetDefaultUpdateKey(); - if (!string.IsNullOrWhiteSpace(defaultKey)) - yield return defaultKey; - } + return updateKeys; + } - // wiki metadata - if (entry != null) - { - if (entry.NexusID.HasValue) - yield return UpdateKey.GetString(ModSiteKey.Nexus, entry.NexusID.ToString()); - if (entry.ModDropID.HasValue) - yield return UpdateKey.GetString(ModSiteKey.ModDrop, entry.ModDropID.ToString()); - if (entry.CurseForgeID.HasValue) - yield return UpdateKey.GetString(ModSiteKey.CurseForge, entry.CurseForgeID.ToString()); - if (entry.ChucklefishID.HasValue) - yield return UpdateKey.GetString(ModSiteKey.Chucklefish, entry.ChucklefishID.ToString()); - } + /// Get every available update key based on the available mod metadata, including duplicates and keys which should be filtered. + /// The specified update keys. + /// The mod's entry in SMAPI's internal database. + /// The mod's entry in the wiki list. + private IEnumerable GetUnfilteredUpdateKeys(string[]? specifiedKeys, ModDataRecord? record, WikiModEntry? entry) + { + // specified update keys + foreach (string key in specifiedKeys ?? Array.Empty()) + { + if (!string.IsNullOrWhiteSpace(key)) + yield return key.Trim(); + } + + // default update key + { + string? defaultKey = record?.GetDefaultUpdateKey(); + if (!string.IsNullOrWhiteSpace(defaultKey)) + yield return defaultKey; + } + + // wiki metadata + if (entry != null) + { + if (entry.NexusID.HasValue) + yield return UpdateKey.GetString(ModSiteKey.Nexus, entry.NexusID.ToString()); + if (entry.ModDropID.HasValue) + yield return UpdateKey.GetString(ModSiteKey.ModDrop, entry.ModDropID.ToString()); + if (entry.CurseForgeID.HasValue) + yield return UpdateKey.GetString(ModSiteKey.CurseForge, entry.CurseForgeID.ToString()); + if (entry.ChucklefishID.HasValue) + yield return UpdateKey.GetString(ModSiteKey.Chucklefish, entry.ChucklefishID.ToString()); } } } diff --git a/src/SMAPI.Web/Controllers/ModsController.cs b/src/SMAPI.Web/Controllers/ModsController.cs index 919afa5be..b76fbb9b4 100644 --- a/src/SMAPI.Web/Controllers/ModsController.cs +++ b/src/SMAPI.Web/Controllers/ModsController.cs @@ -8,65 +8,64 @@ using StardewModdingAPI.Web.Framework.ConfigModels; using StardewModdingAPI.Web.ViewModels; -namespace StardewModdingAPI.Web.Controllers +namespace StardewModdingAPI.Web.Controllers; + +/// Provides user-friendly info about SMAPI mods. +internal class ModsController : Controller { - /// Provides user-friendly info about SMAPI mods. - internal class ModsController : Controller - { - /********* - ** Fields - *********/ - /// The cache in which to store mod metadata. - private readonly IWikiCacheRepository Cache; + /********* + ** Fields + *********/ + /// The cache in which to store mod metadata. + private readonly IWikiCacheRepository Cache; - /// The number of minutes before which wiki data should be considered old. - private readonly int StaleMinutes; + /// The number of minutes before which wiki data should be considered old. + private readonly int StaleMinutes; - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The cache in which to store mod metadata. - /// The config settings for mod update checks. - public ModsController(IWikiCacheRepository cache, IOptions configProvider) - { - ModCompatibilityListConfig config = configProvider.Value; + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The cache in which to store mod metadata. + /// The config settings for mod update checks. + public ModsController(IWikiCacheRepository cache, IOptions configProvider) + { + ModCompatibilityListConfig config = configProvider.Value; - this.Cache = cache; - this.StaleMinutes = config.StaleMinutes; - } + this.Cache = cache; + this.StaleMinutes = config.StaleMinutes; + } - /// Display information for all mods. - [HttpGet] - [Route("mods")] - public ViewResult Index() - { - return this.View("Index", this.FetchData()); - } + /// Display information for all mods. + [HttpGet] + [Route("mods")] + public ViewResult Index() + { + return this.View("Index", this.FetchData()); + } - /********* - ** Private methods - *********/ - /// Asynchronously fetch mod metadata from the wiki. - public ModListModel FetchData() - { - // fetch cached data - if (!this.Cache.TryGetWikiMetadata(out Cached? metadata)) - return new ModListModel(null, null, Array.Empty(), lastUpdated: DateTimeOffset.UtcNow, isStale: true); + /********* + ** Private methods + *********/ + /// Asynchronously fetch mod metadata from the wiki. + public ModListModel FetchData() + { + // fetch cached data + if (!this.Cache.TryGetWikiMetadata(out Cached? metadata)) + return new ModListModel(null, null, Array.Empty(), lastUpdated: DateTimeOffset.UtcNow, isStale: true); - // build model - return new ModListModel( - stableVersion: metadata.Data.StableVersion, - betaVersion: metadata.Data.BetaVersion, - mods: this.Cache - .GetWikiMods() - .Select(mod => new ModModel(mod.Data)) - .OrderBy(p => Regex.Replace((p.Name ?? "").ToLower(), "[^a-z0-9]", "")), // ignore case, spaces, and special characters when sorting - lastUpdated: metadata.LastUpdated, - isStale: this.Cache.IsStale(metadata.LastUpdated, this.StaleMinutes) - ); - } + // build model + return new ModListModel( + stableVersion: metadata.Data.StableVersion, + betaVersion: metadata.Data.BetaVersion, + mods: this.Cache + .GetWikiMods() + .Select(mod => new ModModel(mod.Data)) + .OrderBy(p => Regex.Replace((p.Name ?? "").ToLower(), "[^a-z0-9]", "")), // ignore case, spaces, and special characters when sorting + lastUpdated: metadata.LastUpdated, + isStale: this.Cache.IsStale(metadata.LastUpdated, this.StaleMinutes) + ); } } diff --git a/src/SMAPI.Web/Framework/AllowLargePostsAttribute.cs b/src/SMAPI.Web/Framework/AllowLargePostsAttribute.cs index bd414ea20..08e2286d8 100644 --- a/src/SMAPI.Web/Framework/AllowLargePostsAttribute.cs +++ b/src/SMAPI.Web/Framework/AllowLargePostsAttribute.cs @@ -2,51 +2,50 @@ using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Mvc.Filters; -namespace StardewModdingAPI.Web.Framework +namespace StardewModdingAPI.Web.Framework; + +/// A filter which increases the maximum request size for an endpoint. +/// Derived from . +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] +public class AllowLargePostsAttribute : Attribute, IAuthorizationFilter, IOrderedFilter { - /// A filter which increases the maximum request size for an endpoint. - /// Derived from . - [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] - public class AllowLargePostsAttribute : Attribute, IAuthorizationFilter, IOrderedFilter - { - /********* - ** Fields - *********/ - /// The underlying form options. - private readonly FormOptions FormOptions; + /********* + ** Fields + *********/ + /// The underlying form options. + private readonly FormOptions FormOptions; - /********* - ** Accessors - *********/ - /// The attribute order. - public int Order { get; set; } + /********* + ** Accessors + *********/ + /// The attribute order. + public int Order { get; set; } - /********* - ** Public methods - *********/ - /// Construct an instance. - public AllowLargePostsAttribute() + /********* + ** Public methods + *********/ + /// Construct an instance. + public AllowLargePostsAttribute() + { + this.FormOptions = new FormOptions { - this.FormOptions = new FormOptions - { - ValueLengthLimit = 200 * 1024 * 1024 // 200MB - }; - } + ValueLengthLimit = 200 * 1024 * 1024 // 200MB + }; + } + + /// Called early in the filter pipeline to confirm request is authorized. + /// The authorization filter context. + public void OnAuthorization(AuthorizationFilterContext context) + { + IFeatureCollection features = context.HttpContext.Features; + IFormFeature? formFeature = features.Get(); - /// Called early in the filter pipeline to confirm request is authorized. - /// The authorization filter context. - public void OnAuthorization(AuthorizationFilterContext context) + if (formFeature?.Form == null) { - IFeatureCollection features = context.HttpContext.Features; - IFormFeature? formFeature = features.Get(); - - if (formFeature?.Form == null) - { - // Request form has not been read yet, so set the limits - features.Set(new FormFeature(context.HttpContext.Request, this.FormOptions)); - } + // Request form has not been read yet, so set the limits + features.Set(new FormFeature(context.HttpContext.Request, this.FormOptions)); } } } diff --git a/src/SMAPI.Web/Framework/Caching/BaseCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/BaseCacheRepository.cs index f5354b939..4651efeac 100644 --- a/src/SMAPI.Web/Framework/Caching/BaseCacheRepository.cs +++ b/src/SMAPI.Web/Framework/Caching/BaseCacheRepository.cs @@ -1,19 +1,16 @@ using System; -namespace StardewModdingAPI.Web.Framework.Caching +namespace StardewModdingAPI.Web.Framework.Caching; + +/// The base logic for a cache repository. +internal abstract class BaseCacheRepository : ICacheRepository { - /// The base logic for a cache repository. - internal abstract class BaseCacheRepository + /********* + ** Public methods + *********/ + /// + public bool IsStale(DateTimeOffset lastUpdated, int staleMinutes) { - /********* - ** Public methods - *********/ - /// Whether cached data is stale. - /// The date when the data was updated. - /// The age in minutes before data is considered stale. - public bool IsStale(DateTimeOffset lastUpdated, int staleMinutes) - { - return lastUpdated < DateTimeOffset.UtcNow.AddMinutes(-staleMinutes); - } + return lastUpdated < DateTimeOffset.UtcNow.AddMinutes(-staleMinutes); } } diff --git a/src/SMAPI.Web/Framework/Caching/BaseExportCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/BaseExportCacheRepository.cs new file mode 100644 index 000000000..879d02766 --- /dev/null +++ b/src/SMAPI.Web/Framework/Caching/BaseExportCacheRepository.cs @@ -0,0 +1,36 @@ +using System.Diagnostics.CodeAnalysis; +using StardewModdingAPI.Toolkit.Framework.Clients; + +namespace StardewModdingAPI.Web.Framework.Caching; + +/// The base logic for an export cache repository. +internal abstract class BaseExportCacheRepository : BaseCacheRepository, IExportCacheRepository +{ + /********* + ** Accessors + *********/ + /// + [MemberNotNullWhen(true, nameof(BaseExportCacheRepository.CacheHeaders))] + public abstract bool IsLoaded { get; } + + /// + public abstract ApiCacheHeaders? CacheHeaders { get; } + + + /********* + ** Public methods + *********/ + /// + public bool IsStale(int staleMinutes) + { + return + this.IsLoaded + && this.IsStale(this.CacheHeaders.LastModified, staleMinutes); + } + + /// + public abstract void Clear(); + + /// + public abstract void SetCacheHeaders(ApiCacheHeaders headers); +} diff --git a/src/SMAPI.Web/Framework/Caching/Cached.cs b/src/SMAPI.Web/Framework/Caching/Cached.cs index b393e1e1d..8798e0005 100644 --- a/src/SMAPI.Web/Framework/Caching/Cached.cs +++ b/src/SMAPI.Web/Framework/Caching/Cached.cs @@ -1,34 +1,33 @@ using System; -namespace StardewModdingAPI.Web.Framework.Caching +namespace StardewModdingAPI.Web.Framework.Caching; + +/// A cache entry. +/// The cached value type. +internal class Cached { - /// A cache entry. - /// The cached value type. - internal class Cached - { - /********* - ** Accessors - *********/ - /// The cached data. - public T Data { get; } + /********* + ** Accessors + *********/ + /// The cached data. + public T Data { get; } - /// When the data was last updated. - public DateTimeOffset LastUpdated { get; } + /// When the data was last updated. + public DateTimeOffset LastUpdated { get; } - /// When the data was last requested through the mod API. - public DateTimeOffset LastRequested { get; internal set; } + /// When the data was last requested through the mod API. + public DateTimeOffset LastRequested { get; internal set; } - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The cached data. - public Cached(T data) - { - this.Data = data; - this.LastUpdated = DateTimeOffset.UtcNow; - this.LastRequested = DateTimeOffset.UtcNow; - } + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The cached data. + public Cached(T data) + { + this.Data = data; + this.LastUpdated = DateTimeOffset.UtcNow; + this.LastRequested = DateTimeOffset.UtcNow; } } diff --git a/src/SMAPI.Web/Framework/Caching/CurseForgeExport/CurseForgeExportCacheMemoryRepository.cs b/src/SMAPI.Web/Framework/Caching/CurseForgeExport/CurseForgeExportCacheMemoryRepository.cs new file mode 100644 index 000000000..60b0e719c --- /dev/null +++ b/src/SMAPI.Web/Framework/Caching/CurseForgeExport/CurseForgeExportCacheMemoryRepository.cs @@ -0,0 +1,66 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using StardewModdingAPI.Toolkit.Framework.Clients; +using StardewModdingAPI.Toolkit.Framework.Clients.CurseForgeExport.ResponseModels; + +namespace StardewModdingAPI.Web.Framework.Caching.CurseForgeExport; + +/// Manages cached mod data from the CurseForge export API in-memory. +internal class CurseForgeExportCacheMemoryRepository : BaseExportCacheRepository, ICurseForgeExportCacheRepository +{ + /********* + ** Fields + *********/ + /// The cached mod data from the CurseForge export API. + private CurseForgeFullExport? Data; + + + /********* + ** Accessors + *********/ + /// + [MemberNotNullWhen(true, nameof(CurseForgeExportCacheMemoryRepository.Data))] + public override bool IsLoaded => this.Data?.Mods.Count > 0; + + /// + public override ApiCacheHeaders? CacheHeaders => this.Data?.CacheHeaders; + + + /********* + ** Public methods + *********/ + /// + public override void Clear() + { + this.SetData(null); + } + + /// + public override void SetCacheHeaders(ApiCacheHeaders headers) + { + if (!this.IsLoaded) + throw new InvalidOperationException("Can't set the cache headers before any data is loaded."); + + this.Data.CacheHeaders = headers; + } + + /// + public bool TryGetMod(uint id, [NotNullWhen(true)] out CurseForgeModExport? mod) + { + var data = this.Data?.Mods; + + if (data is null || !data.TryGetValue(id, out mod)) + { + mod = null; + return false; + } + + return true; + } + + /// + public void SetData(CurseForgeFullExport? export) + { + this.Data = export; + } +} diff --git a/src/SMAPI.Web/Framework/Caching/CurseForgeExport/ICurseForgeExportCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/CurseForgeExport/ICurseForgeExportCacheRepository.cs new file mode 100644 index 000000000..51159e80c --- /dev/null +++ b/src/SMAPI.Web/Framework/Caching/CurseForgeExport/ICurseForgeExportCacheRepository.cs @@ -0,0 +1,20 @@ +using System.Diagnostics.CodeAnalysis; +using StardewModdingAPI.Toolkit.Framework.Clients.CurseForgeExport.ResponseModels; + +namespace StardewModdingAPI.Web.Framework.Caching.CurseForgeExport; + +/// Manages cached mod data from the CurseForge export API. +internal interface ICurseForgeExportCacheRepository : IExportCacheRepository +{ + /********* + ** Methods + *********/ + /// Get the cached data for a mod, if it exists in the export. + /// The CurseForge mod ID. + /// The fetched metadata. + bool TryGetMod(uint id, [NotNullWhen(true)] out CurseForgeModExport? mod); + + /// Set the cached data to use. + /// The export received from the CurseForge Mods API, or null to remove it. + void SetData(CurseForgeFullExport? export); +} diff --git a/src/SMAPI.Web/Framework/Caching/ICacheRepository.cs b/src/SMAPI.Web/Framework/Caching/ICacheRepository.cs index b29152696..a42ed17f3 100644 --- a/src/SMAPI.Web/Framework/Caching/ICacheRepository.cs +++ b/src/SMAPI.Web/Framework/Caching/ICacheRepository.cs @@ -1,13 +1,12 @@ using System; -namespace StardewModdingAPI.Web.Framework.Caching +namespace StardewModdingAPI.Web.Framework.Caching; + +/// Encapsulates logic for accessing data in the cache. +internal interface ICacheRepository { - /// Encapsulates logic for accessing data in the cache. - internal interface ICacheRepository - { - /// Get whether cached data is stale. - /// The date when the data was updated. - /// The age in minutes before data is considered stale. - bool IsStale(DateTimeOffset lastUpdated, int staleMinutes); - } + /// Get whether cached data is stale. + /// The date when the data was updated. + /// The age in minutes before data is considered stale. + bool IsStale(DateTimeOffset lastUpdated, int staleMinutes); } diff --git a/src/SMAPI.Web/Framework/Caching/IExportCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/IExportCacheRepository.cs new file mode 100644 index 000000000..0c41f9d90 --- /dev/null +++ b/src/SMAPI.Web/Framework/Caching/IExportCacheRepository.cs @@ -0,0 +1,33 @@ +using System.Diagnostics.CodeAnalysis; +using StardewModdingAPI.Toolkit.Framework.Clients; + +namespace StardewModdingAPI.Web.Framework.Caching; + +/// Encapsulates logic for accessing data in a cached mod export from a remote API. +internal interface IExportCacheRepository : ICacheRepository +{ + /********* + ** Accessors + *********/ + /// Whether the export data is currently available. + [MemberNotNullWhen(true, nameof(IExportCacheRepository.CacheHeaders))] + public bool IsLoaded { get; } + + /// The date and version of the cached export data, if it's loaded. + public ApiCacheHeaders? CacheHeaders { get; } + + + /********* + ** Methods + *********/ + /// Get whether the cached data is stale. + /// The age in minutes before data is considered stale. + bool IsStale(int staleMinutes); + + /// Clear all data in the cache. + void Clear(); + + /// Set the date and version of the cached export data. + /// The headers to set. + void SetCacheHeaders(ApiCacheHeaders headers); +} diff --git a/src/SMAPI.Web/Framework/Caching/ModDropExport/IModDropExportCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/ModDropExport/IModDropExportCacheRepository.cs new file mode 100644 index 000000000..d89b1d002 --- /dev/null +++ b/src/SMAPI.Web/Framework/Caching/ModDropExport/IModDropExportCacheRepository.cs @@ -0,0 +1,20 @@ +using System.Diagnostics.CodeAnalysis; +using StardewModdingAPI.Toolkit.Framework.Clients.ModDropExport.ResponseModels; + +namespace StardewModdingAPI.Web.Framework.Caching.ModDropExport; + +/// Manages cached mod data from the ModDrop export API. +internal interface IModDropExportCacheRepository : IExportCacheRepository +{ + /********* + ** Methods + *********/ + /// Get the cached data for a mod, if it exists in the export. + /// The ModDrop mod ID. + /// The fetched metadata. + bool TryGetMod(long id, [NotNullWhen(true)] out ModDropModExport? mod); + + /// Set the cached data to use. + /// The export received from the ModDrop Mods API, or null to remove it. + void SetData(ModDropFullExport? export); +} diff --git a/src/SMAPI.Web/Framework/Caching/ModDropExport/ModDropExportCacheMemoryRepository.cs b/src/SMAPI.Web/Framework/Caching/ModDropExport/ModDropExportCacheMemoryRepository.cs new file mode 100644 index 000000000..330fca4c2 --- /dev/null +++ b/src/SMAPI.Web/Framework/Caching/ModDropExport/ModDropExportCacheMemoryRepository.cs @@ -0,0 +1,66 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using StardewModdingAPI.Toolkit.Framework.Clients; +using StardewModdingAPI.Toolkit.Framework.Clients.ModDropExport.ResponseModels; + +namespace StardewModdingAPI.Web.Framework.Caching.ModDropExport; + +/// Manages cached mod data from the ModDrop export API in-memory. +internal class ModDropExportCacheMemoryRepository : BaseExportCacheRepository, IModDropExportCacheRepository +{ + /********* + ** Fields + *********/ + /// The cached mod data from the ModDrop export API. + private ModDropFullExport? Data; + + + /********* + ** Accessors + *********/ + /// + [MemberNotNullWhen(true, nameof(ModDropExportCacheMemoryRepository.Data))] + public override bool IsLoaded => this.Data?.Mods.Count > 0; + + /// + public override ApiCacheHeaders? CacheHeaders => this.Data?.CacheHeaders; + + + /********* + ** Public methods + *********/ + /// + public override void Clear() + { + this.SetData(null); + } + + /// + public override void SetCacheHeaders(ApiCacheHeaders headers) + { + if (!this.IsLoaded) + throw new InvalidOperationException("Can't set the cache headers before any data is loaded."); + + this.Data.CacheHeaders = headers; + } + + /// + public bool TryGetMod(long id, [NotNullWhen(true)] out ModDropModExport? mod) + { + var data = this.Data?.Mods; + + if (data is null || !data.TryGetValue(id, out mod)) + { + mod = null; + return false; + } + + return true; + } + + /// + public void SetData(ModDropFullExport? export) + { + this.Data = export; + } +} diff --git a/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs index fb74e9da8..569f9ab9f 100644 --- a/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs +++ b/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs @@ -2,29 +2,28 @@ using System.Diagnostics.CodeAnalysis; using StardewModdingAPI.Toolkit.Framework.UpdateData; -namespace StardewModdingAPI.Web.Framework.Caching.Mods +namespace StardewModdingAPI.Web.Framework.Caching.Mods; + +/// Manages cached mod data. +internal interface IModCacheRepository : ICacheRepository { - /// Manages cached mod data. - internal interface IModCacheRepository : ICacheRepository - { - /********* - ** Methods - *********/ - /// Get the cached mod data. - /// The mod site to search. - /// The mod's unique ID within the . - /// The fetched mod. - /// Whether to update the mod's 'last requested' date. - bool TryGetMod(ModSiteKey site, string id, [NotNullWhen(true)] out Cached? mod, bool markRequested = true); + /********* + ** Methods + *********/ + /// Get the cached mod data. + /// The mod site to search. + /// The mod's unique ID within the . + /// The fetched mod. + /// Whether to update the mod's 'last requested' date. + bool TryGetMod(ModSiteKey site, string id, [NotNullWhen(true)] out Cached? mod, bool markRequested = true); - /// Save data fetched for a mod. - /// The mod site on which the mod is found. - /// The mod's unique ID within the . - /// The mod data. - void SaveMod(ModSiteKey site, string id, IModPage mod); + /// Save data fetched for a mod. + /// The mod site on which the mod is found. + /// The mod's unique ID within the . + /// The mod data. + void SaveMod(ModSiteKey site, string id, IModPage mod); - /// Delete data for mods which haven't been requested within a given time limit. - /// The minimum age for which to remove mods. - void RemoveStaleMods(TimeSpan age); - } + /// Delete data for mods which haven't been requested within a given time limit. + /// The minimum age for which to remove mods. + void RemoveStaleMods(TimeSpan age); } diff --git a/src/SMAPI.Web/Framework/Caching/Mods/ModCacheMemoryRepository.cs b/src/SMAPI.Web/Framework/Caching/Mods/ModCacheMemoryRepository.cs index 4ba0bd207..dab71473b 100644 --- a/src/SMAPI.Web/Framework/Caching/Mods/ModCacheMemoryRepository.cs +++ b/src/SMAPI.Web/Framework/Caching/Mods/ModCacheMemoryRepository.cs @@ -4,78 +4,77 @@ using System.Linq; using StardewModdingAPI.Toolkit.Framework.UpdateData; -namespace StardewModdingAPI.Web.Framework.Caching.Mods +namespace StardewModdingAPI.Web.Framework.Caching.Mods; + +/// Manages cached mod data in-memory. +internal class ModCacheMemoryRepository : BaseCacheRepository, IModCacheRepository { - /// Manages cached mod data in-memory. - internal class ModCacheMemoryRepository : BaseCacheRepository, IModCacheRepository - { - /********* - ** Fields - *********/ - /// The cached mod data indexed by {site key}:{ID}. - private readonly IDictionary> Mods = new Dictionary>(StringComparer.OrdinalIgnoreCase); + /********* + ** Fields + *********/ + /// The cached mod data indexed by {site key}:{ID}. + private readonly IDictionary> Mods = new Dictionary>(StringComparer.OrdinalIgnoreCase); - /********* - ** Public methods - *********/ - /// Get the cached mod data. - /// The mod site to search. - /// The mod's unique ID within the . - /// The fetched mod. - /// Whether to update the mod's 'last requested' date. - public bool TryGetMod(ModSiteKey site, string id, [NotNullWhen(true)] out Cached? mod, bool markRequested = true) + /********* + ** Public methods + *********/ + /// Get the cached mod data. + /// The mod site to search. + /// The mod's unique ID within the . + /// The fetched mod. + /// Whether to update the mod's 'last requested' date. + public bool TryGetMod(ModSiteKey site, string id, [NotNullWhen(true)] out Cached? mod, bool markRequested = true) + { + // get mod + if (!this.Mods.TryGetValue(this.GetKey(site, id), out var cachedMod)) { - // get mod - if (!this.Mods.TryGetValue(this.GetKey(site, id), out var cachedMod)) - { - mod = null; - return false; - } + mod = null; + return false; + } - // bump 'last requested' - if (markRequested) - cachedMod.LastRequested = DateTimeOffset.UtcNow; + // bump 'last requested' + if (markRequested) + cachedMod.LastRequested = DateTimeOffset.UtcNow; - mod = cachedMod; - return true; - } + mod = cachedMod; + return true; + } - /// Save data fetched for a mod. - /// The mod site on which the mod is found. - /// The mod's unique ID within the . - /// The mod data. - public void SaveMod(ModSiteKey site, string id, IModPage mod) - { - string key = this.GetKey(site, id); - this.Mods[key] = new Cached(mod); - } + /// Save data fetched for a mod. + /// The mod site on which the mod is found. + /// The mod's unique ID within the . + /// The mod data. + public void SaveMod(ModSiteKey site, string id, IModPage mod) + { + string key = this.GetKey(site, id); + this.Mods[key] = new Cached(mod); + } - /// Delete data for mods which haven't been requested within a given time limit. - /// The minimum age for which to remove mods. - public void RemoveStaleMods(TimeSpan age) - { - DateTimeOffset minDate = DateTimeOffset.UtcNow.Subtract(age); + /// Delete data for mods which haven't been requested within a given time limit. + /// The minimum age for which to remove mods. + public void RemoveStaleMods(TimeSpan age) + { + DateTimeOffset minDate = DateTimeOffset.UtcNow.Subtract(age); - string[] staleKeys = this.Mods - .Where(p => p.Value.LastRequested < minDate) - .Select(p => p.Key) - .ToArray(); + string[] staleKeys = this.Mods + .Where(p => p.Value.LastRequested < minDate) + .Select(p => p.Key) + .ToArray(); - foreach (string key in staleKeys) - this.Mods.Remove(key); - } + foreach (string key in staleKeys) + this.Mods.Remove(key); + } - /********* - ** Private methods - *********/ - /// Get a cache key. - /// The mod site. - /// The mod ID. - private string GetKey(ModSiteKey site, string id) - { - return $"{site}:{id.Trim()}".ToLower(); - } + /********* + ** Private methods + *********/ + /// Get a cache key. + /// The mod site. + /// The mod ID. + private string GetKey(ModSiteKey site, string id) + { + return $"{site}:{id.Trim()}".ToLower(); } } diff --git a/src/SMAPI.Web/Framework/Caching/NexusExport/INexusExportCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/NexusExport/INexusExportCacheRepository.cs index 2c813f465..182f078f0 100644 --- a/src/SMAPI.Web/Framework/Caching/NexusExport/INexusExportCacheRepository.cs +++ b/src/SMAPI.Web/Framework/Caching/NexusExport/INexusExportCacheRepository.cs @@ -1,32 +1,20 @@ -using System; using System.Diagnostics.CodeAnalysis; using StardewModdingAPI.Toolkit.Framework.Clients.NexusExport.ResponseModels; -namespace StardewModdingAPI.Web.Framework.Caching.NexusExport -{ - /// Manages cached mod data from the Nexus export API. - internal interface INexusExportCacheRepository : ICacheRepository - { - /********* - ** Methods - *********/ - /// Get whether the export data is currently available. - bool IsLoaded(); - - /// Get when the export data was last fetched, or null if no data is currently available. - DateTimeOffset? GetLastRefreshed(); +namespace StardewModdingAPI.Web.Framework.Caching.NexusExport; - /// Get the cached data for a mod, if it exists in the export. - /// The Nexus mod ID. - /// The fetched metadata. - bool TryGetMod(uint id, [NotNullWhen(true)] out NexusModExport? mod); - - /// Set the cached data to use. - /// The export received from the Nexus Mods API, or null to remove it. - void SetData(NexusFullExport? export); +/// Manages cached mod data from the Nexus export API. +internal interface INexusExportCacheRepository : IExportCacheRepository +{ + /********* + ** Methods + *********/ + /// Get the cached data for a mod, if it exists in the export. + /// The Nexus mod ID. + /// The fetched metadata. + bool TryGetMod(uint id, [NotNullWhen(true)] out NexusModExport? mod); - /// Get whether the cached data is stale. - /// The age in minutes before data is considered stale. - bool IsStale(int staleMinutes); - } + /// Set the cached data to use. + /// The export received from the Nexus Mods API, or null to remove it. + void SetData(NexusFullExport? export); } diff --git a/src/SMAPI.Web/Framework/Caching/NexusExport/NexusExportCacheMemoryRepository.cs b/src/SMAPI.Web/Framework/Caching/NexusExport/NexusExportCacheMemoryRepository.cs index 52f64725a..4265b7323 100644 --- a/src/SMAPI.Web/Framework/Caching/NexusExport/NexusExportCacheMemoryRepository.cs +++ b/src/SMAPI.Web/Framework/Caching/NexusExport/NexusExportCacheMemoryRepository.cs @@ -1,59 +1,66 @@ using System; using System.Diagnostics.CodeAnalysis; +using StardewModdingAPI.Toolkit.Framework.Clients; using StardewModdingAPI.Toolkit.Framework.Clients.NexusExport.ResponseModels; -namespace StardewModdingAPI.Web.Framework.Caching.NexusExport +namespace StardewModdingAPI.Web.Framework.Caching.NexusExport; + +/// Manages cached mod data from the Nexus export API in-memory. +internal class NexusExportCacheMemoryRepository : BaseExportCacheRepository, INexusExportCacheRepository { - /// Manages cached mod data from the Nexus export API in-memory. - internal class NexusExportCacheMemoryRepository : BaseCacheRepository, INexusExportCacheRepository - { - /********* - ** Fields - *********/ - /// The cached mod data from the Nexus export API. - private NexusFullExport? Data; - - - /********* - ** Public methods - *********/ - /// - public bool IsLoaded() - { - return this.Data?.Data.Count > 0; - } + /********* + ** Fields + *********/ + /// The cached mod data from the Nexus export API. + private NexusFullExport? Data; - /// - public DateTimeOffset? GetLastRefreshed() - { - return this.Data?.LastUpdated; - } - /// - public bool TryGetMod(uint id, [NotNullWhen(true)] out NexusModExport? mod) - { - var data = this.Data?.Data; + /********* + ** Accessors + *********/ + /// + [MemberNotNullWhen(true, nameof(NexusExportCacheMemoryRepository.Data))] + public override bool IsLoaded => this.Data?.Data.Count > 0; - if (data is null || !data.TryGetValue(id, out mod)) - { - mod = null; - return false; - } + /// + public override ApiCacheHeaders? CacheHeaders => this.Data?.CacheHeaders; - return true; - } - /// - public void SetData(NexusFullExport? export) - { - this.Data = export; - } + /********* + ** Public methods + *********/ + /// + public override void Clear() + { + this.SetData(null); + } + + /// + public override void SetCacheHeaders(ApiCacheHeaders headers) + { + if (!this.IsLoaded) + throw new InvalidOperationException("Can't set the cache headers before any data is loaded."); - /// - public bool IsStale(int staleMinutes) + this.Data.CacheHeaders = headers; + } + + /// + public bool TryGetMod(uint id, [NotNullWhen(true)] out NexusModExport? mod) + { + var data = this.Data?.Data; + + if (data is null || !data.TryGetValue(id, out mod)) { - DateTimeOffset? lastUpdated = this.Data?.LastUpdated; - return lastUpdated.HasValue && this.IsStale(lastUpdated.Value, staleMinutes); + mod = null; + return false; } + + return true; + } + + /// + public void SetData(NexusFullExport? export) + { + this.Data = export; } } diff --git a/src/SMAPI.Web/Framework/Caching/Wiki/IWikiCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/Wiki/IWikiCacheRepository.cs index b8a0df34b..0370ac18d 100644 --- a/src/SMAPI.Web/Framework/Caching/Wiki/IWikiCacheRepository.cs +++ b/src/SMAPI.Web/Framework/Caching/Wiki/IWikiCacheRepository.cs @@ -3,26 +3,25 @@ using System.Diagnostics.CodeAnalysis; using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; -namespace StardewModdingAPI.Web.Framework.Caching.Wiki +namespace StardewModdingAPI.Web.Framework.Caching.Wiki; + +/// Manages cached wiki data. +internal interface IWikiCacheRepository : ICacheRepository { - /// Manages cached wiki data. - internal interface IWikiCacheRepository : ICacheRepository - { - /********* - ** Methods - *********/ - /// Get the cached wiki metadata. - /// The fetched metadata. - bool TryGetWikiMetadata([NotNullWhen(true)] out Cached? metadata); + /********* + ** Methods + *********/ + /// Get the cached wiki metadata. + /// The fetched metadata. + bool TryGetWikiMetadata([NotNullWhen(true)] out Cached? metadata); - /// Get the cached wiki mods. - /// A filter to apply, if any. - IEnumerable> GetWikiMods(Func? filter = null); + /// Get the cached wiki mods. + /// A filter to apply, if any. + IEnumerable> GetWikiMods(Func? filter = null); - /// Save data fetched from the wiki compatibility list. - /// The current stable Stardew Valley version. - /// The current beta Stardew Valley version. - /// The mod data. - void SaveWikiData(string? stableVersion, string? betaVersion, IEnumerable mods); - } + /// Save data fetched from the wiki compatibility list. + /// The current stable Stardew Valley version. + /// The current beta Stardew Valley version. + /// The mod data. + void SaveWikiData(string? stableVersion, string? betaVersion, IEnumerable mods); } diff --git a/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMemoryRepository.cs b/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMemoryRepository.cs index 8b4338e2b..94819ce89 100644 --- a/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMemoryRepository.cs +++ b/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMemoryRepository.cs @@ -4,51 +4,50 @@ using System.Linq; using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; -namespace StardewModdingAPI.Web.Framework.Caching.Wiki +namespace StardewModdingAPI.Web.Framework.Caching.Wiki; + +/// Manages cached wiki data in-memory. +internal class WikiCacheMemoryRepository : BaseCacheRepository, IWikiCacheRepository { - /// Manages cached wiki data in-memory. - internal class WikiCacheMemoryRepository : BaseCacheRepository, IWikiCacheRepository - { - /********* - ** Fields - *********/ - /// The saved wiki metadata. - private Cached? Metadata; + /********* + ** Fields + *********/ + /// The saved wiki metadata. + private Cached? Metadata; - /// The cached wiki data. - private Cached[] Mods = Array.Empty>(); + /// The cached wiki data. + private Cached[] Mods = Array.Empty>(); - /********* - ** Public methods - *********/ - /// Get the cached wiki metadata. - /// The fetched metadata. - public bool TryGetWikiMetadata([NotNullWhen(true)] out Cached? metadata) - { - metadata = this.Metadata; - return metadata != null; - } + /********* + ** Public methods + *********/ + /// Get the cached wiki metadata. + /// The fetched metadata. + public bool TryGetWikiMetadata([NotNullWhen(true)] out Cached? metadata) + { + metadata = this.Metadata; + return metadata != null; + } - /// Get the cached wiki mods. - /// A filter to apply, if any. - public IEnumerable> GetWikiMods(Func? filter = null) + /// Get the cached wiki mods. + /// A filter to apply, if any. + public IEnumerable> GetWikiMods(Func? filter = null) + { + foreach (var mod in this.Mods) { - foreach (var mod in this.Mods) - { - if (filter == null || filter(mod.Data)) - yield return mod; - } + if (filter == null || filter(mod.Data)) + yield return mod; } + } - /// Save data fetched from the wiki compatibility list. - /// The current stable Stardew Valley version. - /// The current beta Stardew Valley version. - /// The mod data. - public void SaveWikiData(string? stableVersion, string? betaVersion, IEnumerable mods) - { - this.Metadata = new Cached(new WikiMetadata(stableVersion, betaVersion)); - this.Mods = mods.Select(mod => new Cached(mod)).ToArray(); - } + /// Save data fetched from the wiki compatibility list. + /// The current stable Stardew Valley version. + /// The current beta Stardew Valley version. + /// The mod data. + public void SaveWikiData(string? stableVersion, string? betaVersion, IEnumerable mods) + { + this.Metadata = new Cached(new WikiMetadata(stableVersion, betaVersion)); + this.Mods = mods.Select(mod => new Cached(mod)).ToArray(); } } diff --git a/src/SMAPI.Web/Framework/Caching/Wiki/WikiMetadata.cs b/src/SMAPI.Web/Framework/Caching/Wiki/WikiMetadata.cs index f53ea2014..5e264ce80 100644 --- a/src/SMAPI.Web/Framework/Caching/Wiki/WikiMetadata.cs +++ b/src/SMAPI.Web/Framework/Caching/Wiki/WikiMetadata.cs @@ -1,28 +1,27 @@ -namespace StardewModdingAPI.Web.Framework.Caching.Wiki +namespace StardewModdingAPI.Web.Framework.Caching.Wiki; + +/// The model for cached wiki metadata. +internal class WikiMetadata { - /// The model for cached wiki metadata. - internal class WikiMetadata - { - /********* - ** Accessors - *********/ - /// The current stable Stardew Valley version. - public string? StableVersion { get; } + /********* + ** Accessors + *********/ + /// The current stable Stardew Valley version. + public string? StableVersion { get; } - /// The current beta Stardew Valley version. - public string? BetaVersion { get; } + /// The current beta Stardew Valley version. + public string? BetaVersion { get; } - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The current stable Stardew Valley version. - /// The current beta Stardew Valley version. - public WikiMetadata(string? stableVersion, string? betaVersion) - { - this.StableVersion = stableVersion; - this.BetaVersion = betaVersion; - } + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The current stable Stardew Valley version. + /// The current beta Stardew Valley version. + public WikiMetadata(string? stableVersion, string? betaVersion) + { + this.StableVersion = stableVersion; + this.BetaVersion = betaVersion; } } diff --git a/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs b/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs index ce0f11225..ab62b03cc 100644 --- a/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs +++ b/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs @@ -5,94 +5,93 @@ using Pathoschild.Http.Client; using StardewModdingAPI.Toolkit.Framework.UpdateData; -namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish -{ - /// An HTTP client for fetching mod metadata from the Chucklefish mod site. - internal class ChucklefishClient : IChucklefishClient - { - /********* - ** Fields - *********/ - /// The URL for a mod page excluding the base URL, where {0} is the mod ID. - private readonly string ModPageUrlFormat; +namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish; - /// The underlying HTTP client. - private readonly IClient Client; +/// An HTTP client for fetching mod metadata from the Chucklefish mod site. +internal class ChucklefishClient : IChucklefishClient +{ + /********* + ** Fields + *********/ + /// The URL for a mod page excluding the base URL, where {0} is the mod ID. + private readonly string ModPageUrlFormat; + /// The underlying HTTP client. + private readonly IClient Client; - /********* - ** Accessors - *********/ - /// The unique key for the mod site. - public ModSiteKey SiteKey => ModSiteKey.Chucklefish; + /********* + ** Accessors + *********/ + /// The unique key for the mod site. + public ModSiteKey SiteKey => ModSiteKey.Chucklefish; - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The user agent for the API client. - /// The base URL for the Chucklefish mod site. - /// The URL for a mod page excluding the , where {0} is the mod ID. - public ChucklefishClient(string userAgent, string baseUrl, string modPageUrlFormat) - { - this.ModPageUrlFormat = modPageUrlFormat; - this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent); - } - /// Get update check info about a mod. - /// The mod ID. - public async Task GetModData(string id) - { - IModPage page = new GenericModPage(this.SiteKey, id); - - // get mod ID - if (!uint.TryParse(id, out uint parsedId)) - return page.SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid Chucklefish mod ID, must be an integer ID."); + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The user agent for the API client. + /// The base URL for the Chucklefish mod site. + /// The URL for a mod page excluding the , where {0} is the mod ID. + public ChucklefishClient(string userAgent, string baseUrl, string modPageUrlFormat) + { + this.ModPageUrlFormat = modPageUrlFormat; + this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent); + } - // fetch HTML - string? html; - try - { - html = await this.Client - .GetAsync(string.Format(this.ModPageUrlFormat, parsedId)) - .AsString(); - } - catch (ApiException ex) when (ex.Status is HttpStatusCode.NotFound or HttpStatusCode.Forbidden) - { - return page.SetError(RemoteModStatus.DoesNotExist, "Found no Chucklefish mod with this ID."); - } - var doc = new HtmlDocument(); - doc.LoadHtml(html); + /// Get update check info about a mod. + /// The mod ID. + public async Task GetModData(string id) + { + IModPage page = new GenericModPage(this.SiteKey, id); - // extract mod info - string url = this.GetModUrl(parsedId); - string? version = doc.DocumentNode.SelectSingleNode("//h1/span")?.InnerText; - string name = doc.DocumentNode.SelectSingleNode("//h1").ChildNodes[0].InnerText.Trim(); - if (name.StartsWith("[SMAPI]")) - name = name.Substring("[SMAPI]".Length).TrimStart(); + // get mod ID + if (!uint.TryParse(id, out uint parsedId)) + return page.SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid Chucklefish mod ID, must be an integer ID."); - // return info - return page.SetInfo(name: name, version: version, url: url, downloads: Array.Empty()); + // fetch HTML + string? html; + try + { + html = await this.Client + .GetAsync(string.Format(this.ModPageUrlFormat, parsedId)) + .AsString(); } - - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. - public void Dispose() + catch (ApiException ex) when (ex.Status is HttpStatusCode.NotFound or HttpStatusCode.Forbidden) { - this.Client.Dispose(); + return page.SetError(RemoteModStatus.DoesNotExist, "Found no Chucklefish mod with this ID."); } + var doc = new HtmlDocument(); + doc.LoadHtml(html); + // extract mod info + string url = this.GetModUrl(parsedId); + string? version = doc.DocumentNode.SelectSingleNode("//h1/span")?.InnerText; + string name = doc.DocumentNode.SelectSingleNode("//h1").ChildNodes[0].InnerText.Trim(); + if (name.StartsWith("[SMAPI]")) + name = name.Substring("[SMAPI]".Length).TrimStart(); - /********* - ** Private methods - *********/ - /// Get the full mod page URL for a given ID. - /// The mod ID. - private string GetModUrl(uint id) - { - UriBuilder builder = new(this.Client.BaseClient.BaseAddress!); - builder.Path += string.Format(this.ModPageUrlFormat, id); - return builder.Uri.ToString(); - } + // return info + return page.SetInfo(name: name, version: version, url: url, downloads: Array.Empty()); + } + + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + public void Dispose() + { + this.Client.Dispose(); + } + + + /********* + ** Private methods + *********/ + /// Get the full mod page URL for a given ID. + /// The mod ID. + private string GetModUrl(uint id) + { + UriBuilder builder = new(this.Client.BaseClient.BaseAddress!); + builder.Path += string.Format(this.ModPageUrlFormat, id); + return builder.Uri.ToString(); } } diff --git a/src/SMAPI.Web/Framework/Clients/Chucklefish/IChucklefishClient.cs b/src/SMAPI.Web/Framework/Clients/Chucklefish/IChucklefishClient.cs index 836d43f79..9381eccdf 100644 --- a/src/SMAPI.Web/Framework/Clients/Chucklefish/IChucklefishClient.cs +++ b/src/SMAPI.Web/Framework/Clients/Chucklefish/IChucklefishClient.cs @@ -1,7 +1,6 @@ using System; -namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish -{ - /// An HTTP client for fetching mod metadata from the Chucklefish mod site. - internal interface IChucklefishClient : IModSiteClient, IDisposable { } -} +namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish; + +/// An HTTP client for fetching mod metadata from the Chucklefish mod site. +internal interface IChucklefishClient : IModSiteClient, IDisposable { } diff --git a/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs b/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs index 9b4f25802..879982784 100644 --- a/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs +++ b/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs @@ -3,103 +3,213 @@ using System.Text.RegularExpressions; using System.Threading.Tasks; using Pathoschild.Http.Client; +using StardewModdingAPI.Toolkit.Framework.Clients.CurseForgeExport.ResponseModels; using StardewModdingAPI.Toolkit.Framework.UpdateData; +using StardewModdingAPI.Web.Framework.Caching.CurseForgeExport; using StardewModdingAPI.Web.Framework.Clients.CurseForge.ResponseModels; -namespace StardewModdingAPI.Web.Framework.Clients.CurseForge +namespace StardewModdingAPI.Web.Framework.Clients.CurseForge; + +/// An HTTP client for fetching mod metadata from the CurseForge API. +internal class CurseForgeClient : ICurseForgeClient { - /// An HTTP client for fetching mod metadata from the CurseForge API. - internal class CurseForgeClient : ICurseForgeClient + /********* + ** Fields + *********/ + /// The URL for a CurseForge mod page for the user, where {0} is the mod ID. + private readonly string WebModUrl; + + /// The underlying HTTP client. + private readonly IClient Client; + + /// The cached mod data from the CurseForge export API to use if available. + private readonly ICurseForgeExportCacheRepository ExportCache; + + /// A regex pattern which matches a version number in a CurseForge mod file name. + private readonly Regex VersionInNamePattern = new(@"^(?:.+? | *)v?(\d+\.\d+(?:\.\d+)?(?:-.+?)?) *(?:\.(?:zip|rar|7z))?$", RegexOptions.Compiled); + + + /********* + ** Accessors + *********/ + /// The unique key for the mod site. + public ModSiteKey SiteKey => ModSiteKey.CurseForge; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The user agent for the API client. + /// The base URL for the CurseForge API. + /// The API authentication key. + /// The URL for a CurseForge mod page for the user, where {0} is the mod ID. + /// The cached mod data from the CurseForge export API to use if available. + public CurseForgeClient(string userAgent, string apiUrl, string apiKey, string webModUrl, ICurseForgeExportCacheRepository exportCache) { - /********* - ** Fields - *********/ - /// The underlying HTTP client. - private readonly IClient Client; - - /// A regex pattern which matches a version number in a CurseForge mod file name. - private readonly Regex VersionInNamePattern = new(@"^(?:.+? | *)v?(\d+\.\d+(?:\.\d+)?(?:-.+?)?) *(?:\.(?:zip|rar|7z))?$", RegexOptions.Compiled); - - - /********* - ** Accessors - *********/ - /// The unique key for the mod site. - public ModSiteKey SiteKey => ModSiteKey.CurseForge; - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The user agent for the API client. - /// The base URL for the CurseForge API. - /// The API authentication key. - public CurseForgeClient(string userAgent, string apiUrl, string apiKey) - { - this.Client = new FluentClient(apiUrl) - .SetUserAgent(userAgent) - .AddDefault(request => request.WithHeader("x-api-key", apiKey)); - } + this.Client = new FluentClient(apiUrl) + .SetUserAgent(userAgent) + .AddDefault(request => request.WithHeader("x-api-key", apiKey)); + this.WebModUrl = webModUrl; + this.ExportCache = exportCache; + } - /// Get update check info about a mod. - /// The mod ID. - public async Task GetModData(string id) - { - IModPage page = new GenericModPage(this.SiteKey, id); + /// Get update check info about a mod. + /// The mod ID. + public async Task GetModData(string id) + { + // get ID + if (!uint.TryParse(id, out uint parsedId)) + return this.InitModPage(id).SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid CurseForge mod ID, must be an integer ID."); - // get ID - if (!uint.TryParse(id, out uint parsedId)) - return page.SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid CurseForge mod ID, must be an integer ID."); + // To minimize time users spend waiting for the update check result, we fetch the mod + // from these sources in order of priority: + // + // 1. CurseForge export API: + // This is a special endpoint provided by CurseForge specifically for SMAPI's update + // checks. It returns a cached view of every Stardew Valley mod, so we don't need to + // submit separate requests for each mod. + // + // 2. CurseForge API: + // Though mostly superseded by the export API, this is the fallback if the export + // isn't available for some reason. + return + ( + this.ExportCache.IsLoaded && parsedId != 898372/* SMAPI isn't in the export since it's not technically a mod */ + ? this.GetModFromExportData(parsedId) + : await this.GetModFromApiAsync(parsedId) + ) + ?? this.InitModPage(id).SetError(RemoteModStatus.DoesNotExist, "Found no CurseForge mod with this ID."); + } - // get raw data - ModModel? mod; - try - { - ResponseModel response = await this.Client - .GetAsync($"mods/{parsedId}") - .As>(); - mod = response.Data; - } - catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound) - { - return page.SetError(RemoteModStatus.DoesNotExist, "Found no CurseForge mod with this ID."); - } + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + public void Dispose() + { + this.Client.Dispose(); + } + + + /********* + ** Private methods + *********/ + /// Initialize an empty mod page model. + /// The CurseForge mod ID. + private IModPage InitModPage(uint id) + { + return new GenericModPage(this.SiteKey, id.ToString()); + } + + /// Initialize an empty mod page model. + /// The CurseForge mod ID. + private IModPage InitModPage(string id) + { + return new GenericModPage(this.SiteKey, id); + } + + /// Get metadata about a mod by searching the CurseForge export API data. + /// The CurseForge mod ID. + /// Returns the mod info if found, else null. + private IModPage? GetModFromExportData(uint id) + { + // skip if no data available + if (!this.ExportCache.IsLoaded || !this.ExportCache.TryGetMod(id, out CurseForgeModExport? data)) + return null; - // get downloads - List downloads = new List(); - foreach (ModFileModel file in mod.LatestFiles) + // get downloads + var downloads = new List(); + foreach (CurseForgeFileExport file in data.Files) + { + // CurseForge imports files from Nexus into groups, but files uploaded manually still have a group type + // set to null or zero. + switch (file.FileGroupType) { - downloads.Add( - new GenericModDownload(name: file.DisplayName ?? file.FileName, description: null, version: this.GetRawVersion(file)) - ); + case null: + case CurseForgeFileGroupType.None: + case CurseForgeFileGroupType.Main: + case CurseForgeFileGroupType.Optional: + downloads.Add( + new GenericModDownload(file.DisplayName ?? file.FileName ?? file.Id.ToString(), null, this.GetRawVersion(null, file.FileName)) + ); + break; } + } + + // yield info + return this.InitModPage(id) + .SetInfo( + name: data.Name ?? id.ToString(), + version: null, + url: data.ModPageUrl ?? this.GetModUrl(id), + downloads: downloads.ToArray() + ); + } - // return info - return page.SetInfo(name: mod.Name, version: null, url: mod.Links.WebsiteUrl, downloads: downloads); + /// Get metadata about a mod from the CurseForge API. + /// The CurseForge mod ID. + /// Returns the mod info if found, else null. + private async Task GetModFromApiAsync(uint id) + { + // get raw data + ModModel? mod; + try + { + ResponseModel response = await this.Client + .GetAsync($"mods/{id}") + .As>(); + mod = response.Data; + } + catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound) + { + return null; } - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. - public void Dispose() + // get downloads + List downloads = new List(); + foreach (ModFileModel file in mod.LatestFiles) { - this.Client.Dispose(); + downloads.Add( + new GenericModDownload(name: file.DisplayName ?? file.FileName, description: null, version: this.GetRawVersion(file.DisplayName, file.FileName)) + ); } + // return info + return this.InitModPage(id).SetInfo(name: mod.Name, version: null, url: mod.Links.WebsiteUrl, downloads: downloads); + } + + /// Get a raw version string for a mod file, if available. + /// The file's display name. + /// The file's internal name. + private string? GetRawVersion(string? displayName, string? fileName) + { + // get raw version + Match match = this.VersionInNamePattern.Match(displayName ?? ""); + if (!match.Success) + match = this.VersionInNamePattern.Match(fileName ?? ""); + if (!match.Success) + return null; - /********* - ** Private methods - *********/ - /// Get a raw version string for a mod file, if available. - /// The file whose version to get. - private string? GetRawVersion(ModFileModel file) + // fix auto-synced prerelease versions having a double version in the name (like "2.4.0-alpha.20240818-2-4-0-alpha-20240818") + string version = match.Groups[1].Value; + if (version.Length > 2 && version.Length % 2 == 1) { - Match match = this.VersionInNamePattern.Match(file.DisplayName ?? ""); - if (!match.Success) - match = this.VersionInNamePattern.Match(file.FileName); + int splitIndex = version.Length / 2; + if (version[splitIndex] == '-') + { + string left = version[..splitIndex]; + string right = version[(splitIndex + 1)..]; - return match.Success - ? match.Groups[1].Value - : null; + if (left.Replace('.', '-') == right) + version = left; + } } + + return version; + } + + /// Get the full mod page URL for a given ID. + /// The mod ID. + private string GetModUrl(uint id) + { + return string.Format(this.WebModUrl, id); } } diff --git a/src/SMAPI.Web/Framework/Clients/CurseForge/DisabledCurseForgeExportApiClient.cs b/src/SMAPI.Web/Framework/Clients/CurseForge/DisabledCurseForgeExportApiClient.cs new file mode 100644 index 000000000..b3a2bef29 --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/CurseForge/DisabledCurseForgeExportApiClient.cs @@ -0,0 +1,35 @@ +using System; +using System.Threading.Tasks; +using StardewModdingAPI.Toolkit.Framework.Clients; +using StardewModdingAPI.Toolkit.Framework.Clients.CurseForgeExport; +using StardewModdingAPI.Toolkit.Framework.Clients.CurseForgeExport.ResponseModels; + +namespace StardewModdingAPI.Web.Framework.Clients.CurseForge; + +/// A client for the CurseForge export API which does nothing, used for local development. +internal class DisabledCurseForgeExportApiClient : ICurseForgeExportApiClient +{ + /********* + ** Public methods + *********/ + /// + public Task FetchCacheHeadersAsync() + { + return Task.FromResult( + new ApiCacheHeaders(DateTimeOffset.MinValue, "immutable") + ); + } + + /// + public async Task FetchExportAsync() + { + return new CurseForgeFullExport + { + Mods = new(), + CacheHeaders = await this.FetchCacheHeadersAsync() + }; + } + + /// + public void Dispose() { } +} diff --git a/src/SMAPI.Web/Framework/Clients/CurseForge/ICurseForgeClient.cs b/src/SMAPI.Web/Framework/Clients/CurseForge/ICurseForgeClient.cs index 2018c2303..c68f286c2 100644 --- a/src/SMAPI.Web/Framework/Clients/CurseForge/ICurseForgeClient.cs +++ b/src/SMAPI.Web/Framework/Clients/CurseForge/ICurseForgeClient.cs @@ -1,7 +1,6 @@ using System; -namespace StardewModdingAPI.Web.Framework.Clients.CurseForge -{ - /// An HTTP client for fetching mod metadata from the CurseForge API. - internal interface ICurseForgeClient : IModSiteClient, IDisposable { } -} +namespace StardewModdingAPI.Web.Framework.Clients.CurseForge; + +/// An HTTP client for fetching mod metadata from the CurseForge API. +internal interface ICurseForgeClient : IModSiteClient, IDisposable { } diff --git a/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModFileModel.cs b/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModFileModel.cs index e9adcf206..ac91a229d 100644 --- a/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModFileModel.cs +++ b/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModFileModel.cs @@ -1,28 +1,27 @@ -namespace StardewModdingAPI.Web.Framework.Clients.CurseForge.ResponseModels +namespace StardewModdingAPI.Web.Framework.Clients.CurseForge.ResponseModels; + +/// Metadata from the CurseForge API about a mod file. +public class ModFileModel { - /// Metadata from the CurseForge API about a mod file. - public class ModFileModel - { - /********* - ** Accessors - *********/ - /// The file name as downloaded. - public string FileName { get; } + /********* + ** Accessors + *********/ + /// The file name as downloaded. + public string FileName { get; } - /// The file display name. - public string? DisplayName { get; } + /// The file display name. + public string? DisplayName { get; } - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The file name as downloaded. - /// The file display name. - public ModFileModel(string fileName, string? displayName) - { - this.FileName = fileName; - this.DisplayName = displayName; - } + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The file name as downloaded. + /// The file display name. + public ModFileModel(string fileName, string? displayName) + { + this.FileName = fileName; + this.DisplayName = displayName; } } diff --git a/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModLinksModel.cs b/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModLinksModel.cs index 2f9abe4f5..0fadc58f3 100644 --- a/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModLinksModel.cs +++ b/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModLinksModel.cs @@ -1,7 +1,6 @@ -namespace StardewModdingAPI.Web.Framework.Clients.CurseForge.ResponseModels -{ - /// A list of links for a mod. - /// The URL for the CurseForge mod page. - /// The URL for the mod's source code, if any. - public record ModLinksModel(string WebsiteUrl, string? SourceUrl); -} +namespace StardewModdingAPI.Web.Framework.Clients.CurseForge.ResponseModels; + +/// A list of links for a mod. +/// The URL for the CurseForge mod page. +/// The URL for the mod's source code, if any. +public record ModLinksModel(string WebsiteUrl, string? SourceUrl); diff --git a/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModModel.cs b/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModModel.cs index 7018be541..54b2ee03c 100644 --- a/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModModel.cs +++ b/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModModel.cs @@ -1,9 +1,8 @@ -namespace StardewModdingAPI.Web.Framework.Clients.CurseForge.ResponseModels -{ - /// A mod from the CurseForge API. - /// The mod's unique ID on CurseForge. - /// The mod name. - /// The available file downloads. - /// The URLs for this mod. - public record ModModel(int Id, string Name, ModFileModel[] LatestFiles, ModLinksModel Links); -} +namespace StardewModdingAPI.Web.Framework.Clients.CurseForge.ResponseModels; + +/// A mod from the CurseForge API. +/// The mod's unique ID on CurseForge. +/// The mod name. +/// The available file downloads. +/// The URLs for this mod. +public record ModModel(int Id, string Name, ModFileModel[] LatestFiles, ModLinksModel Links); diff --git a/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ResponseModel.cs b/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ResponseModel.cs index 4d538a938..ed75213f8 100644 --- a/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ResponseModel.cs +++ b/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ResponseModel.cs @@ -1,8 +1,5 @@ -using Newtonsoft.Json; +namespace StardewModdingAPI.Web.Framework.Clients.CurseForge.ResponseModels; -namespace StardewModdingAPI.Web.Framework.Clients.CurseForge.ResponseModels -{ - /// A response from the CurseForge API. - /// The data returned by the API. - public record ResponseModel(TData Data); -} +/// A response from the CurseForge API. +/// The data returned by the API. +public record ResponseModel(TData Data); diff --git a/src/SMAPI.Web/Framework/Clients/GenericModDownload.cs b/src/SMAPI.Web/Framework/Clients/GenericModDownload.cs index 6c9c08efb..26b533e8f 100644 --- a/src/SMAPI.Web/Framework/Clients/GenericModDownload.cs +++ b/src/SMAPI.Web/Framework/Clients/GenericModDownload.cs @@ -1,49 +1,48 @@ using System; -namespace StardewModdingAPI.Web.Framework.Clients +namespace StardewModdingAPI.Web.Framework.Clients; + +/// Generic metadata about a file download on a mod page. +internal class GenericModDownload : IModDownload { - /// Generic metadata about a file download on a mod page. - internal class GenericModDownload : IModDownload + /********* + ** Accessors + *********/ + /// The download's display name. + public string Name { get; } + + /// The download's description. + public string? Description { get; } + + /// The download's file version. + public string? Version { get; } + + /// The mod URL page from which to download this update, if different from the URL of the mod page it was fetched from. + public string? ModPageUrl { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The download's display name. + /// The download's description. + /// The download's file version. + /// The mod URL page from which to download this update, if different from the URL of the mod page it was fetched from. + public GenericModDownload(string name, string? description, string? version, string? modPageUrl = null) + { + this.Name = name; + this.Description = description; + this.Version = version; + this.ModPageUrl = modPageUrl; + } + + /// Get whether the subkey matches this download. + /// The update subkey to check. + public virtual bool MatchesSubkey(string subkey) { - /********* - ** Accessors - *********/ - /// The download's display name. - public string Name { get; } - - /// The download's description. - public string? Description { get; } - - /// The download's file version. - public string? Version { get; } - - /// The mod URL page from which to download this update, if different from the URL of the mod page it was fetched from. - public string? ModPageUrl { get; } - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The download's display name. - /// The download's description. - /// The download's file version. - /// The mod URL page from which to download this update, if different from the URL of the mod page it was fetched from. - public GenericModDownload(string name, string? description, string? version, string? modPageUrl = null) - { - this.Name = name; - this.Description = description; - this.Version = version; - this.ModPageUrl = modPageUrl; - } - - /// Get whether the subkey matches this download. - /// The update subkey to check. - public virtual bool MatchesSubkey(string subkey) - { - return - this.Name.Contains(subkey, StringComparison.OrdinalIgnoreCase) - || this.Description?.Contains(subkey, StringComparison.OrdinalIgnoreCase) == true; - } + return + this.Name.Contains(subkey, StringComparison.OrdinalIgnoreCase) + || this.Description?.Contains(subkey, StringComparison.OrdinalIgnoreCase) == true; } } diff --git a/src/SMAPI.Web/Framework/Clients/GenericModPage.cs b/src/SMAPI.Web/Framework/Clients/GenericModPage.cs index 63ca5a95d..17e8e51b2 100644 --- a/src/SMAPI.Web/Framework/Clients/GenericModPage.cs +++ b/src/SMAPI.Web/Framework/Clients/GenericModPage.cs @@ -4,97 +4,96 @@ using System.Linq; using StardewModdingAPI.Toolkit.Framework.UpdateData; -namespace StardewModdingAPI.Web.Framework.Clients +namespace StardewModdingAPI.Web.Framework.Clients; + +/// Generic metadata about a mod page. +internal class GenericModPage : IModPage { - /// Generic metadata about a mod page. - internal class GenericModPage : IModPage + /********* + ** Accessors + *********/ + /// The mod site containing the mod. + public ModSiteKey Site { get; set; } + + /// The mod's unique ID within the site. + public string Id { get; set; } + + /// The mod name. + public string? Name { get; set; } + + /// The mod's semantic version number. + public string? Version { get; set; } + + /// The mod's web URL. + public string? Url { get; set; } + + /// The mod downloads. + public IModDownload[] Downloads { get; set; } = Array.Empty(); + + /// The mod availability status on the remote site. + public RemoteModStatus Status { get; set; } = RemoteModStatus.InvalidData; + + /// A user-friendly error which indicates why fetching the mod info failed (if applicable). + public string? Error { get; set; } + + /// Whether the mod data is valid. + [MemberNotNullWhen(true, nameof(IModPage.Name), nameof(IModPage.Url))] + public bool IsValid => this.Status == RemoteModStatus.Ok; + + /// Whether this mod page requires update subkeys and does not allow matching downloads without them. + public bool RequireSubkey { get; set; } = false; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The mod site containing the mod. + /// The mod's unique ID within the site. + public GenericModPage(ModSiteKey site, string id) + { + this.Site = site; + this.Id = id; + } + + /// Set the fetched mod info. + /// The mod name. + /// The mod's semantic version number. + /// The mod's web URL. + /// The mod downloads. + public IModPage SetInfo(string name, string? version, string url, IEnumerable downloads) + { + this.Name = name; + this.Version = version; + this.Url = url; + this.Downloads = downloads.ToArray(); + this.Status = RemoteModStatus.Ok; + + return this; + } + + /// Set a mod fetch error. + /// The mod availability status on the remote site. + /// A user-friendly error which indicates why fetching the mod info failed (if applicable). + public IModPage SetError(RemoteModStatus status, string error) + { + this.Status = status; + this.Error = error; + + return this; + } + + /// Get the mod name for an update subkey, if different from the mod page name. + /// The update subkey. + public virtual string? GetName(string? subkey) + { + return this.Name; + } + + /// Get the mod page URL for an update subkey, if different from the mod page it was fetched from. + /// The update subkey. + public virtual string? GetUrl(string? subkey) { - /********* - ** Accessors - *********/ - /// The mod site containing the mod. - public ModSiteKey Site { get; set; } - - /// The mod's unique ID within the site. - public string Id { get; set; } - - /// The mod name. - public string? Name { get; set; } - - /// The mod's semantic version number. - public string? Version { get; set; } - - /// The mod's web URL. - public string? Url { get; set; } - - /// The mod downloads. - public IModDownload[] Downloads { get; set; } = Array.Empty(); - - /// The mod availability status on the remote site. - public RemoteModStatus Status { get; set; } = RemoteModStatus.InvalidData; - - /// A user-friendly error which indicates why fetching the mod info failed (if applicable). - public string? Error { get; set; } - - /// Whether the mod data is valid. - [MemberNotNullWhen(true, nameof(IModPage.Name), nameof(IModPage.Url))] - public bool IsValid => this.Status == RemoteModStatus.Ok; - - /// Whether this mod page requires update subkeys and does not allow matching downloads without them. - public bool RequireSubkey { get; set; } = false; - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The mod site containing the mod. - /// The mod's unique ID within the site. - public GenericModPage(ModSiteKey site, string id) - { - this.Site = site; - this.Id = id; - } - - /// Set the fetched mod info. - /// The mod name. - /// The mod's semantic version number. - /// The mod's web URL. - /// The mod downloads. - public IModPage SetInfo(string name, string? version, string url, IEnumerable downloads) - { - this.Name = name; - this.Version = version; - this.Url = url; - this.Downloads = downloads.ToArray(); - this.Status = RemoteModStatus.Ok; - - return this; - } - - /// Set a mod fetch error. - /// The mod availability status on the remote site. - /// A user-friendly error which indicates why fetching the mod info failed (if applicable). - public IModPage SetError(RemoteModStatus status, string error) - { - this.Status = status; - this.Error = error; - - return this; - } - - /// Get the mod name for an update subkey, if different from the mod page name. - /// The update subkey. - public virtual string? GetName(string? subkey) - { - return this.Name; - } - - /// Get the mod page URL for an update subkey, if different from the mod page it was fetched from. - /// The update subkey. - public virtual string? GetUrl(string? subkey) - { - return this.Url; - } + return this.Url; } } diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/GitAsset.cs b/src/SMAPI.Web/Framework/Clients/GitHub/GitAsset.cs index dbce93687..1fdd04c5f 100644 --- a/src/SMAPI.Web/Framework/Clients/GitHub/GitAsset.cs +++ b/src/SMAPI.Web/Framework/Clients/GitHub/GitAsset.cs @@ -1,38 +1,37 @@ using Newtonsoft.Json; -namespace StardewModdingAPI.Web.Framework.Clients.GitHub +namespace StardewModdingAPI.Web.Framework.Clients.GitHub; + +/// A GitHub download attached to a release. +internal class GitAsset { - /// A GitHub download attached to a release. - internal class GitAsset - { - /********* - ** Accessors - *********/ - /// The file name. - [JsonProperty("name")] - public string FileName { get; } + /********* + ** Accessors + *********/ + /// The file name. + [JsonProperty("name")] + public string FileName { get; } - /// The file content type. - [JsonProperty("content_type")] - public string ContentType { get; } + /// The file content type. + [JsonProperty("content_type")] + public string ContentType { get; } - /// The download URL. - [JsonProperty("browser_download_url")] - public string DownloadUrl { get; } + /// The download URL. + [JsonProperty("browser_download_url")] + public string DownloadUrl { get; } - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The file name. - /// The file content type. - /// The download URL. - public GitAsset(string fileName, string contentType, string downloadUrl) - { - this.FileName = fileName; - this.ContentType = contentType; - this.DownloadUrl = downloadUrl; - } + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The file name. + /// The file content type. + /// The download URL. + public GitAsset(string fileName, string contentType, string downloadUrl) + { + this.FileName = fileName; + this.ContentType = contentType; + this.DownloadUrl = downloadUrl; } } diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs b/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs index 785979a56..8b8234f9d 100644 --- a/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs +++ b/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs @@ -5,153 +5,152 @@ using Pathoschild.Http.Client; using StardewModdingAPI.Toolkit.Framework.UpdateData; -namespace StardewModdingAPI.Web.Framework.Clients.GitHub +namespace StardewModdingAPI.Web.Framework.Clients.GitHub; + +/// An HTTP client for fetching metadata from GitHub. +internal class GitHubClient : IGitHubClient { - /// An HTTP client for fetching metadata from GitHub. - internal class GitHubClient : IGitHubClient + /********* + ** Fields + *********/ + /// The underlying HTTP client. + private readonly IClient Client; + + + /********* + ** Accessors + *********/ + /// The unique key for the mod site. + public ModSiteKey SiteKey => ModSiteKey.GitHub; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The base URL for the GitHub API. + /// The user agent for the API client. + /// The Accept header value expected by the GitHub API. + /// The username with which to authenticate to the GitHub API. + /// The password with which to authenticate to the GitHub API. + public GitHubClient(string baseUrl, string userAgent, string acceptHeader, string? username, string? password) { - /********* - ** Fields - *********/ - /// The underlying HTTP client. - private readonly IClient Client; - - - /********* - ** Accessors - *********/ - /// The unique key for the mod site. - public ModSiteKey SiteKey => ModSiteKey.GitHub; - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The base URL for the GitHub API. - /// The user agent for the API client. - /// The Accept header value expected by the GitHub API. - /// The username with which to authenticate to the GitHub API. - /// The password with which to authenticate to the GitHub API. - public GitHubClient(string baseUrl, string userAgent, string acceptHeader, string? username, string? password) + this.Client = new FluentClient(baseUrl) + .SetUserAgent(userAgent) + .AddDefault(req => req.WithHeader("Accept", acceptHeader)); + if (!string.IsNullOrWhiteSpace(username)) + this.Client = this.Client.SetBasicAuthentication(username, password!); + } + + /// Get basic metadata for a GitHub repository, if available. + /// The repository key (like Pathoschild/SMAPI). + /// Returns the repository info if it exists, else null. + public async Task GetRepositoryAsync(string repo) + { + this.AssertKeyFormat(repo); + try { - this.Client = new FluentClient(baseUrl) - .SetUserAgent(userAgent) - .AddDefault(req => req.WithHeader("Accept", acceptHeader)); - if (!string.IsNullOrWhiteSpace(username)) - this.Client = this.Client.SetBasicAuthentication(username, password!); + return await this.Client + .GetAsync($"repos/{repo}") + .As(); } - - /// Get basic metadata for a GitHub repository, if available. - /// The repository key (like Pathoschild/SMAPI). - /// Returns the repository info if it exists, else null. - public async Task GetRepositoryAsync(string repo) + catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound) { - this.AssertKeyFormat(repo); - try - { - return await this.Client - .GetAsync($"repos/{repo}") - .As(); - } - catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound) - { - return null; - } + return null; } + } - /// Get the latest release for a GitHub repository. - /// The repository key (like Pathoschild/SMAPI). - /// Whether to return a prerelease version if it's latest. - /// Returns the release if found, else null. - public async Task GetLatestReleaseAsync(string repo, bool includePrerelease = false) + /// Get the latest release for a GitHub repository. + /// The repository key (like Pathoschild/SMAPI). + /// Whether to return a prerelease version if it's latest. + /// Returns the release if found, else null. + public async Task GetLatestReleaseAsync(string repo, bool includePrerelease = false) + { + this.AssertKeyFormat(repo); + try { - this.AssertKeyFormat(repo); - try - { - if (includePrerelease) - { - GitRelease[] results = await this.Client - .GetAsync($"repos/{repo}/releases?per_page=2") // allow for draft release (only visible if GitHub repo is owned by same account as the update check credentials) - .AsArray(); - return results.FirstOrDefault(p => !p.IsDraft); - } - - return await this.Client - .GetAsync($"repos/{repo}/releases/latest") - .As(); - } - catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound) + if (includePrerelease) { - return null; + GitRelease[] results = await this.Client + .GetAsync($"repos/{repo}/releases?per_page=2") // allow for draft release (only visible if GitHub repo is owned by same account as the update check credentials) + .AsArray(); + return results.FirstOrDefault(p => !p.IsDraft); } - } - /// Get update check info about a mod. - /// The mod ID. - public async Task GetModData(string id) + return await this.Client + .GetAsync($"repos/{repo}/releases/latest") + .As(); + } + catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound) { - IModPage page = new GenericModPage(this.SiteKey, id); + return null; + } + } + + /// Get update check info about a mod. + /// The mod ID. + public async Task GetModData(string id) + { + IModPage page = new GenericModPage(this.SiteKey, id); - if (!id.Contains("/") || id.IndexOf("/", StringComparison.OrdinalIgnoreCase) != id.LastIndexOf("/", StringComparison.OrdinalIgnoreCase)) - return page.SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid GitHub mod ID, must be a username and project name like 'Pathoschild/SMAPI'."); + if (!id.Contains("/") || id.IndexOf("/", StringComparison.OrdinalIgnoreCase) != id.LastIndexOf("/", StringComparison.OrdinalIgnoreCase)) + return page.SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid GitHub mod ID, must be a username and project name like 'Pathoschild/SMAPI'."); - // fetch repo info - GitRepo? repository = await this.GetRepositoryAsync(id); - if (repository == null) - return page.SetError(RemoteModStatus.DoesNotExist, "Found no GitHub repository for this ID."); - string name = repository.FullName; - string url = $"{repository.WebUrl}/releases"; + // fetch repo info + GitRepo? repository = await this.GetRepositoryAsync(id); + if (repository == null) + return page.SetError(RemoteModStatus.DoesNotExist, "Found no GitHub repository for this ID."); + string name = repository.FullName; + string url = $"{repository.WebUrl}/releases"; - // get releases - GitRelease? latest; - GitRelease? preview; + // get releases + GitRelease? latest; + GitRelease? preview; + { + // get latest release (whether preview or stable) + latest = await this.GetLatestReleaseAsync(id, includePrerelease: true); + if (latest == null) + return page.SetError(RemoteModStatus.DoesNotExist, "Found no GitHub release for this ID."); + + // get stable version if different + preview = null; + if (latest.IsPrerelease) { - // get latest release (whether preview or stable) - latest = await this.GetLatestReleaseAsync(id, includePrerelease: true); - if (latest == null) - return page.SetError(RemoteModStatus.DoesNotExist, "Found no GitHub release for this ID."); - - // get stable version if different - preview = null; - if (latest.IsPrerelease) + GitRelease? release = await this.GetLatestReleaseAsync(id, includePrerelease: false); + if (release != null) { - GitRelease? release = await this.GetLatestReleaseAsync(id, includePrerelease: false); - if (release != null) - { - preview = latest; - latest = release; - } + preview = latest; + latest = release; } } + } - // get downloads - IModDownload[] downloads = new[] { latest, preview } - .Where(release => release is not null) - .Select(release => (IModDownload)new GenericModDownload(release!.Name, release.Body, release.Tag)) - .ToArray(); + // get downloads + IModDownload[] downloads = new[] { latest, preview } + .Where(release => release is not null) + .Select(release => (IModDownload)new GenericModDownload(release!.Name, release.Body, release.Tag)) + .ToArray(); - // return info - return page.SetInfo(name: name, url: url, version: null, downloads: downloads); - } + // return info + return page.SetInfo(name: name, url: url, version: null, downloads: downloads); + } - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. - public void Dispose() - { - this.Client.Dispose(); - } + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + public void Dispose() + { + this.Client.Dispose(); + } - /********* - ** Private methods - *********/ - /// Assert that a repository key is formatted correctly. - /// The repository key (like Pathoschild/SMAPI). - /// The repository key is invalid. - private void AssertKeyFormat(string repo) - { - if (repo == null || !repo.Contains("/") || repo.IndexOf("/", StringComparison.OrdinalIgnoreCase) != repo.LastIndexOf("/", StringComparison.OrdinalIgnoreCase)) - throw new ArgumentException($"The value '{repo}' isn't a valid GitHub repository key, must be a username and project name like 'Pathoschild/SMAPI'.", nameof(repo)); - } + /********* + ** Private methods + *********/ + /// Assert that a repository key is formatted correctly. + /// The repository key (like Pathoschild/SMAPI). + /// The repository key is invalid. + private void AssertKeyFormat(string repo) + { + if (repo == null || !repo.Contains("/") || repo.IndexOf("/", StringComparison.OrdinalIgnoreCase) != repo.LastIndexOf("/", StringComparison.OrdinalIgnoreCase)) + throw new ArgumentException($"The value '{repo}' isn't a valid GitHub repository key, must be a username and project name like 'Pathoschild/SMAPI'.", nameof(repo)); } } diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/GitLicense.cs b/src/SMAPI.Web/Framework/Clients/GitHub/GitLicense.cs index 24d6c3c58..d532d643e 100644 --- a/src/SMAPI.Web/Framework/Clients/GitHub/GitLicense.cs +++ b/src/SMAPI.Web/Framework/Clients/GitHub/GitLicense.cs @@ -1,38 +1,37 @@ using Newtonsoft.Json; -namespace StardewModdingAPI.Web.Framework.Clients.GitHub +namespace StardewModdingAPI.Web.Framework.Clients.GitHub; + +/// The license info for a GitHub project. +internal class GitLicense { - /// The license info for a GitHub project. - internal class GitLicense - { - /********* - ** Accessors - *********/ - /// The license display name. - [JsonProperty("name")] - public string Name { get; } + /********* + ** Accessors + *********/ + /// The license display name. + [JsonProperty("name")] + public string Name { get; } - /// The SPDX ID for the license. - [JsonProperty("spdx_id")] - public string SpdxId { get; } + /// The SPDX ID for the license. + [JsonProperty("spdx_id")] + public string SpdxId { get; } - /// The URL for the license info. - [JsonProperty("url")] - public string Url { get; } + /// The URL for the license info. + [JsonProperty("url")] + public string Url { get; } - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The license display name. - /// The SPDX ID for the license. - /// The URL for the license info. - public GitLicense(string name, string spdxId, string url) - { - this.Name = name; - this.SpdxId = spdxId; - this.Url = url; - } + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The license display name. + /// The SPDX ID for the license. + /// The URL for the license info. + public GitLicense(string name, string spdxId, string url) + { + this.Name = name; + this.SpdxId = spdxId; + this.Url = url; } } diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/GitRelease.cs b/src/SMAPI.Web/Framework/Clients/GitHub/GitRelease.cs index 79500d936..9995cd5ec 100644 --- a/src/SMAPI.Web/Framework/Clients/GitHub/GitRelease.cs +++ b/src/SMAPI.Web/Framework/Clients/GitHub/GitRelease.cs @@ -1,61 +1,60 @@ using System; using Newtonsoft.Json; -namespace StardewModdingAPI.Web.Framework.Clients.GitHub +namespace StardewModdingAPI.Web.Framework.Clients.GitHub; + +/// A GitHub project release. +internal class GitRelease { - /// A GitHub project release. - internal class GitRelease + /********* + ** Accessors + *********/ + /// The display name. + [JsonProperty("name")] + public string Name { get; } + + /// The semantic version string. + [JsonProperty("tag_name")] + public string Tag { get; } + + /// The URL to the release web page. + [JsonProperty("html_url")] + public string WebUrl { get; } + + /// The Markdown description for the release. + public string Body { get; internal set; } + + /// Whether this is a draft version. + [JsonProperty("draft")] + public bool IsDraft { get; } + + /// Whether this is a prerelease version. + [JsonProperty("prerelease")] + public bool IsPrerelease { get; } + + /// The attached files. + public GitAsset[] Assets { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The display name. + /// The semantic version string. + /// The URL to the release web page. + /// The Markdown description for the release. + /// Whether this is a draft version. + /// Whether this is a prerelease version. + /// The attached files. + public GitRelease(string name, string tag, string webUrl, string? body, bool isDraft, bool isPrerelease, GitAsset[]? assets) { - /********* - ** Accessors - *********/ - /// The display name. - [JsonProperty("name")] - public string Name { get; } - - /// The semantic version string. - [JsonProperty("tag_name")] - public string Tag { get; } - - /// The URL to the release web page. - [JsonProperty("html_url")] - public string WebUrl { get; } - - /// The Markdown description for the release. - public string Body { get; internal set; } - - /// Whether this is a draft version. - [JsonProperty("draft")] - public bool IsDraft { get; } - - /// Whether this is a prerelease version. - [JsonProperty("prerelease")] - public bool IsPrerelease { get; } - - /// The attached files. - public GitAsset[] Assets { get; } - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The display name. - /// The semantic version string. - /// The URL to the release web page. - /// The Markdown description for the release. - /// Whether this is a draft version. - /// Whether this is a prerelease version. - /// The attached files. - public GitRelease(string name, string tag, string webUrl, string? body, bool isDraft, bool isPrerelease, GitAsset[]? assets) - { - this.Name = name; - this.Tag = tag; - this.WebUrl = webUrl; - this.Body = body ?? string.Empty; - this.IsDraft = isDraft; - this.IsPrerelease = isPrerelease; - this.Assets = assets ?? Array.Empty(); - } + this.Name = name; + this.Tag = tag; + this.WebUrl = webUrl; + this.Body = body ?? string.Empty; + this.IsDraft = isDraft; + this.IsPrerelease = isPrerelease; + this.Assets = assets ?? Array.Empty(); } } diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/GitRepo.cs b/src/SMAPI.Web/Framework/Clients/GitHub/GitRepo.cs index 879b5e495..7b09c2b9b 100644 --- a/src/SMAPI.Web/Framework/Clients/GitHub/GitRepo.cs +++ b/src/SMAPI.Web/Framework/Clients/GitHub/GitRepo.cs @@ -1,38 +1,37 @@ using Newtonsoft.Json; -namespace StardewModdingAPI.Web.Framework.Clients.GitHub +namespace StardewModdingAPI.Web.Framework.Clients.GitHub; + +/// Basic metadata about a GitHub project. +internal class GitRepo { - /// Basic metadata about a GitHub project. - internal class GitRepo - { - /********* - ** Accessors - *********/ - /// The full repository name, including the owner. - [JsonProperty("full_name")] - public string FullName { get; } + /********* + ** Accessors + *********/ + /// The full repository name, including the owner. + [JsonProperty("full_name")] + public string FullName { get; } - /// The URL to the repository web page, if any. - [JsonProperty("html_url")] - public string? WebUrl { get; } + /// The URL to the repository web page, if any. + [JsonProperty("html_url")] + public string? WebUrl { get; } - /// The code license, if any. - [JsonProperty("license")] - public GitLicense? License { get; } + /// The code license, if any. + [JsonProperty("license")] + public GitLicense? License { get; } - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The full repository name, including the owner. - /// The URL to the repository web page, if any. - /// The code license, if any. - public GitRepo(string fullName, string? webUrl, GitLicense? license) - { - this.FullName = fullName; - this.WebUrl = webUrl; - this.License = license; - } + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The full repository name, including the owner. + /// The URL to the repository web page, if any. + /// The code license, if any. + public GitRepo(string fullName, string? webUrl, GitLicense? license) + { + this.FullName = fullName; + this.WebUrl = webUrl; + this.License = license; } } diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs b/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs index 886e32d36..ebc63cae8 100644 --- a/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs +++ b/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs @@ -1,23 +1,22 @@ using System; using System.Threading.Tasks; -namespace StardewModdingAPI.Web.Framework.Clients.GitHub +namespace StardewModdingAPI.Web.Framework.Clients.GitHub; + +/// An HTTP client for fetching metadata from GitHub. +internal interface IGitHubClient : IModSiteClient, IDisposable { - /// An HTTP client for fetching metadata from GitHub. - internal interface IGitHubClient : IModSiteClient, IDisposable - { - /********* - ** Methods - *********/ - /// Get basic metadata for a GitHub repository, if available. - /// The repository key (like Pathoschild/SMAPI). - /// Returns the repository info if it exists, else null. - Task GetRepositoryAsync(string repo); + /********* + ** Methods + *********/ + /// Get basic metadata for a GitHub repository, if available. + /// The repository key (like Pathoschild/SMAPI). + /// Returns the repository info if it exists, else null. + Task GetRepositoryAsync(string repo); - /// Get the latest release for a GitHub repository. - /// The repository key (like Pathoschild/SMAPI). - /// Whether to return a prerelease version if it's latest. - /// Returns the release if found, else null. - Task GetLatestReleaseAsync(string repo, bool includePrerelease = false); - } + /// Get the latest release for a GitHub repository. + /// The repository key (like Pathoschild/SMAPI). + /// Whether to return a prerelease version if it's latest. + /// Returns the release if found, else null. + Task GetLatestReleaseAsync(string repo, bool includePrerelease = false); } diff --git a/src/SMAPI.Web/Framework/Clients/IModSiteClient.cs b/src/SMAPI.Web/Framework/Clients/IModSiteClient.cs index 3697ffae9..f7a670926 100644 --- a/src/SMAPI.Web/Framework/Clients/IModSiteClient.cs +++ b/src/SMAPI.Web/Framework/Clients/IModSiteClient.cs @@ -1,23 +1,22 @@ using System.Threading.Tasks; using StardewModdingAPI.Toolkit.Framework.UpdateData; -namespace StardewModdingAPI.Web.Framework.Clients +namespace StardewModdingAPI.Web.Framework.Clients; + +/// A client for fetching update check info from a mod site. +internal interface IModSiteClient { - /// A client for fetching update check info from a mod site. - internal interface IModSiteClient - { - /********* - ** Accessors - *********/ - /// The unique key for the mod site. - public ModSiteKey SiteKey { get; } + /********* + ** Accessors + *********/ + /// The unique key for the mod site. + public ModSiteKey SiteKey { get; } - /********* - ** Methods - *********/ - /// Get update check info about a mod. - /// The mod ID. - Task GetModData(string id); - } + /********* + ** Methods + *********/ + /// Get update check info about a mod. + /// The mod ID. + Task GetModData(string id); } diff --git a/src/SMAPI.Web/Framework/Clients/ModDrop/DisabledModDropExportApiClient.cs b/src/SMAPI.Web/Framework/Clients/ModDrop/DisabledModDropExportApiClient.cs new file mode 100644 index 000000000..4fa5fcf7a --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/ModDrop/DisabledModDropExportApiClient.cs @@ -0,0 +1,35 @@ +using System; +using System.Threading.Tasks; +using StardewModdingAPI.Toolkit.Framework.Clients; +using StardewModdingAPI.Toolkit.Framework.Clients.ModDropExport; +using StardewModdingAPI.Toolkit.Framework.Clients.ModDropExport.ResponseModels; + +namespace StardewModdingAPI.Web.Framework.Clients.ModDrop; + +/// A client for the ModDrop export API which does nothing, used for local development. +internal class DisabledModDropExportApiClient : IModDropExportApiClient +{ + /********* + ** Public methods + *********/ + /// + public Task FetchCacheHeadersAsync() + { + return Task.FromResult( + new ApiCacheHeaders(DateTimeOffset.MinValue, "immutable") + ); + } + + /// + public async Task FetchExportAsync() + { + return new ModDropFullExport + { + Mods = new(), + CacheHeaders = await this.FetchCacheHeadersAsync() + }; + } + + /// + public void Dispose() { } +} diff --git a/src/SMAPI.Web/Framework/Clients/ModDrop/IModDropClient.cs b/src/SMAPI.Web/Framework/Clients/ModDrop/IModDropClient.cs index 468b72b1e..95c863c96 100644 --- a/src/SMAPI.Web/Framework/Clients/ModDrop/IModDropClient.cs +++ b/src/SMAPI.Web/Framework/Clients/ModDrop/IModDropClient.cs @@ -1,7 +1,6 @@ using System; -namespace StardewModdingAPI.Web.Framework.Clients.ModDrop -{ - /// An HTTP client for fetching mod metadata from the ModDrop API. - internal interface IModDropClient : IDisposable, IModSiteClient { } -} +namespace StardewModdingAPI.Web.Framework.Clients.ModDrop; + +/// An HTTP client for fetching mod metadata from the ModDrop API. +internal interface IModDropClient : IDisposable, IModSiteClient { } diff --git a/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropClient.cs b/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropClient.cs index 1bb3f1c1e..a1376e249 100644 --- a/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropClient.cs +++ b/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropClient.cs @@ -3,114 +3,201 @@ using System.Threading.Tasks; using Pathoschild.Http.Client; using StardewModdingAPI.Toolkit; +using StardewModdingAPI.Toolkit.Framework.Clients.ModDropExport.ResponseModels; using StardewModdingAPI.Toolkit.Framework.UpdateData; +using StardewModdingAPI.Web.Framework.Caching.ModDropExport; using StardewModdingAPI.Web.Framework.Clients.ModDrop.ResponseModels; -namespace StardewModdingAPI.Web.Framework.Clients.ModDrop +namespace StardewModdingAPI.Web.Framework.Clients.ModDrop; + +/// An HTTP client for fetching mod metadata from the ModDrop API. +internal class ModDropClient : IModDropClient { - /// An HTTP client for fetching mod metadata from the ModDrop API. - internal class ModDropClient : IModDropClient + /********* + ** Fields + *********/ + /// The underlying HTTP client. + private readonly IClient Client; + + /// The cached mod data from the ModDrop export API to use if available. + private readonly IModDropExportCacheRepository ExportCache; + + /// The URL for a ModDrop mod page for the user, where {0} is the mod ID. + private readonly string ModUrlFormat; + + + /********* + ** Accessors + *********/ + /// The unique key for the mod site. + public ModSiteKey SiteKey => ModSiteKey.ModDrop; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The user agent for the API client. + /// The base URL for the ModDrop API. + /// The URL for a ModDrop mod page for the user, where {0} is the mod ID. + /// The cached mod data from the ModDrop export API to use if available. + public ModDropClient(string userAgent, string apiUrl, string modUrlFormat, IModDropExportCacheRepository exportCache) + { + this.Client = new FluentClient(apiUrl).SetUserAgent(userAgent); + this.ModUrlFormat = modUrlFormat; + this.ExportCache = exportCache; + } + + /// Get update check info about a mod. + /// The mod ID. + [SuppressMessage("ReSharper", "ConditionalAccessQualifierIsNonNullableAccordingToAPIContract", Justification = "The nullability is validated in this method.")] + public async Task GetModData(string id) + { + IModPage page = new GenericModPage(this.SiteKey, id); + + if (!long.TryParse(id, out long parsedId)) + return page.SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid ModDrop mod ID, must be an integer ID."); + + // To minimize time users spend waiting for the update check result, we fetch the mod + // from these sources in order of priority: + // + // 1. ModDrop export API: + // This is a special endpoint provided by ModDrop specifically for SMAPI's update + // checks. It returns a cached view of every Stardew Valley mod, so we don't need to + // submit separate requests for each mod. + // + // 2. ModDrop API: + // Though mostly superseded by the export API, this is the fallback if the export + // isn't available for some reason. + return + ( + this.ExportCache.IsLoaded + ? this.GetModFromExportData(parsedId) + : await this.GetModFromApiAsync(parsedId) + ) + ?? this.InitModPage(parsedId).SetError(RemoteModStatus.DoesNotExist, "Found no ModDrop mod with this ID."); + } + + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + public void Dispose() + { + this.Client.Dispose(); + } + + + /********* + ** Private methods + *********/ + /// Initialize an empty mod page model. + /// The ModDrop mod ID. + private IModPage InitModPage(long id) + { + return new GenericModPage(this.SiteKey, id.ToString()); + } + + /// Get metadata about a mod by searching the ModDrop export API data. + /// The ModDrop mod ID. + /// Returns the mod info if found, else null. + private IModPage? GetModFromExportData(long id) { - /********* - ** Fields - *********/ - /// The underlying HTTP client. - private readonly IClient Client; - - /// The URL for a ModDrop mod page for the user, where {0} is the mod ID. - private readonly string ModUrlFormat; - - - /********* - ** Accessors - *********/ - /// The unique key for the mod site. - public ModSiteKey SiteKey => ModSiteKey.ModDrop; - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The user agent for the API client. - /// The base URL for the ModDrop API. - /// The URL for a ModDrop mod page for the user, where {0} is the mod ID. - public ModDropClient(string userAgent, string apiUrl, string modUrlFormat) + // skip if no data available + if (!this.ExportCache.IsLoaded || !this.ExportCache.TryGetMod(id, out ModDropModExport? data)) + return null; + + // get downloads + var downloads = new List(); + foreach (ModDropFileExport file in data.Files) { - this.Client = new FluentClient(apiUrl).SetUserAgent(userAgent); - this.ModUrlFormat = modUrlFormat; + if (file.IsOld || file.IsDeleted || file.IsHidden || !this.TryParseVersionFromFileName(file.Name, file.Version, out ISemanticVersion? version)) + continue; + + downloads.Add( + new GenericModDownload(file.Name ?? file.Id.ToString(), file.Description, version.ToString()) + ); } - /// Get update check info about a mod. - /// The mod ID. - [SuppressMessage("ReSharper", "ConditionalAccessQualifierIsNonNullableAccordingToAPIContract", Justification = "The nullability is validated in this method.")] - public async Task GetModData(string id) + // yield info + return this + .InitModPage(id) + .SetInfo(name: data.Title ?? id.ToString(), version: null, url: data.PageUrl ?? this.GetDefaultModPageUrl(id), downloads: downloads.ToArray()); + } + + /// Get metadata about a mod from the ModDrop API. + /// The ModDrop mod ID. + /// Returns the mod info if found, else null. + private async Task GetModFromApiAsync(long id) + { + // get raw data + ModListModel response = await this.Client + .PostAsync("") + .WithBody(new + { + ModIDs = new[] { id }, + Files = true, + Mods = true + }) + .As(); + + if (!response.Mods.TryGetValue(id, out ModModel? mod) || mod?.Mod is null) + return this.InitModPage(id).SetError(RemoteModStatus.DoesNotExist, "Found no ModDrop page with this ID."); + if (mod.Mod.ErrorCode is not null) + return this.InitModPage(id).SetError(RemoteModStatus.InvalidData, $"ModDrop returned error code {mod.Mod.ErrorCode} for mod ID '{id}'."); + + // get files + var downloads = new List(); + foreach (FileDataModel file in mod.Files) { - IModPage page = new GenericModPage(this.SiteKey, id); + if (file.IsOld || file.IsDeleted || file.IsHidden || !this.TryParseVersionFromFileName(file.Name, file.Version, out ISemanticVersion? version)) + continue; - if (!long.TryParse(id, out long parsedId)) - return page.SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid ModDrop mod ID, must be an integer ID."); + downloads.Add( + new GenericModDownload(file.Name, file.Description, version.ToString()) + ); + } - // get raw data - ModListModel response = await this.Client - .PostAsync("") - .WithBody(new - { - ModIDs = new[] { parsedId }, - Files = true, - Mods = true - }) - .As(); - - if (!response.Mods.TryGetValue(parsedId, out ModModel? mod) || mod?.Mod is null) - return page.SetError(RemoteModStatus.DoesNotExist, "Found no ModDrop page with this ID."); - if (mod.Mod.ErrorCode is not null) - return page.SetError(RemoteModStatus.InvalidData, $"ModDrop returned error code {mod.Mod.ErrorCode} for mod ID '{id}'."); - - // get files - var downloads = new List(); - foreach (FileDataModel file in mod.Files) + // return info + return this + .InitModPage(id) + .SetInfo(name: mod.Mod.Title, version: null, url: this.GetDefaultModPageUrl(id), downloads: downloads); + } + + /// Extract the version number from a ModDrop file's info, if possible. + /// The file name. + /// The file version provided by ModDrop. + /// The parsed version number, if valid. + /// Returns whether a version number was successfully parsed. + private bool TryParseVersionFromFileName(string? fileName, string? fileVersion, [NotNullWhen(true)] out ISemanticVersion? version) + { + // ModDrop drops the version prerelease tag if it's not in their whitelist of allowed suffixes. For + // example, "1.0.0-alpha" is fine but "1.0.0-sdvalpha" will have version field "1.0.0". + // + // If the version is non-prerelease but the file's display name contains a prerelease version, parse it + // out of the name instead. + if (fileName != null && fileName.Contains(fileVersion + "-") && SemanticVersion.TryParse(fileVersion, out ISemanticVersion? parsedVersion) && !parsedVersion.IsPrerelease()) + { + string[] parts = fileName.Split(' '); + + if (parts.Length > 1) // can't safely parse name without spaces (e.g. "mod-1.0.0-release" may not be version 1.0.0-release) { - if (file.IsOld || file.IsDeleted || file.IsHidden) - continue; - - // ModDrop drops the version prerelease tag if it's not in their whitelist of allowed suffixes. For - // example, "1.0.0-alpha" is fine but "1.0.0-sdvalpha" will have version field "1.0.0". - // - // If the version is non-prerelease but the file's display name contains a prerelease version, parse it - // out of the name instead. - string version = file.Version; - if (file.Name.Contains(version + "-") && SemanticVersion.TryParse(version, out ISemanticVersion? parsedVersion) && !parsedVersion.IsPrerelease()) + foreach (string part in parts) { - string[] parts = file.Name.Split(' '); - if (parts.Length == 1) - continue; // can't safely parse name without spaces (e.g. "mod-1.0.0-release" may not be version 1.0.0-release) - - foreach (string part in parts) + if (part.StartsWith(fileVersion + "-") && SemanticVersion.TryParse(part, out parsedVersion)) { - if (part.StartsWith(version + "-") && SemanticVersion.TryParse(part, out parsedVersion)) - { - version = parsedVersion.ToString(); - break; - } + version = parsedVersion; + return true; } } - - downloads.Add( - new GenericModDownload(file.Name, file.Description, version) - ); } - - // return info - string name = mod.Mod.Title; - string url = string.Format(this.ModUrlFormat, id); - return page.SetInfo(name: name, version: null, url: url, downloads: downloads); } - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. - public void Dispose() - { - this.Client.Dispose(); - } + // else the provided version + return SemanticVersion.TryParse(fileVersion, out version); + } + + /// Get the mod page URL for a ModDrop mod. + /// The ModDrop mod ID. + private string GetDefaultModPageUrl(long id) + { + return string.Format(this.ModUrlFormat, id); } } diff --git a/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/FileDataModel.cs b/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/FileDataModel.cs index 319053388..901e2a6ba 100644 --- a/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/FileDataModel.cs +++ b/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/FileDataModel.cs @@ -1,57 +1,56 @@ using Newtonsoft.Json; -namespace StardewModdingAPI.Web.Framework.Clients.ModDrop.ResponseModels +namespace StardewModdingAPI.Web.Framework.Clients.ModDrop.ResponseModels; + +/// Metadata from the ModDrop API about a mod file. +public class FileDataModel { - /// Metadata from the ModDrop API about a mod file. - public class FileDataModel + /********* + ** Accessors + *********/ + /// The file title. + [JsonProperty("title")] + public string Name { get; } + + /// The file description. + [JsonProperty("desc")] + public string Description { get; } + + /// The file version. + public string Version { get; } + + /// Whether the file is deleted. + public bool IsDeleted { get; } + + /// Whether the file is hidden from users. + public bool IsHidden { get; } + + /// Whether this is the default file for the mod. + public bool IsDefault { get; } + + /// Whether this is an archived file. + public bool IsOld { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The file title. + /// The file description. + /// The file version. + /// Whether the file is deleted. + /// Whether the file is hidden from users. + /// Whether this is the default file for the mod. + /// Whether this is an archived file. + public FileDataModel(string name, string description, string version, bool isDeleted, bool isHidden, bool isDefault, bool isOld) { - /********* - ** Accessors - *********/ - /// The file title. - [JsonProperty("title")] - public string Name { get; } - - /// The file description. - [JsonProperty("desc")] - public string Description { get; } - - /// The file version. - public string Version { get; } - - /// Whether the file is deleted. - public bool IsDeleted { get; } - - /// Whether the file is hidden from users. - public bool IsHidden { get; } - - /// Whether this is the default file for the mod. - public bool IsDefault { get; } - - /// Whether this is an archived file. - public bool IsOld { get; } - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The file title. - /// The file description. - /// The file version. - /// Whether the file is deleted. - /// Whether the file is hidden from users. - /// Whether this is the default file for the mod. - /// Whether this is an archived file. - public FileDataModel(string name, string description, string version, bool isDeleted, bool isHidden, bool isDefault, bool isOld) - { - this.Name = name; - this.Description = description; - this.Version = version; - this.IsDeleted = isDeleted; - this.IsHidden = isHidden; - this.IsDefault = isDefault; - this.IsOld = isOld; - } + this.Name = name; + this.Description = description; + this.Version = version; + this.IsDeleted = isDeleted; + this.IsHidden = isHidden; + this.IsDefault = isDefault; + this.IsOld = isOld; } } diff --git a/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/ModDataModel.cs b/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/ModDataModel.cs index 0654b5769..8673e9e53 100644 --- a/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/ModDataModel.cs +++ b/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/ModDataModel.cs @@ -1,33 +1,32 @@ -namespace StardewModdingAPI.Web.Framework.Clients.ModDrop.ResponseModels +namespace StardewModdingAPI.Web.Framework.Clients.ModDrop.ResponseModels; + +/// Metadata about a mod from the ModDrop API. +public class ModDataModel { - /// Metadata about a mod from the ModDrop API. - public class ModDataModel - { - /********* - ** Accessors - *********/ - /// The mod's unique ID on ModDrop. - public int ID { get; set; } + /********* + ** Accessors + *********/ + /// The mod's unique ID on ModDrop. + public int ID { get; set; } - /// The mod name. - public string Title { get; set; } + /// The mod name. + public string Title { get; set; } - /// The error code, if any. - public int? ErrorCode { get; set; } + /// The error code, if any. + public int? ErrorCode { get; set; } - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The mod's unique ID on ModDrop. - /// The mod name. - /// The error code, if any. - public ModDataModel(int id, string title, int? errorCode) - { - this.ID = id; - this.Title = title; - this.ErrorCode = errorCode; - } + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The mod's unique ID on ModDrop. + /// The mod name. + /// The error code, if any. + public ModDataModel(int id, string title, int? errorCode) + { + this.ID = id; + this.Title = title; + this.ErrorCode = errorCode; } } diff --git a/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/ModListModel.cs b/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/ModListModel.cs index cb4be35c3..f65847d79 100644 --- a/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/ModListModel.cs +++ b/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/ModListModel.cs @@ -1,14 +1,13 @@ using System.Collections.Generic; -namespace StardewModdingAPI.Web.Framework.Clients.ModDrop.ResponseModels +namespace StardewModdingAPI.Web.Framework.Clients.ModDrop.ResponseModels; + +/// A list of mods from the ModDrop API. +public class ModListModel { - /// A list of mods from the ModDrop API. - public class ModListModel - { - /********* - ** Accessors - *********/ - /// The mod data. - public IDictionary Mods { get; } = new Dictionary(); - } + /********* + ** Accessors + *********/ + /// The mod data. + public IDictionary Mods { get; } = new Dictionary(); } diff --git a/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/ModModel.cs b/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/ModModel.cs index 60b818d6d..ad51e457a 100644 --- a/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/ModModel.cs +++ b/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/ModModel.cs @@ -1,28 +1,27 @@ -namespace StardewModdingAPI.Web.Framework.Clients.ModDrop.ResponseModels +namespace StardewModdingAPI.Web.Framework.Clients.ModDrop.ResponseModels; + +/// An entry in a mod list from the ModDrop API. +public class ModModel { - /// An entry in a mod list from the ModDrop API. - public class ModModel - { - /********* - ** Accessors - *********/ - /// The available file downloads. - public FileDataModel[] Files { get; } + /********* + ** Accessors + *********/ + /// The available file downloads. + public FileDataModel[] Files { get; } - /// The mod metadata. - public ModDataModel Mod { get; } + /// The mod metadata. + public ModDataModel Mod { get; } - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The available file downloads. - /// The mod metadata. - public ModModel(FileDataModel[] files, ModDataModel mod) - { - this.Files = files; - this.Mod = mod; - } + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The available file downloads. + /// The mod metadata. + public ModModel(FileDataModel[] files, ModDataModel mod) + { + this.Files = files; + this.Mod = mod; } } diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/DisabledNexusClient.cs b/src/SMAPI.Web/Framework/Clients/Nexus/DisabledNexusClient.cs index 6edd5f647..1d90637ea 100644 --- a/src/SMAPI.Web/Framework/Clients/Nexus/DisabledNexusClient.cs +++ b/src/SMAPI.Web/Framework/Clients/Nexus/DisabledNexusClient.cs @@ -1,31 +1,30 @@ using System.Threading.Tasks; using StardewModdingAPI.Toolkit.Framework.UpdateData; -namespace StardewModdingAPI.Web.Framework.Clients.Nexus -{ - /// A client for the Nexus website which does nothing, used for local development. - internal class DisabledNexusClient : INexusClient - { - /********* - ** Accessors - *********/ - /// - public ModSiteKey SiteKey => ModSiteKey.Nexus; +namespace StardewModdingAPI.Web.Framework.Clients.Nexus; +/// A client for the Nexus website which does nothing, used for local development. +internal class DisabledNexusClient : INexusClient +{ + /********* + ** Accessors + *********/ + /// + public ModSiteKey SiteKey => ModSiteKey.Nexus; - /********* - ** Public methods - *********/ - /// Get update check info about a mod. - /// The mod ID. - public Task GetModData(string id) - { - return Task.FromResult( - new GenericModPage(ModSiteKey.Nexus, id).SetError(RemoteModStatus.TemporaryError, "The Nexus client is currently disabled due to the configuration.") - ); - } - /// - public void Dispose() { } + /********* + ** Public methods + *********/ + /// Get update check info about a mod. + /// The mod ID. + public Task GetModData(string id) + { + return Task.FromResult( + new GenericModPage(ModSiteKey.Nexus, id).SetError(RemoteModStatus.TemporaryError, "The Nexus client is currently disabled due to the configuration.") + ); } + + /// + public void Dispose() { } } diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/DisabledNexusExportApiClient.cs b/src/SMAPI.Web/Framework/Clients/Nexus/DisabledNexusExportApiClient.cs index 71f12c0cb..3da90932f 100644 --- a/src/SMAPI.Web/Framework/Clients/Nexus/DisabledNexusExportApiClient.cs +++ b/src/SMAPI.Web/Framework/Clients/Nexus/DisabledNexusExportApiClient.cs @@ -1,29 +1,35 @@ using System; using System.Threading.Tasks; +using StardewModdingAPI.Toolkit.Framework.Clients; using StardewModdingAPI.Toolkit.Framework.Clients.NexusExport; using StardewModdingAPI.Toolkit.Framework.Clients.NexusExport.ResponseModels; -namespace StardewModdingAPI.Web.Framework.Clients.Nexus +namespace StardewModdingAPI.Web.Framework.Clients.Nexus; + +/// A client for the Nexus export API which does nothing, used for local development. +internal class DisabledNexusExportApiClient : INexusExportApiClient { - /// A client for the Nexus website which does nothing, used for local development. - internal class DisabledNexusExportApiClient : INexusExportApiClient + /********* + ** Public methods + *********/ + /// + public Task FetchCacheHeadersAsync() { - /********* - ** Public methods - *********/ - /// - public Task FetchExportAsync() - { - return Task.FromResult( - new NexusFullExport - { - Data = new(), - LastUpdated = DateTimeOffset.UtcNow - } - ); - } + return Task.FromResult( + new ApiCacheHeaders(DateTimeOffset.MinValue, "immutable") + ); + } - /// - public void Dispose() { } + /// + public async Task FetchExportAsync() + { + return new NexusFullExport + { + Data = new(), + CacheHeaders = await this.FetchCacheHeadersAsync() + }; } + + /// + public void Dispose() { } } diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/INexusClient.cs b/src/SMAPI.Web/Framework/Clients/Nexus/INexusClient.cs index a44b8c66c..695476947 100644 --- a/src/SMAPI.Web/Framework/Clients/Nexus/INexusClient.cs +++ b/src/SMAPI.Web/Framework/Clients/Nexus/INexusClient.cs @@ -1,7 +1,6 @@ using System; -namespace StardewModdingAPI.Web.Framework.Clients.Nexus -{ - /// An HTTP client for fetching mod metadata from Nexus Mods. - internal interface INexusClient : IModSiteClient, IDisposable { } -} +namespace StardewModdingAPI.Web.Framework.Clients.Nexus; + +/// An HTTP client for fetching mod metadata from Nexus Mods. +internal interface INexusClient : IModSiteClient, IDisposable { } diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs b/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs index 247b4bc57..864fb5564 100644 --- a/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs +++ b/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs @@ -13,281 +13,280 @@ using StardewModdingAPI.Web.Framework.Clients.Nexus.ResponseModels; using FluentNexusClient = Pathoschild.FluentNexus.NexusClient; -namespace StardewModdingAPI.Web.Framework.Clients.Nexus +namespace StardewModdingAPI.Web.Framework.Clients.Nexus; + +/// An HTTP client for fetching mod metadata from the Nexus website. +internal class NexusClient : INexusClient { - /// An HTTP client for fetching mod metadata from the Nexus website. - internal class NexusClient : INexusClient + /********* + ** Fields + *********/ + /// The URL for a Nexus mod page for the user, excluding the base URL, where {0} is the mod ID. + private readonly string WebModUrlFormat; + + /// The URL for a Nexus mod page to scrape for versions, excluding the base URL, where {0} is the mod ID. + public string WebModScrapeUrlFormat { get; set; } + + /// The underlying HTTP client for the Nexus Mods website. + private readonly IClient WebClient; + + /// The underlying HTTP client for the Nexus API. + private readonly FluentNexusClient ApiClient; + + /// The cached mod data from the Nexus export API to use if available. + private readonly INexusExportCacheRepository ExportCache; + + + /********* + ** Accessors + *********/ + /// The unique key for the mod site. + public ModSiteKey SiteKey => ModSiteKey.Nexus; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The user agent for the Nexus Mods web client. + /// The base URL for the Nexus Mods site. + /// The URL for a Nexus Mods mod page for the user, excluding the , where {0} is the mod ID. + /// The URL for a Nexus mod page to scrape for versions, excluding the base URL, where {0} is the mod ID. + /// The app version to show in API user agents. + /// The Nexus API authentication key. + /// The cached mod data from the Nexus export API to use if available. + public NexusClient(string webUserAgent, string webBaseUrl, string webModUrlFormat, string webModScrapeUrlFormat, string apiAppVersion, string apiKey, INexusExportCacheRepository exportCache) + { + this.WebModUrlFormat = webModUrlFormat; + this.WebModScrapeUrlFormat = webModScrapeUrlFormat; + this.WebClient = new FluentClient(webBaseUrl).SetUserAgent(webUserAgent); + this.ApiClient = new FluentNexusClient(apiKey, "SMAPI", apiAppVersion); + this.ExportCache = exportCache; + } + + /// Get update check info about a mod. + /// The mod ID. + public async Task GetModData(string id) { - /********* - ** Fields - *********/ - /// The URL for a Nexus mod page for the user, excluding the base URL, where {0} is the mod ID. - private readonly string WebModUrlFormat; - - /// The URL for a Nexus mod page to scrape for versions, excluding the base URL, where {0} is the mod ID. - public string WebModScrapeUrlFormat { get; set; } - - /// The underlying HTTP client for the Nexus Mods website. - private readonly IClient WebClient; - - /// The underlying HTTP client for the Nexus API. - private readonly FluentNexusClient ApiClient; - - /// The cached mod data from the Nexus export API to use if available. - private readonly INexusExportCacheRepository NexusExportCache; - - - /********* - ** Accessors - *********/ - /// The unique key for the mod site. - public ModSiteKey SiteKey => ModSiteKey.Nexus; - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The user agent for the Nexus Mods web client. - /// The base URL for the Nexus Mods site. - /// The URL for a Nexus Mods mod page for the user, excluding the , where {0} is the mod ID. - /// The URL for a Nexus mod page to scrape for versions, excluding the base URL, where {0} is the mod ID. - /// The app version to show in API user agents. - /// The Nexus API authentication key. - /// The cached mod data from the Nexus export API to use if available. - public NexusClient(string webUserAgent, string webBaseUrl, string webModUrlFormat, string webModScrapeUrlFormat, string apiAppVersion, string apiKey, INexusExportCacheRepository nexusExportCache) + IModPage page = new GenericModPage(this.SiteKey, id); + + if (!uint.TryParse(id, out uint parsedId)) + return page.SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid Nexus mod ID, must be an integer ID."); + + // Nexus has strict rate limits meant for a user's mod manager, which are too low to + // provide an update-check server for all SMAPI players. To avoid rate limits, we fetch + // the mod from these sources in order of priority: + // + // 1. Nexus export API: + // This is a special endpoint provided by Nexus Mods specifically for SMAPI's update + // checks. It returns a cached view of every Stardew Valley mod, so we don't need to + // submit separate requests for each mod. + // + // 2. Nexus website: + // Though mostly superseded by the export API, this is the fallback if the export + // isn't available for some reason (e.g. because the server only has stale data). + // This has no rate limits and Nexus has special firewall rules in place to let + // SMAPI's web servers do this if needed. However, adult mods are hidden since + // we're not logged in. + // + // 3. Nexus API: + // For adult mods, fallback to the official Nexus API which has strict rate + // limits. + NexusMod? mod; + if (this.ExportCache.IsLoaded) + mod = await this.GetModFromExportDataAsync(parsedId); + else { - this.WebModUrlFormat = webModUrlFormat; - this.WebModScrapeUrlFormat = webModScrapeUrlFormat; - this.WebClient = new FluentClient(webBaseUrl).SetUserAgent(webUserAgent); - this.ApiClient = new FluentNexusClient(apiKey, "SMAPI", apiAppVersion); - this.NexusExportCache = nexusExportCache; + mod = await this.GetModFromWebsiteAsync(parsedId); + if (mod?.Status == NexusModStatus.AdultContentForbidden) + mod = await this.GetModFromApiAsync(parsedId); } - /// Get update check info about a mod. - /// The mod ID. - public async Task GetModData(string id) + // page doesn't exist + if (mod == null || mod.Status is NexusModStatus.Hidden or NexusModStatus.NotPublished) + return page.SetError(RemoteModStatus.DoesNotExist, "Found no Nexus mod with this ID."); + + // return info + page.SetInfo(name: mod.Name ?? parsedId.ToString(), url: mod.Url ?? this.GetModUrl(parsedId), version: mod.Version, downloads: mod.Downloads); + if (mod.Status != NexusModStatus.Ok) + page.SetError(RemoteModStatus.TemporaryError, mod.Error!); + return page; + } + + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + public void Dispose() + { + this.WebClient.Dispose(); + } + + + /********* + ** Private methods + *********/ + /// Get metadata about a mod by searching the Nexus export API data. + /// The Nexus mod ID. + /// Returns the mod info if found, else null. + private Task GetModFromExportDataAsync(uint id) + { + static Task ModResult(NexusMod mod) => Task.FromResult(mod); + static Task StatusResult(NexusModStatus status) => Task.FromResult(new NexusMod(status, status.ToString())); + + // skip if no data available + if (!this.ExportCache.IsLoaded || !this.ExportCache.TryGetMod(id, out NexusModExport? data)) + return Task.FromResult(null); + + // handle hidden mod + if (!data.Published) + return StatusResult(NexusModStatus.NotPublished); + if (!data.AllowView || data.Moderated) + return StatusResult(NexusModStatus.Hidden); + + // get downloads + var downloads = new List(); + foreach ((uint fileId, NexusFileExport file) in data.Files) { - IModPage page = new GenericModPage(this.SiteKey, id); - - if (!uint.TryParse(id, out uint parsedId)) - return page.SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid Nexus mod ID, must be an integer ID."); - - // Nexus has strict rate limits meant for a user's mod manager, which are too low to - // provide an update-check server for all SMAPI players. To avoid rate limits, we fetch - // the mod from these sources in order of priority: - // - // 1. Nexus export API: - // This is a special endpoint provided by Nexus Mods specifically for SMAPI's update - // checks. It returns a cached view of every Stardew Valley mod, so we don't need to - // submit separate requests for each mod. - // - // 2. Nexus website: - // Though mostly superseded by the export API, this is the fallback if the export - // isn't available for some reason (e.g. because the server only has stale data). - // This has no rate limits and Nexus has special firewall rules in place to let - // SMAPI's web servers do this if needed. However, adult mods are hidden since - // we're not logged in. - // - // 3. Nexus API: - // For adult mods, fallback to the official Nexus API which has strict rate - // limits. - NexusMod? mod; - if (this.NexusExportCache.IsLoaded()) - mod = await this.GetModFromExportDataAsync(parsedId); - else + if ((FileCategory)file.CategoryId is FileCategory.Main or FileCategory.Optional) { - mod = await this.GetModFromWebsiteAsync(parsedId); - if (mod?.Status == NexusModStatus.AdultContentForbidden) - mod = await this.GetModFromApiAsync(parsedId); + downloads.Add( + new GenericModDownload(file.Name ?? fileId.ToString(), file.Description, file.Version) + ); } + } - // page doesn't exist - if (mod == null || mod.Status is NexusModStatus.Hidden or NexusModStatus.NotPublished) - return page.SetError(RemoteModStatus.DoesNotExist, "Found no Nexus mod with this ID."); + // yield info + return ModResult( + new NexusMod( + name: data.Name ?? id.ToString(), + version: data.Version, + url: this.GetModUrl(id), + downloads: downloads.ToArray() + ) + ); + } - // return info - page.SetInfo(name: mod.Name ?? parsedId.ToString(), url: mod.Url ?? this.GetModUrl(parsedId), version: mod.Version, downloads: mod.Downloads); - if (mod.Status != NexusModStatus.Ok) - page.SetError(RemoteModStatus.TemporaryError, mod.Error!); - return page; + /// Get metadata about a mod by scraping the Nexus website. + /// The Nexus mod ID. + /// Returns the mod info if found, else null. + private async Task GetModFromWebsiteAsync(uint id) + { + // fetch HTML + string html; + try + { + html = await this.WebClient + .GetAsync(string.Format(this.WebModScrapeUrlFormat, id)) + .AsString(); } - - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. - public void Dispose() + catch (ApiException ex) when (ex.Status is HttpStatusCode.NotFound or HttpStatusCode.Forbidden) { - this.WebClient.Dispose(); + return null; } + // parse HTML + HtmlDocument doc = new(); + doc.LoadHtml(html); - /********* - ** Private methods - *********/ - /// Get metadata about a mod by searching the Nexus export API data. - /// The Nexus mod ID. - /// Returns the mod info if found, else null. - private Task GetModFromExportDataAsync(uint id) + // handle Nexus error message + HtmlNode? node = doc.DocumentNode.SelectSingleNode("//div[contains(@class, 'site-notice')][contains(@class, 'warning')]"); + if (node != null) { - static Task ModResult(NexusMod mod) => Task.FromResult(mod); - static Task StatusResult(NexusModStatus status) => Task.FromResult(new NexusMod(status, status.ToString())); - - // skip if no data available - if (!this.NexusExportCache.IsLoaded() || !this.NexusExportCache.TryGetMod(id, out NexusModExport? data)) - return Task.FromResult(null); - - // handle hidden mod - if (!data.Published) - return StatusResult(NexusModStatus.NotPublished); - if (!data.AllowView || data.Moderated) - return StatusResult(NexusModStatus.Hidden); - - // get downloads - var downloads = new List(); - foreach ((uint fileId, NexusFileExport file) in data.Files) + string[] errorParts = node.InnerText.Trim().Split('\n', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + string errorCode = errorParts[0]; + string? errorText = errorParts.Length > 1 ? errorParts[1] : null; + switch (errorCode.ToLower()) { - if ((FileCategory)file.CategoryId is FileCategory.Main or FileCategory.Optional) - { - downloads.Add( - new GenericModDownload(file.Name ?? fileId.ToString(), file.Description, file.Version) + case "not found": + return null; + + default: + return new NexusMod( + status: this.GetWebStatus(errorCode), + error: $"Nexus error: {errorCode} ({errorText})." ); - } } - - // yield info - return ModResult( - new NexusMod( - name: data.Name ?? id.ToString(), - version: data.Version, - url: this.GetModUrl(id), - downloads: downloads.ToArray() - ) - ); } - /// Get metadata about a mod by scraping the Nexus website. - /// The Nexus mod ID. - /// Returns the mod info if found, else null. - private async Task GetModFromWebsiteAsync(uint id) - { - // fetch HTML - string html; - try - { - html = await this.WebClient - .GetAsync(string.Format(this.WebModScrapeUrlFormat, id)) - .AsString(); - } - catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound) - { - return null; - } + // extract mod info + string url = this.GetModUrl(id); + string? name = doc.DocumentNode.SelectSingleNode("//div[@id='pagetitle']//h1")?.InnerText.Trim(); + string? version = doc.DocumentNode.SelectSingleNode("//ul[contains(@class, 'stats')]//li[@class='stat-version']//div[@class='stat']")?.InnerText.Trim(); + SemanticVersion.TryParse(version, out ISemanticVersion? parsedVersion); - // parse HTML - HtmlDocument doc = new(); - doc.LoadHtml(html); + // extract files + var downloads = new List(); + foreach (HtmlNode fileSection in doc.DocumentNode.SelectNodes("//div[contains(@class, 'files-tabs')]")) + { + string sectionName = fileSection.Descendants("h2").First().InnerText; + if (sectionName != "Main files" && sectionName != "Optional files") + continue; - // handle Nexus error message - HtmlNode? node = doc.DocumentNode.SelectSingleNode("//div[contains(@class, 'site-notice')][contains(@class, 'warning')]"); - if (node != null) + foreach (var container in fileSection.Descendants("dt")) { - string[] errorParts = node.InnerText.Trim().Split('\n', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - string errorCode = errorParts[0]; - string? errorText = errorParts.Length > 1 ? errorParts[1] : null; - switch (errorCode.ToLower()) - { - case "not found": - return null; - - default: - return new NexusMod( - status: this.GetWebStatus(errorCode), - error: $"Nexus error: {errorCode} ({errorText})." - ); - } - } - - // extract mod info - string url = this.GetModUrl(id); - string? name = doc.DocumentNode.SelectSingleNode("//div[@id='pagetitle']//h1")?.InnerText.Trim(); - string? version = doc.DocumentNode.SelectSingleNode("//ul[contains(@class, 'stats')]//li[@class='stat-version']//div[@class='stat']")?.InnerText.Trim(); - SemanticVersion.TryParse(version, out ISemanticVersion? parsedVersion); + string fileName = container.GetDataAttribute("name").Value; + string fileVersion = container.GetDataAttribute("version").Value; + string? description = container.SelectSingleNode("following-sibling::*[1][self::dd]//div").InnerText?.Trim(); // get text of next
tag; derived from https://stackoverflow.com/a/25535623/262123 - // extract files - var downloads = new List(); - foreach (HtmlNode fileSection in doc.DocumentNode.SelectNodes("//div[contains(@class, 'files-tabs')]")) - { - string sectionName = fileSection.Descendants("h2").First().InnerText; - if (sectionName != "Main files" && sectionName != "Optional files") - continue; - - foreach (var container in fileSection.Descendants("dt")) - { - string fileName = container.GetDataAttribute("name").Value; - string fileVersion = container.GetDataAttribute("version").Value; - string? description = container.SelectSingleNode("following-sibling::*[1][self::dd]//div").InnerText?.Trim(); // get text of next
tag; derived from https://stackoverflow.com/a/25535623/262123 - - downloads.Add( - new GenericModDownload(fileName, description, fileVersion) - ); - } + downloads.Add( + new GenericModDownload(fileName, description, fileVersion) + ); } - - // yield info - return new NexusMod( - name: name ?? id.ToString(), - version: parsedVersion?.ToString() ?? version, - url: url, - downloads: downloads.ToArray() - ); } - /// Get metadata about a mod from the Nexus API. - /// The Nexus mod ID. - /// Returns the mod info if found, else null. - private async Task GetModFromApiAsync(uint id) - { - // fetch mod - Mod mod = await this.ApiClient.Mods.GetMod("stardewvalley", (int)id); - ModFileList files = await this.ApiClient.ModFiles.GetModFiles("stardewvalley", (int)id, FileCategory.Main, FileCategory.Optional); - - // yield info - return new NexusMod( - name: mod.Name, - version: SemanticVersion.TryParse(mod.Version, out ISemanticVersion? version) ? version.ToString() : mod.Version, - url: this.GetModUrl(id), - downloads: files.Files - .Select(file => (IModDownload)new GenericModDownload(file.Name, file.Description, file.FileVersion)) - .ToArray() - ); - } + // yield info + return new NexusMod( + name: name ?? id.ToString(), + version: parsedVersion?.ToString() ?? version, + url: url, + downloads: downloads.ToArray() + ); + } - /// Get the full mod page URL for a given ID. - /// The mod ID. - private string GetModUrl(uint id) - { - UriBuilder builder = new(this.WebClient.BaseClient.BaseAddress!); - builder.Path += string.Format(this.WebModUrlFormat, id); - return builder.Uri.ToString(); - } + /// Get metadata about a mod from the Nexus API. + /// The Nexus mod ID. + /// Returns the mod info if found, else null. + private async Task GetModFromApiAsync(uint id) + { + // fetch mod + Mod mod = await this.ApiClient.Mods.GetMod("stardewvalley", (int)id); + ModFileList files = await this.ApiClient.ModFiles.GetModFiles("stardewvalley", (int)id, FileCategory.Main, FileCategory.Optional); + + // yield info + return new NexusMod( + name: mod.Name, + version: SemanticVersion.TryParse(mod.Version, out ISemanticVersion? version) ? version.ToString() : mod.Version, + url: this.GetModUrl(id), + downloads: files.Files + .Select(file => (IModDownload)new GenericModDownload(file.Name, file.Description, file.FileVersion)) + .ToArray() + ); + } + + /// Get the full mod page URL for a given ID. + /// The mod ID. + private string GetModUrl(uint id) + { + UriBuilder builder = new(this.WebClient.BaseClient.BaseAddress!); + builder.Path += string.Format(this.WebModUrlFormat, id); + return builder.Uri.ToString(); + } - /// Get the mod status for a web error code. - /// The Nexus error code. - private NexusModStatus GetWebStatus(string errorCode) + /// Get the mod status for a web error code. + /// The Nexus error code. + private NexusModStatus GetWebStatus(string errorCode) + { + switch (errorCode.Trim().ToLower()) { - switch (errorCode.Trim().ToLower()) - { - case "adult content": - return NexusModStatus.AdultContentForbidden; + case "adult content": + return NexusModStatus.AdultContentForbidden; - case "hidden mod": - return NexusModStatus.Hidden; + case "hidden mod": + return NexusModStatus.Hidden; - case "not published": - return NexusModStatus.NotPublished; + case "not published": + return NexusModStatus.NotPublished; - default: - return NexusModStatus.Other; - } + default: + return NexusModStatus.Other; } } } diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/NexusModStatus.cs b/src/SMAPI.Web/Framework/Clients/Nexus/NexusModStatus.cs index 9ef314cd6..aa0819b54 100644 --- a/src/SMAPI.Web/Framework/Clients/Nexus/NexusModStatus.cs +++ b/src/SMAPI.Web/Framework/Clients/Nexus/NexusModStatus.cs @@ -1,21 +1,20 @@ -namespace StardewModdingAPI.Web.Framework.Clients.Nexus +namespace StardewModdingAPI.Web.Framework.Clients.Nexus; + +/// The status of a Nexus mod. +internal enum NexusModStatus { - /// The status of a Nexus mod. - internal enum NexusModStatus - { - /// The mod is published and valid. - Ok, + /// The mod is published and valid. + Ok, - /// The mod is hidden by the author. - Hidden, + /// The mod is hidden by the author. + Hidden, - /// The mod hasn't been published yet. - NotPublished, + /// The mod hasn't been published yet. + NotPublished, - /// The mod contains adult content which is hidden for anonymous web users. - AdultContentForbidden, + /// The mod contains adult content which is hidden for anonymous web users. + AdultContentForbidden, - /// The Nexus API returned an unhandled error. - Other - } + /// The Nexus API returned an unhandled error. + Other } diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/ResponseModels/NexusMod.cs b/src/SMAPI.Web/Framework/Clients/Nexus/ResponseModels/NexusMod.cs index 3155cfda9..d70655bdd 100644 --- a/src/SMAPI.Web/Framework/Clients/Nexus/ResponseModels/NexusMod.cs +++ b/src/SMAPI.Web/Framework/Clients/Nexus/ResponseModels/NexusMod.cs @@ -1,62 +1,61 @@ using System; using Newtonsoft.Json; -namespace StardewModdingAPI.Web.Framework.Clients.Nexus.ResponseModels +namespace StardewModdingAPI.Web.Framework.Clients.Nexus.ResponseModels; + +/// Mod metadata from Nexus Mods. +internal class NexusMod { - /// Mod metadata from Nexus Mods. - internal class NexusMod + /********* + ** Accessors + *********/ + /// The mod name. + public string? Name { get; } + + /// The mod's semantic version number. + public string? Version { get; } + + /// The mod's web URL. + [JsonProperty("mod_page_uri")] + public string? Url { get; } + + /// The mod's publication status. + [JsonIgnore] + public NexusModStatus Status { get; } + + /// The files available to download. + [JsonIgnore] + public IModDownload[] Downloads { get; } + + /// A custom user-friendly error which indicates why fetching the mod info failed (if applicable). + [JsonIgnore] + public string? Error { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The mod name + /// The mod's semantic version number. + /// The mod's web URL. + /// The files available to download. + public NexusMod(string name, string? version, string url, IModDownload[] downloads) + { + this.Name = name; + this.Version = version; + this.Url = url; + this.Status = NexusModStatus.Ok; + this.Downloads = downloads; + } + + /// Construct an instance. + /// The mod's publication status. + /// A custom user-friendly error which indicates why fetching the mod info failed (if applicable). + public NexusMod(NexusModStatus status, string error) { - /********* - ** Accessors - *********/ - /// The mod name. - public string? Name { get; } - - /// The mod's semantic version number. - public string? Version { get; } - - /// The mod's web URL. - [JsonProperty("mod_page_uri")] - public string? Url { get; } - - /// The mod's publication status. - [JsonIgnore] - public NexusModStatus Status { get; } - - /// The files available to download. - [JsonIgnore] - public IModDownload[] Downloads { get; } - - /// A custom user-friendly error which indicates why fetching the mod info failed (if applicable). - [JsonIgnore] - public string? Error { get; } - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The mod name - /// The mod's semantic version number. - /// The mod's web URL. - /// The files available to download. - public NexusMod(string name, string? version, string url, IModDownload[] downloads) - { - this.Name = name; - this.Version = version; - this.Url = url; - this.Status = NexusModStatus.Ok; - this.Downloads = downloads; - } - - /// Construct an instance. - /// The mod's publication status. - /// A custom user-friendly error which indicates why fetching the mod info failed (if applicable). - public NexusMod(NexusModStatus status, string error) - { - this.Status = status; - this.Error = error; - this.Downloads = Array.Empty(); - } + this.Status = status; + this.Error = error; + this.Downloads = Array.Empty(); } } diff --git a/src/SMAPI.Web/Framework/Clients/Pastebin/IPastebinClient.cs b/src/SMAPI.Web/Framework/Clients/Pastebin/IPastebinClient.cs index 431fed7b4..0db795b5b 100644 --- a/src/SMAPI.Web/Framework/Clients/Pastebin/IPastebinClient.cs +++ b/src/SMAPI.Web/Framework/Clients/Pastebin/IPastebinClient.cs @@ -1,13 +1,12 @@ using System; using System.Threading.Tasks; -namespace StardewModdingAPI.Web.Framework.Clients.Pastebin +namespace StardewModdingAPI.Web.Framework.Clients.Pastebin; + +/// An API client for Pastebin. +internal interface IPastebinClient : IDisposable { - /// An API client for Pastebin. - internal interface IPastebinClient : IDisposable - { - /// Fetch a saved paste. - /// The paste ID. - Task GetAsync(string id); - } + /// Fetch a saved paste. + /// The paste ID. + Task GetAsync(string id); } diff --git a/src/SMAPI.Web/Framework/Clients/Pastebin/PasteInfo.cs b/src/SMAPI.Web/Framework/Clients/Pastebin/PasteInfo.cs index 7f40e7132..3ed8256a4 100644 --- a/src/SMAPI.Web/Framework/Clients/Pastebin/PasteInfo.cs +++ b/src/SMAPI.Web/Framework/Clients/Pastebin/PasteInfo.cs @@ -1,35 +1,34 @@ using System.Diagnostics.CodeAnalysis; -namespace StardewModdingAPI.Web.Framework.Clients.Pastebin +namespace StardewModdingAPI.Web.Framework.Clients.Pastebin; + +/// The response for a get-paste request. +internal class PasteInfo { - /// The response for a get-paste request. - internal class PasteInfo - { - /********* - ** Accessors - *********/ - /// Whether the log was successfully fetched. - [MemberNotNullWhen(true, nameof(PasteInfo.Content))] - [MemberNotNullWhen(false, nameof(PasteInfo.Error))] - public bool Success => this.Error == null || this.Content != null; + /********* + ** Accessors + *********/ + /// Whether the log was successfully fetched. + [MemberNotNullWhen(true, nameof(PasteInfo.Content))] + [MemberNotNullWhen(false, nameof(PasteInfo.Error))] + public bool Success => this.Error == null || this.Content != null; - /// The fetched paste content (if is true). - public string? Content { get; internal set; } + /// The fetched paste content (if is true). + public string? Content { get; internal set; } - /// The error message (if is false). - public string? Error { get; } + /// The error message (if is false). + public string? Error { get; } - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The fetched paste content. - /// The error message, if it failed. - public PasteInfo(string? content, string? error) - { - this.Content = content; - this.Error = error; - } + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The fetched paste content. + /// The error message, if it failed. + public PasteInfo(string? content, string? error) + { + this.Content = content; + this.Error = error; } } diff --git a/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs b/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs index 0e00f0713..b0aa9780c 100644 --- a/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs +++ b/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs @@ -3,61 +3,60 @@ using System.Threading.Tasks; using Pathoschild.Http.Client; -namespace StardewModdingAPI.Web.Framework.Clients.Pastebin +namespace StardewModdingAPI.Web.Framework.Clients.Pastebin; + +/// An API client for Pastebin. +internal class PastebinClient : IPastebinClient { - /// An API client for Pastebin. - internal class PastebinClient : IPastebinClient - { - /********* - ** Fields - *********/ - /// The underlying HTTP client. - private readonly IClient Client; + /********* + ** Fields + *********/ + /// The underlying HTTP client. + private readonly IClient Client; - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The base URL for the Pastebin API. - /// The user agent for the API client. - public PastebinClient(string baseUrl, string userAgent) - { - this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent); - } + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The base URL for the Pastebin API. + /// The user agent for the API client. + public PastebinClient(string baseUrl, string userAgent) + { + this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent); + } - /// Fetch a saved paste. - /// The paste ID. - public async Task GetAsync(string id) + /// Fetch a saved paste. + /// The paste ID. + public async Task GetAsync(string id) + { + try { - try - { - // get from API - string? content = await this.Client - .GetAsync($"raw/{id}") - .AsString(); + // get from API + string? content = await this.Client + .GetAsync($"raw/{id}") + .AsString(); - // handle Pastebin errors - if (string.IsNullOrWhiteSpace(content)) - return new PasteInfo(null, "Received an empty response from Pastebin."); - if (content.StartsWith("Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. - public void Dispose() + catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound) + { + return new PasteInfo(null, "There's no log with that ID."); + } + catch (Exception ex) { - this.Client.Dispose(); + return new PasteInfo(null, $"Pastebin error: {ex}"); } } + + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + public void Dispose() + { + this.Client.Dispose(); + } } diff --git a/src/SMAPI.Web/Framework/Clients/UpdateManifest/IUpdateManifestClient.cs b/src/SMAPI.Web/Framework/Clients/UpdateManifest/IUpdateManifestClient.cs index bf1edd3f5..11a6d0c9e 100644 --- a/src/SMAPI.Web/Framework/Clients/UpdateManifest/IUpdateManifestClient.cs +++ b/src/SMAPI.Web/Framework/Clients/UpdateManifest/IUpdateManifestClient.cs @@ -1,7 +1,6 @@ using System; -namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest -{ - /// An API client for fetching update metadata from an arbitrary JSON URL. - internal interface IUpdateManifestClient : IModSiteClient, IDisposable { } -} +namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest; + +/// An API client for fetching update metadata from an arbitrary JSON URL. +internal interface IUpdateManifestClient : IModSiteClient, IDisposable { } diff --git a/src/SMAPI.Web/Framework/Clients/UpdateManifest/ResponseModels/UpdateManifestModModel.cs b/src/SMAPI.Web/Framework/Clients/UpdateManifest/ResponseModels/UpdateManifestModModel.cs index ead5c2299..05c933c99 100644 --- a/src/SMAPI.Web/Framework/Clients/UpdateManifest/ResponseModels/UpdateManifestModModel.cs +++ b/src/SMAPI.Web/Framework/Clients/UpdateManifest/ResponseModels/UpdateManifestModModel.cs @@ -1,35 +1,34 @@ using System; -namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest.ResponseModels +namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest.ResponseModels; + +/// The data model for a mod in an update manifest file. +internal class UpdateManifestModModel { - /// The data model for a mod in an update manifest file. - internal class UpdateManifestModModel - { - /********* - ** Accessors - *********/ - /// The mod's name. - public string? Name { get; } + /********* + ** Accessors + *********/ + /// The mod's name. + public string? Name { get; } - /// The mod page URL from which to download updates. - public string? ModPageUrl { get; } + /// The mod page URL from which to download updates. + public string? ModPageUrl { get; } - /// The available versions for this mod. - public UpdateManifestVersionModel[] Versions { get; } + /// The available versions for this mod. + public UpdateManifestVersionModel[] Versions { get; } - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The mod's name. - /// The mod page URL from which to download updates. - /// The available versions for this mod. - public UpdateManifestModModel(string? name, string? modPageUrl, UpdateManifestVersionModel[]? versions) - { - this.Name = name; - this.ModPageUrl = modPageUrl; - this.Versions = versions ?? Array.Empty(); - } + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The mod's name. + /// The mod page URL from which to download updates. + /// The available versions for this mod. + public UpdateManifestModModel(string? name, string? modPageUrl, UpdateManifestVersionModel[]? versions) + { + this.Name = name; + this.ModPageUrl = modPageUrl; + this.Versions = versions ?? Array.Empty(); } } diff --git a/src/SMAPI.Web/Framework/Clients/UpdateManifest/ResponseModels/UpdateManifestModel.cs b/src/SMAPI.Web/Framework/Clients/UpdateManifest/ResponseModels/UpdateManifestModel.cs index 5ccd31b0d..df75fa343 100644 --- a/src/SMAPI.Web/Framework/Clients/UpdateManifest/ResponseModels/UpdateManifestModel.cs +++ b/src/SMAPI.Web/Framework/Clients/UpdateManifest/ResponseModels/UpdateManifestModel.cs @@ -1,30 +1,29 @@ using System.Collections.Generic; -namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest.ResponseModels +namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest.ResponseModels; + +/// The data model for an update manifest file. +internal class UpdateManifestModel { - /// The data model for an update manifest file. - internal class UpdateManifestModel - { - /********* - ** Accessors - *********/ - /// The manifest format version. This is equivalent to the SMAPI version, and is used to parse older manifests correctly if later versions of SMAPI change the expected format. - public string Format { get; } + /********* + ** Accessors + *********/ + /// The manifest format version. This is equivalent to the SMAPI version, and is used to parse older manifests correctly if later versions of SMAPI change the expected format. + public string Format { get; } - /// The mod info in this update manifest. - public IDictionary Mods { get; } + /// The mod info in this update manifest. + public IDictionary Mods { get; } - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The manifest format version. - /// The mod info in this update manifest. - public UpdateManifestModel(string format, IDictionary? mods) - { - this.Format = format; - this.Mods = mods ?? new Dictionary(); - } + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The manifest format version. + /// The mod info in this update manifest. + public UpdateManifestModel(string format, IDictionary? mods) + { + this.Format = format; + this.Mods = mods ?? new Dictionary(); } } diff --git a/src/SMAPI.Web/Framework/Clients/UpdateManifest/ResponseModels/UpdateManifestVersionModel.cs b/src/SMAPI.Web/Framework/Clients/UpdateManifest/ResponseModels/UpdateManifestVersionModel.cs index 6678f5eba..2fb8e1a3a 100644 --- a/src/SMAPI.Web/Framework/Clients/UpdateManifest/ResponseModels/UpdateManifestVersionModel.cs +++ b/src/SMAPI.Web/Framework/Clients/UpdateManifest/ResponseModels/UpdateManifestVersionModel.cs @@ -1,28 +1,27 @@ -namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest.ResponseModels +namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest.ResponseModels; + +/// Data model for a Version in an update manifest. +internal class UpdateManifestVersionModel { - /// Data model for a Version in an update manifest. - internal class UpdateManifestVersionModel - { - /********* - ** Accessors - *********/ - /// The mod's semantic version. - public string? Version { get; } + /********* + ** Accessors + *********/ + /// The mod's semantic version. + public string? Version { get; } - /// The mod page URL from which to download updates, if different from . - public string? ModPageUrl { get; } + /// The mod page URL from which to download updates, if different from . + public string? ModPageUrl { get; } - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The mod's semantic version. - /// The mod page URL from which to download updates, if different from . - public UpdateManifestVersionModel(string version, string? modPageUrl) - { - this.Version = version; - this.ModPageUrl = modPageUrl; - } + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The mod's semantic version. + /// The mod page URL from which to download updates, if different from . + public UpdateManifestVersionModel(string version, string? modPageUrl) + { + this.Version = version; + this.ModPageUrl = modPageUrl; } } diff --git a/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestClient.cs b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestClient.cs index 270728977..306854091 100644 --- a/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestClient.cs +++ b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestClient.cs @@ -8,99 +8,98 @@ using StardewModdingAPI.Toolkit.Framework.UpdateData; using StardewModdingAPI.Web.Framework.Clients.UpdateManifest.ResponseModels; -namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest +namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest; + +/// An API client for fetching update metadata from an arbitrary JSON URL. +internal class UpdateManifestClient : IUpdateManifestClient { - /// An API client for fetching update metadata from an arbitrary JSON URL. - internal class UpdateManifestClient : IUpdateManifestClient - { - /********* - ** Fields - *********/ - /// The underlying HTTP client. - private readonly IClient Client; + /********* + ** Fields + *********/ + /// The underlying HTTP client. + private readonly IClient Client; - /********* - ** Accessors - *********/ - /// The unique key for the mod site. - public ModSiteKey SiteKey => ModSiteKey.UpdateManifest; + /********* + ** Accessors + *********/ + /// The unique key for the mod site. + public ModSiteKey SiteKey => ModSiteKey.UpdateManifest; - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The user agent for the API client. - public UpdateManifestClient(string userAgent) - { - this.Client = new FluentClient() - .SetUserAgent(userAgent); + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The user agent for the API client. + public UpdateManifestClient(string userAgent) + { + this.Client = new FluentClient() + .SetUserAgent(userAgent); - this.Client.Formatters.JsonFormatter.SupportedMediaTypes.Add(new MediaTypeHeaderValue("text/plain")); - } + this.Client.Formatters.JsonFormatter.SupportedMediaTypes.Add(new MediaTypeHeaderValue("text/plain")); + } + + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + public void Dispose() + { + this.Client.Dispose(); + } - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. - public void Dispose() + /// + [SuppressMessage("ReSharper", "ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract", Justification = "This is the method which ensures the annotations are correct.")] + public async Task GetModData(string id) + { + // get raw update manifest + UpdateManifestModel? manifest; + try { - this.Client.Dispose(); + manifest = await this.Client.GetAsync(id).As(); + if (manifest is null) + return this.GetFormatError(id, "manifest can't be empty"); } - - /// - [SuppressMessage("ReSharper", "ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract", Justification = "This is the method which ensures the annotations are correct.")] - public async Task GetModData(string id) + catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound) { - // get raw update manifest - UpdateManifestModel? manifest; - try - { - manifest = await this.Client.GetAsync(id).As(); - if (manifest is null) - return this.GetFormatError(id, "manifest can't be empty"); - } - catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound) - { - return new GenericModPage(this.SiteKey, id).SetError(RemoteModStatus.DoesNotExist, $"No update manifest found at {id}"); - } - catch (Exception ex) - { - return this.GetFormatError(id, ex.Message); - } + return new GenericModPage(this.SiteKey, id).SetError(RemoteModStatus.DoesNotExist, $"No update manifest found at {id}"); + } + catch (Exception ex) + { + return this.GetFormatError(id, ex.Message); + } - // validate - if (!SemanticVersion.TryParse(manifest.Format, out _)) - return this.GetFormatError(id, $"invalid format version '{manifest.Format}'"); - foreach (UpdateManifestModModel mod in manifest.Mods.Values) + // validate + if (!SemanticVersion.TryParse(manifest.Format, out _)) + return this.GetFormatError(id, $"invalid format version '{manifest.Format}'"); + foreach (UpdateManifestModModel mod in manifest.Mods.Values) + { + if (mod is null) + return this.GetFormatError(id, "a mod record can't be null"); + if (string.IsNullOrWhiteSpace(mod.ModPageUrl)) + return this.GetFormatError(id, $"all mods must have a {nameof(mod.ModPageUrl)} value"); + foreach (UpdateManifestVersionModel? version in mod.Versions) { - if (mod is null) - return this.GetFormatError(id, "a mod record can't be null"); - if (string.IsNullOrWhiteSpace(mod.ModPageUrl)) - return this.GetFormatError(id, $"all mods must have a {nameof(mod.ModPageUrl)} value"); - foreach (UpdateManifestVersionModel? version in mod.Versions) - { - if (version is null) - return this.GetFormatError(id, "a version record can't be null"); - if (string.IsNullOrWhiteSpace(version.Version)) - return this.GetFormatError(id, $"all version records must have a {nameof(version.Version)} field"); - if (!SemanticVersion.TryParse(version.Version, out _)) - return this.GetFormatError(id, $"invalid mod version '{version.Version}'"); - } + if (version is null) + return this.GetFormatError(id, "a version record can't be null"); + if (string.IsNullOrWhiteSpace(version.Version)) + return this.GetFormatError(id, $"all version records must have a {nameof(version.Version)} field"); + if (!SemanticVersion.TryParse(version.Version, out _)) + return this.GetFormatError(id, $"invalid mod version '{version.Version}'"); } - - // build model - return new UpdateManifestModPage(id, manifest); } + // build model + return new UpdateManifestModPage(id, manifest); + } - /********* - ** Private methods - *********/ - /// Get a mod page instance with an error indicating the update manifest is invalid. - /// The full URL to the update manifest. - /// A human-readable reason phrase indicating why it's invalid. - private IModPage GetFormatError(string url, string reason) - { - return new GenericModPage(this.SiteKey, url).SetError(RemoteModStatus.InvalidData, $"The update manifest at {url} is invalid ({reason})"); - } + + /********* + ** Private methods + *********/ + /// Get a mod page instance with an error indicating the update manifest is invalid. + /// The full URL to the update manifest. + /// A human-readable reason phrase indicating why it's invalid. + private IModPage GetFormatError(string url, string reason) + { + return new GenericModPage(this.SiteKey, url).SetError(RemoteModStatus.InvalidData, $"The update manifest at {url} is invalid ({reason})"); } } diff --git a/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModDownload.cs b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModDownload.cs index f8cb760a4..66a15377b 100644 --- a/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModDownload.cs +++ b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModDownload.cs @@ -1,34 +1,33 @@ -namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest +namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest; + +/// Metadata about a mod download in an update manifest file. +internal class UpdateManifestModDownload : GenericModDownload { - /// Metadata about a mod download in an update manifest file. - internal class UpdateManifestModDownload : GenericModDownload - { - /********* - ** Fields - *********/ - /// The update subkey for this mod download. - private readonly string Subkey; + /********* + ** Fields + *********/ + /// The update subkey for this mod download. + private readonly string Subkey; - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The field name for this mod download in the manifest. - /// The mod name for this download. - /// The download's version. - /// The download's URL. - public UpdateManifestModDownload(string fieldName, string name, string? version, string? url) - : base(name, null, version, url) - { - this.Subkey = '@' + fieldName; - } + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The field name for this mod download in the manifest. + /// The mod name for this download. + /// The download's version. + /// The download's URL. + public UpdateManifestModDownload(string fieldName, string name, string? version, string? url) + : base(name, null, version, url) + { + this.Subkey = '@' + fieldName; + } - /// Get whether the subkey matches this download. - /// The update subkey to check. - public override bool MatchesSubkey(string subkey) - { - return subkey == this.Subkey; - } + /// Get whether the subkey matches this download. + /// The update subkey to check. + public override bool MatchesSubkey(string subkey) + { + return subkey == this.Subkey; } } diff --git a/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModPage.cs b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModPage.cs index df7527136..78289321e 100644 --- a/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModPage.cs +++ b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModPage.cs @@ -3,69 +3,68 @@ using StardewModdingAPI.Toolkit.Framework.UpdateData; using StardewModdingAPI.Web.Framework.Clients.UpdateManifest.ResponseModels; -namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest +namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest; + +/// Metadata about an update manifest "page". +internal class UpdateManifestModPage : GenericModPage { - /// Metadata about an update manifest "page". - internal class UpdateManifestModPage : GenericModPage - { - /********* - ** Fields - *********/ - /// The mods from the update manifest. - private readonly IDictionary Mods; + /********* + ** Fields + *********/ + /// The mods from the update manifest. + private readonly IDictionary Mods; - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The URL of the update manifest file. - /// The parsed update manifest. - public UpdateManifestModPage(string url, UpdateManifestModel manifest) - : base(ModSiteKey.UpdateManifest, url) - { - this.RequireSubkey = true; - this.Mods = manifest.Mods; - this.SetInfo(name: url, url: url, version: null, downloads: this.ParseDownloads(manifest.Mods).ToArray()); - } + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The URL of the update manifest file. + /// The parsed update manifest. + public UpdateManifestModPage(string url, UpdateManifestModel manifest) + : base(ModSiteKey.UpdateManifest, url) + { + this.RequireSubkey = true; + this.Mods = manifest.Mods; + this.SetInfo(name: url, url: url, version: null, downloads: this.ParseDownloads(manifest.Mods).ToArray()); + } - /// Return the mod name for the given subkey, if it exists in this update manifest. - /// The subkey. - /// The mod name for the given subkey, or if this manifest does not contain the given subkey. - public override string? GetName(string? subkey) - { - return subkey is not null && this.Mods.TryGetValue(subkey.TrimStart('@'), out UpdateManifestModModel? mod) - ? mod.Name - : null; - } + /// Return the mod name for the given subkey, if it exists in this update manifest. + /// The subkey. + /// The mod name for the given subkey, or if this manifest does not contain the given subkey. + public override string? GetName(string? subkey) + { + return subkey is not null && this.Mods.TryGetValue(subkey.TrimStart('@'), out UpdateManifestModModel? mod) + ? mod.Name + : null; + } - /// Return the mod URL for the given subkey, if it exists in this update manifest. - /// The subkey. - /// The mod URL for the given subkey, or if this manifest does not contain the given subkey. - public override string? GetUrl(string? subkey) - { - return subkey is not null && this.Mods.TryGetValue(subkey.TrimStart('@'), out UpdateManifestModModel? mod) - ? mod.ModPageUrl - : null; - } + /// Return the mod URL for the given subkey, if it exists in this update manifest. + /// The subkey. + /// The mod URL for the given subkey, or if this manifest does not contain the given subkey. + public override string? GetUrl(string? subkey) + { + return subkey is not null && this.Mods.TryGetValue(subkey.TrimStart('@'), out UpdateManifestModModel? mod) + ? mod.ModPageUrl + : null; + } - /********* - ** Private methods - *********/ - /// Convert the raw download info from an update manifest to . - /// The mods from the update manifest. - private IEnumerable ParseDownloads(IDictionary? mods) - { - if (mods is null) - yield break; + /********* + ** Private methods + *********/ + /// Convert the raw download info from an update manifest to . + /// The mods from the update manifest. + private IEnumerable ParseDownloads(IDictionary? mods) + { + if (mods is null) + yield break; - foreach ((string modKey, UpdateManifestModModel mod) in mods) - { - foreach (UpdateManifestVersionModel version in mod.Versions) - yield return new UpdateManifestModDownload(modKey, mod.Name ?? modKey, version.Version, version.ModPageUrl); - } + foreach ((string modKey, UpdateManifestModModel mod) in mods) + { + foreach (UpdateManifestVersionModel version in mod.Versions) + yield return new UpdateManifestModDownload(modKey, mod.Name ?? modKey, version.Version, version.ModPageUrl); } - } + } diff --git a/src/SMAPI.Web/Framework/Compression/GzipHelper.cs b/src/SMAPI.Web/Framework/Compression/GzipHelper.cs index e7a2df131..3912e8ace 100644 --- a/src/SMAPI.Web/Framework/Compression/GzipHelper.cs +++ b/src/SMAPI.Web/Framework/Compression/GzipHelper.cs @@ -4,91 +4,90 @@ using System.IO.Compression; using System.Text; -namespace StardewModdingAPI.Web.Framework.Compression +namespace StardewModdingAPI.Web.Framework.Compression; + +/// Handles GZip compression logic. +internal class GzipHelper : IGzipHelper { - /// Handles GZip compression logic. - internal class GzipHelper : IGzipHelper - { - /********* - ** Fields - *********/ - /// The first bytes in a valid zip file. - /// See . - private const uint GzipLeadBytes = 0x8b1f; + /********* + ** Fields + *********/ + /// The first bytes in a valid zip file. + /// See . + private const uint GzipLeadBytes = 0x8b1f; - /********* - ** Public methods - *********/ - /// Compress a string. - /// The text to compress. - /// Derived from . - public string CompressString(string text) + /********* + ** Public methods + *********/ + /// Compress a string. + /// The text to compress. + /// Derived from . + public string CompressString(string text) + { + // get raw bytes + byte[] buffer = Encoding.UTF8.GetBytes(text); + + // compressed + byte[] compressedData; + using (MemoryStream stream = new()) { - // get raw bytes - byte[] buffer = Encoding.UTF8.GetBytes(text); + using (GZipStream zipStream = new(stream, CompressionLevel.Optimal, leaveOpen: true)) + zipStream.Write(buffer, 0, buffer.Length); - // compressed - byte[] compressedData; - using (MemoryStream stream = new()) - { - using (GZipStream zipStream = new(stream, CompressionLevel.Optimal, leaveOpen: true)) - zipStream.Write(buffer, 0, buffer.Length); + stream.Position = 0; + compressedData = new byte[stream.Length]; + stream.Read(compressedData, 0, compressedData.Length); + } - stream.Position = 0; - compressedData = new byte[stream.Length]; - stream.Read(compressedData, 0, compressedData.Length); - } + // prefix length + byte[] zipBuffer = new byte[compressedData.Length + 4]; + Buffer.BlockCopy(compressedData, 0, zipBuffer, 4, compressedData.Length); + Buffer.BlockCopy(BitConverter.GetBytes(buffer.Length), 0, zipBuffer, 0, 4); - // prefix length - byte[] zipBuffer = new byte[compressedData.Length + 4]; - Buffer.BlockCopy(compressedData, 0, zipBuffer, 4, compressedData.Length); - Buffer.BlockCopy(BitConverter.GetBytes(buffer.Length), 0, zipBuffer, 0, 4); + // return string representation + return Convert.ToBase64String(zipBuffer); + } - // return string representation - return Convert.ToBase64String(zipBuffer); - } + /// Decompress a string. + /// The compressed text. + /// Derived from . + [return: NotNullIfNotNull("rawText")] + public string? DecompressString(string? rawText) + { + if (rawText is null) + return rawText; - /// Decompress a string. - /// The compressed text. - /// Derived from . - [return: NotNullIfNotNull("rawText")] - public string? DecompressString(string? rawText) + // get raw bytes + byte[] zipBuffer; + try { - if (rawText is null) - return rawText; - - // get raw bytes - byte[] zipBuffer; - try - { - zipBuffer = Convert.FromBase64String(rawText); - } - catch - { - return rawText; // not valid base64, wasn't compressed by the log parser - } + zipBuffer = Convert.FromBase64String(rawText); + } + catch + { + return rawText; // not valid base64, wasn't compressed by the log parser + } - // skip if not gzip - if (BitConverter.ToUInt16(zipBuffer, 4) != GzipHelper.GzipLeadBytes) - return rawText; + // skip if not gzip + if (BitConverter.ToUInt16(zipBuffer, 4) != GzipHelper.GzipLeadBytes) + return rawText; - // decompress - using MemoryStream memoryStream = new(); - { - // read length prefix - int dataLength = BitConverter.ToInt32(zipBuffer, 0); - memoryStream.Write(zipBuffer, 4, zipBuffer.Length - 4); + // decompress + using MemoryStream memoryStream = new(); + { + // read length prefix + int dataLength = BitConverter.ToInt32(zipBuffer, 0); + memoryStream.Write(zipBuffer, 4, zipBuffer.Length - 4); - // read data - byte[] buffer = new byte[dataLength]; - memoryStream.Position = 0; - using (GZipStream gZipStream = new(memoryStream, CompressionMode.Decompress)) - gZipStream.Read(buffer, 0, buffer.Length); + // read data + byte[] buffer = new byte[dataLength]; + memoryStream.Position = 0; + using (GZipStream gZipStream = new(memoryStream, CompressionMode.Decompress)) + gZipStream.Read(buffer, 0, buffer.Length); - // return original string - return Encoding.UTF8.GetString(buffer); - } + // return original string + return Encoding.UTF8.GetString(buffer); } } } diff --git a/src/SMAPI.Web/Framework/Compression/IGzipHelper.cs b/src/SMAPI.Web/Framework/Compression/IGzipHelper.cs index ef2d5696a..9ee1c409f 100644 --- a/src/SMAPI.Web/Framework/Compression/IGzipHelper.cs +++ b/src/SMAPI.Web/Framework/Compression/IGzipHelper.cs @@ -1,20 +1,19 @@ using System.Diagnostics.CodeAnalysis; -namespace StardewModdingAPI.Web.Framework.Compression +namespace StardewModdingAPI.Web.Framework.Compression; + +/// Handles GZip compression logic. +internal interface IGzipHelper { - /// Handles GZip compression logic. - internal interface IGzipHelper - { - /********* - ** Methods - *********/ - /// Compress a string. - /// The text to compress. - string CompressString(string text); + /********* + ** Methods + *********/ + /// Compress a string. + /// The text to compress. + string CompressString(string text); - /// Decompress a string. - /// The compressed text. - [return: NotNullIfNotNull("rawText")] - string? DecompressString(string? rawText); - } + /// Decompress a string. + /// The compressed text. + [return: NotNullIfNotNull("rawText")] + string? DecompressString(string? rawText); } diff --git a/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs index e96db5762..58fbf6642 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs @@ -1,103 +1,111 @@ -namespace StardewModdingAPI.Web.Framework.ConfigModels +namespace StardewModdingAPI.Web.Framework.ConfigModels; + +/// The config settings for the API clients. +internal class ApiClientsConfig { - /// The config settings for the API clients. - internal class ApiClientsConfig - { - /********* - ** Accessors - *********/ - /**** - ** Generic - ****/ - /// The user agent for API clients, where {0} is the SMAPI version. - public string UserAgent { get; set; } = null!; + /********* + ** Accessors + *********/ + /**** + ** Generic + ****/ + /// The user agent for API clients, where {0} is the SMAPI version. + public string UserAgent { get; set; } = null!; + + + /**** + ** Azure + ****/ + /// The connection string for the Azure Blob storage account. + public string? AzureBlobConnectionString { get; set; } + + /// The Azure Blob container in which to store temporary uploaded logs. + public string AzureBlobTempContainer { get; set; } = null!; + /// The number of days since the blob's last-modified date when it will be deleted. + public int AzureBlobTempExpiryDays { get; set; } - /**** - ** Azure - ****/ - /// The connection string for the Azure Blob storage account. - public string? AzureBlobConnectionString { get; set; } + /// The number of days before expiry within which blob expiry dates should be auto-renewed on access. + public int AzureBlobTempExpiryAutoRenewalDays { get; set; } - /// The Azure Blob container in which to store temporary uploaded logs. - public string AzureBlobTempContainer { get; set; } = null!; - /// The number of days since the blob's last-modified date when it will be deleted. - public int AzureBlobTempExpiryDays { get; set; } + /**** + ** Chucklefish + ****/ + /// The base URL for the Chucklefish mod site. + public string ChucklefishBaseUrl { get; set; } = null!; - /// The number of days before expiry within which blob expiry dates should be auto-renewed on access. - public int AzureBlobTempExpiryAutoRenewalDays { get; set; } + /// The URL for a mod page on the Chucklefish mod site excluding the , where {0} is the mod ID. + public string ChucklefishModPageUrlFormat { get; set; } = null!; - /**** - ** Chucklefish - ****/ - /// The base URL for the Chucklefish mod site. - public string ChucklefishBaseUrl { get; set; } = null!; + /**** + ** CurseForge + ****/ + /// The base URL for the CurseForge API. + public string CurseForgeBaseUrl { get; set; } = null!; - /// The URL for a mod page on the Chucklefish mod site excluding the , where {0} is the mod ID. - public string ChucklefishModPageUrlFormat { get; set; } = null!; + /// The base URL for the CurseForge export API. + public string CurseForgeExportUrl { get; set; } = null!; + /// The API authentication key for the CurseForge API. + public string CurseForgeApiKey { get; set; } = null!; - /**** - ** CurseForge - ****/ - /// The base URL for the CurseForge API. - public string CurseForgeBaseUrl { get; set; } = null!; + /// The URL for a mod page on the CurseForge mod site, where {0} is the mod ID. + public string CurseForgeWebPageUrl { get; set; } = null!; - /// The API authentication key for the CurseForge API. - public string CurseForgeApiKey { get; set; } = null!; + /**** + ** GitHub + ****/ + /// The base URL for the GitHub API. + public string GitHubBaseUrl { get; set; } = null!; - /**** - ** GitHub - ****/ - /// The base URL for the GitHub API. - public string GitHubBaseUrl { get; set; } = null!; + /// The Accept header value expected by the GitHub API. + public string GitHubAcceptHeader { get; set; } = null!; - /// The Accept header value expected by the GitHub API. - public string GitHubAcceptHeader { get; set; } = null!; + /// The username with which to authenticate to the GitHub API (if any). + public string? GitHubUsername { get; set; } - /// The username with which to authenticate to the GitHub API (if any). - public string? GitHubUsername { get; set; } + /// The password with which to authenticate to the GitHub API (if any). + public string? GitHubPassword { get; set; } - /// The password with which to authenticate to the GitHub API (if any). - public string? GitHubPassword { get; set; } + /**** + ** ModDrop + ****/ + /// The base URL for the ModDrop API. + public string ModDropApiUrl { get; set; } = null!; - /**** - ** ModDrop - ****/ - /// The base URL for the ModDrop API. - public string ModDropApiUrl { get; set; } = null!; + /// The URL for a ModDrop mod page for the user, where {0} is the mod ID. + public string ModDropModPageUrl { get; set; } = null!; - /// The URL for a ModDrop mod page for the user, where {0} is the mod ID. - public string ModDropModPageUrl { get; set; } = null!; + /// The base URL for the ModDrop export API. + public string ModDropExportUrl { get; set; } = null!; - /**** - ** Nexus Mods - ****/ - /// The base URL for the Nexus Mods REST API. - public string NexusBaseUrl { get; set; } = null!; + /**** + ** Nexus Mods + ****/ + /// The base URL for the Nexus Mods REST API. + public string NexusBaseUrl { get; set; } = null!; - /// The base URL for the Nexus Mods export API. - public string NexusExportUrl { get; set; } = null!; + /// The base URL for the Nexus Mods export API. + public string NexusExportUrl { get; set; } = null!; - /// The URL for a Nexus mod page for the user, excluding the , where {0} is the mod ID. - public string NexusModUrlFormat { get; set; } = null!; + /// The URL for a Nexus mod page for the user, excluding the , where {0} is the mod ID. + public string NexusModUrlFormat { get; set; } = null!; - /// The URL for a Nexus mod page to scrape for versions, excluding the , where {0} is the mod ID. - public string NexusModScrapeUrlFormat { get; set; } = null!; + /// The URL for a Nexus mod page to scrape for versions, excluding the , where {0} is the mod ID. + public string NexusModScrapeUrlFormat { get; set; } = null!; - /// The Nexus API authentication key. - public string? NexusApiKey { get; set; } + /// The Nexus API authentication key. + public string? NexusApiKey { get; set; } - /**** - ** Pastebin - ****/ - /// The base URL for the Pastebin API. - public string PastebinBaseUrl { get; set; } = null!; - } + /**** + ** Pastebin + ****/ + /// The base URL for the Pastebin API. + public string PastebinBaseUrl { get; set; } = null!; } diff --git a/src/SMAPI.Web/Framework/ConfigModels/BackgroundServicesConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/BackgroundServicesConfig.cs index de871c9a5..9bf455071 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/BackgroundServicesConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/BackgroundServicesConfig.cs @@ -1,12 +1,11 @@ -namespace StardewModdingAPI.Web.Framework.ConfigModels +namespace StardewModdingAPI.Web.Framework.ConfigModels; + +/// The config settings for background services. +internal class BackgroundServicesConfig { - /// The config settings for background services. - internal class BackgroundServicesConfig - { - /********* - ** Accessors - *********/ - /// Whether to enable background update services. - public bool Enabled { get; set; } - } + /********* + ** Accessors + *********/ + /// Whether to enable background update services. + public bool Enabled { get; set; } } diff --git a/src/SMAPI.Web/Framework/ConfigModels/ModCompatibilityListConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/ModCompatibilityListConfig.cs index 24b540cd6..e1fb8bcd9 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/ModCompatibilityListConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/ModCompatibilityListConfig.cs @@ -1,12 +1,11 @@ -namespace StardewModdingAPI.Web.Framework.ConfigModels +namespace StardewModdingAPI.Web.Framework.ConfigModels; + +/// The config settings for the mod compatibility list. +internal class ModCompatibilityListConfig { - /// The config settings for the mod compatibility list. - internal class ModCompatibilityListConfig - { - /********* - ** Accessors - *********/ - /// The number of minutes before which wiki data should be considered old. - public int StaleMinutes { get; set; } - } + /********* + ** Accessors + *********/ + /// The number of minutes before which wiki data should be considered old. + public int StaleMinutes { get; set; } } diff --git a/src/SMAPI.Web/Framework/ConfigModels/ModOverrideConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/ModOverrideConfig.cs index e46ecf2bc..fce31f1a6 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/ModOverrideConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/ModOverrideConfig.cs @@ -1,15 +1,14 @@ -namespace StardewModdingAPI.Web.Framework.ConfigModels +namespace StardewModdingAPI.Web.Framework.ConfigModels; + +/// Override update-check metadata for a mod. +internal class ModOverrideConfig { - /// Override update-check metadata for a mod. - internal class ModOverrideConfig - { - /// The unique ID from the mod's manifest. - public string ID { get; set; } = null!; + /// The unique ID from the mod's manifest. + public string ID { get; set; } = null!; - /// Whether to allow non-standard versions. - public bool AllowNonStandardVersions { get; set; } + /// Whether to allow non-standard versions. + public bool AllowNonStandardVersions { get; set; } - /// The mod page URL to use regardless of which site has the update, or null to use the site URL. - public string? SetUrl { get; set; } - } + /// The mod page URL to use regardless of which site has the update, or null to use the site URL. + public string? SetUrl { get; set; } } diff --git a/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs index c3b136e8f..8bfa34f2d 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs @@ -1,23 +1,22 @@ using System; -namespace StardewModdingAPI.Web.Framework.ConfigModels +namespace StardewModdingAPI.Web.Framework.ConfigModels; + +/// The config settings for mod update checks. +internal class ModUpdateCheckConfig { - /// The config settings for mod update checks. - internal class ModUpdateCheckConfig - { - /********* - ** Accessors - *********/ - /// The number of minutes successful update checks should be cached before re-fetching them. - public int SuccessCacheMinutes { get; set; } + /********* + ** Accessors + *********/ + /// The number of minutes successful update checks should be cached before re-fetching them. + public int SuccessCacheMinutes { get; set; } - /// The number of minutes failed update checks should be cached before re-fetching them. - public int ErrorCacheMinutes { get; set; } + /// The number of minutes failed update checks should be cached before re-fetching them. + public int ErrorCacheMinutes { get; set; } - /// Update-check metadata to override. - public ModOverrideConfig[] ModOverrides { get; set; } = Array.Empty(); + /// Update-check metadata to override. + public ModOverrideConfig[] ModOverrides { get; set; } = Array.Empty(); - /// The update-check config for SMAPI's own update checks. - public SmapiInfoConfig SmapiInfo { get; set; } = null!; - } + /// The update-check config for SMAPI's own update checks. + public SmapiInfoConfig SmapiInfo { get; set; } = null!; } diff --git a/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs index 62685e474..4e780e84b 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs @@ -1,15 +1,14 @@ -namespace StardewModdingAPI.Web.Framework.ConfigModels +namespace StardewModdingAPI.Web.Framework.ConfigModels; + +/// The site config settings. +public class SiteConfig // must be public to pass into views { - /// The site config settings. - public class SiteConfig // must be public to pass into views - { - /********* - ** Accessors - *********/ - /// A message to show below the download button (e.g. for details on downloading a beta version), in Markdown format. - public string? OtherBlurb { get; set; } + /********* + ** Accessors + *********/ + /// A message to show below the download button (e.g. for details on downloading a beta version), in Markdown format. + public string? OtherBlurb { get; set; } - /// A list of supports to credit on the main page, in Markdown format. - public string? SupporterList { get; set; } - } + /// A list of supports to credit on the main page, in Markdown format. + public string? SupporterList { get; set; } } diff --git a/src/SMAPI.Web/Framework/ConfigModels/SmapiInfoConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/SmapiInfoConfig.cs index a95e00481..ff55232a8 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/SmapiInfoConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/SmapiInfoConfig.cs @@ -1,17 +1,16 @@ using System; -namespace StardewModdingAPI.Web.Framework.ConfigModels +namespace StardewModdingAPI.Web.Framework.ConfigModels; + +/// The update-check config for SMAPI's own update checks. +internal class SmapiInfoConfig { - /// The update-check config for SMAPI's own update checks. - internal class SmapiInfoConfig - { - /// The mod ID used for SMAPI update checks. - public string ID { get; set; } = null!; + /// The mod ID used for SMAPI update checks. + public string ID { get; set; } = null!; - /// The default update key used for SMAPI update checks. - public string DefaultUpdateKey { get; set; } = null!; + /// The default update key used for SMAPI update checks. + public string DefaultUpdateKey { get; set; } = null!; - /// The update keys to add for SMAPI update checks when the player has a beta version installed. - public string[] AddBetaUpdateKeys { get; set; } = Array.Empty(); - } + /// The update keys to add for SMAPI update checks when the player has a beta version installed. + public string[] AddBetaUpdateKeys { get; set; } = Array.Empty(); } diff --git a/src/SMAPI.Web/Framework/Extensions.cs b/src/SMAPI.Web/Framework/Extensions.cs index 62a23155b..0073ab6a6 100644 --- a/src/SMAPI.Web/Framework/Extensions.cs +++ b/src/SMAPI.Web/Framework/Extensions.cs @@ -8,59 +8,68 @@ using Microsoft.AspNetCore.Routing; using Newtonsoft.Json; -namespace StardewModdingAPI.Web.Framework +namespace StardewModdingAPI.Web.Framework; + +/// Provides extensions on ASP.NET Core types. +public static class Extensions { - /// Provides extensions on ASP.NET Core types. - public static class Extensions + /********* + ** Public methods + *********/ + /**** + ** View helpers + ****/ + /// Get a URL for an action method. Unlike , only the specified are added to the URL without merging values from the current HTTP request. + /// The URL helper to extend. + /// The name of the action method. + /// The name of the controller. + /// An object that contains route values. + /// Get an absolute URL instead of a server-relative path/ + /// The generated URL. + public static string? PlainAction(this IUrlHelper helper, [AspMvcAction] string action, [AspMvcController] string controller, object? values = null, bool absoluteUrl = false) { - /********* - ** Public methods - *********/ - /**** - ** View helpers - ****/ - /// Get a URL for an action method. Unlike , only the specified are added to the URL without merging values from the current HTTP request. - /// The URL helper to extend. - /// The name of the action method. - /// The name of the controller. - /// An object that contains route values. - /// Get an absolute URL instead of a server-relative path/ - /// The generated URL. - public static string? PlainAction(this IUrlHelper helper, [AspMvcAction] string action, [AspMvcController] string controller, object? values = null, bool absoluteUrl = false) + // get route values + RouteValueDictionary valuesDict = new(values); + foreach (var value in helper.ActionContext.RouteData.Values) { - // get route values - RouteValueDictionary valuesDict = new(values); - foreach (var value in helper.ActionContext.RouteData.Values) - { - if (!valuesDict.ContainsKey(value.Key)) - valuesDict[value.Key] = null; // explicitly remove it from the URL - } - - // get relative URL - string? url = helper.Action(action, controller, valuesDict); - if (url == null && action.EndsWith("Async")) - url = helper.Action(action[..^"Async".Length], controller, valuesDict); - - // get absolute URL - if (absoluteUrl) - { - HttpRequest request = helper.ActionContext.HttpContext.Request; - Uri baseUri = new($"{request.Scheme}://{request.Host}"); - url = new Uri(baseUri, url).ToString(); - } - - return url; + if (!valuesDict.ContainsKey(value.Key)) + valuesDict[value.Key] = null; // explicitly remove it from the URL } - /// Get a serialized JSON representation of the value. - /// The page to extend. - /// The value to serialize. - /// The serialized JSON. - /// This bypasses unnecessary validation (e.g. not allowing null values) in . - public static IHtmlContent ForJson(this RazorPageBase page, object? value) + // get relative URL + string? url = helper.Action(action, controller, valuesDict); + if (url == null && action.EndsWith("Async")) + url = helper.Action(action[..^"Async".Length], controller, valuesDict); + + // get absolute URL + if (absoluteUrl) { - string json = JsonConvert.SerializeObject(value); - return new HtmlString(json); + HttpRequest request = helper.ActionContext.HttpContext.Request; + Uri baseUri = new($"{request.Scheme}://{request.Host}"); + url = new Uri(baseUri, url).ToString(); } + + return url; + } + + /// Convert a virtual (relative, starting with ~/) path to an application absolute path, and append a query argument to force browsers to re-download the asset if needed. + /// The URL helper to extend. + /// The virtual path of the content. + public static string ContentWithCacheBust(this IUrlHelper helper, string url) + { + char delimiter = url.Contains('?') ? '&' : '?'; + + return helper.Content($"{url}{delimiter}v={Program.CacheBustValue}"); + } + + /// Get a serialized JSON representation of the value. + /// The page to extend. + /// The value to serialize. + /// The serialized JSON. + /// This bypasses unnecessary validation (e.g. not allowing null values) in . + public static IHtmlContent ForJson(this RazorPageBase page, object? value) + { + string json = JsonConvert.SerializeObject(value); + return new HtmlString(json); } } diff --git a/src/SMAPI.Web/Framework/IModDownload.cs b/src/SMAPI.Web/Framework/IModDownload.cs index 8cb829893..ccb7259d2 100644 --- a/src/SMAPI.Web/Framework/IModDownload.cs +++ b/src/SMAPI.Web/Framework/IModDownload.cs @@ -1,29 +1,28 @@ -namespace StardewModdingAPI.Web.Framework +namespace StardewModdingAPI.Web.Framework; + +/// Generic metadata about a file download on a mod page. +internal interface IModDownload { - /// Generic metadata about a file download on a mod page. - internal interface IModDownload - { - /********* - ** Accessors - *********/ - /// The download's display name. - string Name { get; } + /********* + ** Accessors + *********/ + /// The download's display name. + string Name { get; } - /// The download's description. - string? Description { get; } + /// The download's description. + string? Description { get; } - /// The download's file version. - string? Version { get; } + /// The download's file version. + string? Version { get; } - /// The mod URL page from which to download this update, if different from the URL of the mod page it was fetched from. - string? ModPageUrl { get; } + /// The mod URL page from which to download this update, if different from the URL of the mod page it was fetched from. + string? ModPageUrl { get; } - /********* - ** Methods - *********/ - /// Get whether the subkey matches this download. - /// The update subkey to check. - bool MatchesSubkey(string subkey); - } + /********* + ** Methods + *********/ + /// Get whether the subkey matches this download. + /// The update subkey to check. + bool MatchesSubkey(string subkey); } diff --git a/src/SMAPI.Web/Framework/IModPage.cs b/src/SMAPI.Web/Framework/IModPage.cs index 85be41e2b..e73744e24 100644 --- a/src/SMAPI.Web/Framework/IModPage.cs +++ b/src/SMAPI.Web/Framework/IModPage.cs @@ -2,68 +2,67 @@ using System.Diagnostics.CodeAnalysis; using StardewModdingAPI.Toolkit.Framework.UpdateData; -namespace StardewModdingAPI.Web.Framework +namespace StardewModdingAPI.Web.Framework; + +/// Generic metadata about a mod page. +internal interface IModPage { - /// Generic metadata about a mod page. - internal interface IModPage - { - /********* - ** Accessors - *********/ - /// The mod site containing the mod. - ModSiteKey Site { get; } - - /// The mod's unique ID within the site. - string Id { get; } - - /// The mod name. - string? Name { get; } - - /// The mod's semantic version number. - string? Version { get; } - - /// The mod's web URL. - string? Url { get; } - - /// The mod downloads. - IModDownload[] Downloads { get; } - - /// The mod page status. - RemoteModStatus Status { get; } - - /// A user-friendly error which indicates why fetching the mod info failed (if applicable). - string? Error { get; } - - /// Whether the mod data is valid. - [MemberNotNullWhen(true, nameof(IModPage.Name), nameof(IModPage.Url))] - [MemberNotNullWhen(false, nameof(IModPage.Error))] - bool IsValid { get; } - - /// Whether this mod page requires update subkeys and does not allow matching downloads without them. - bool RequireSubkey { get; } - - - /********* - ** Methods - *********/ - /// Get the mod name for an update subkey, if different from the mod page name. - /// The update subkey. - string? GetName(string? subkey); - - /// Get the mod page URL for an update subkey, if different from the mod page it was fetched from. - /// The update subkey. - string? GetUrl(string? subkey); - - /// Set the fetched mod info. - /// The mod name. - /// The mod's semantic version number. - /// The mod's web URL. - /// The mod downloads. - IModPage SetInfo(string name, string? version, string url, IEnumerable downloads); - - /// Set a mod fetch error. - /// The mod availability status on the remote site. - /// A user-friendly error which indicates why fetching the mod info failed (if applicable). - IModPage SetError(RemoteModStatus status, string error); - } + /********* + ** Accessors + *********/ + /// The mod site containing the mod. + ModSiteKey Site { get; } + + /// The mod's unique ID within the site. + string Id { get; } + + /// The mod name. + string? Name { get; } + + /// The mod's semantic version number. + string? Version { get; } + + /// The mod's web URL. + string? Url { get; } + + /// The mod downloads. + IModDownload[] Downloads { get; } + + /// The mod page status. + RemoteModStatus Status { get; } + + /// A user-friendly error which indicates why fetching the mod info failed (if applicable). + string? Error { get; } + + /// Whether the mod data is valid. + [MemberNotNullWhen(true, nameof(IModPage.Name), nameof(IModPage.Url))] + [MemberNotNullWhen(false, nameof(IModPage.Error))] + bool IsValid { get; } + + /// Whether this mod page requires update subkeys and does not allow matching downloads without them. + bool RequireSubkey { get; } + + + /********* + ** Methods + *********/ + /// Get the mod name for an update subkey, if different from the mod page name. + /// The update subkey. + string? GetName(string? subkey); + + /// Get the mod page URL for an update subkey, if different from the mod page it was fetched from. + /// The update subkey. + string? GetUrl(string? subkey); + + /// Set the fetched mod info. + /// The mod name. + /// The mod's semantic version number. + /// The mod's web URL. + /// The mod downloads. + IModPage SetInfo(string name, string? version, string url, IEnumerable downloads); + + /// Set a mod fetch error. + /// The mod availability status on the remote site. + /// A user-friendly error which indicates why fetching the mod info failed (if applicable). + IModPage SetError(RemoteModStatus status, string error); } diff --git a/src/SMAPI.Web/Framework/InternalControllerFeatureProvider.cs b/src/SMAPI.Web/Framework/InternalControllerFeatureProvider.cs index 2c24c610c..30fe0cf40 100644 --- a/src/SMAPI.Web/Framework/InternalControllerFeatureProvider.cs +++ b/src/SMAPI.Web/Framework/InternalControllerFeatureProvider.cs @@ -3,25 +3,24 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Controllers; -namespace StardewModdingAPI.Web.Framework +namespace StardewModdingAPI.Web.Framework; + +/// Discovers controllers with support for non-public controllers. +internal class InternalControllerFeatureProvider : ControllerFeatureProvider { - /// Discovers controllers with support for non-public controllers. - internal class InternalControllerFeatureProvider : ControllerFeatureProvider + /********* + ** Public methods + *********/ + /// Determines if a given type is a controller. + /// The candidate. + /// true if the type is a controller; otherwise false. + protected override bool IsController(TypeInfo type) { - /********* - ** Public methods - *********/ - /// Determines if a given type is a controller. - /// The candidate. - /// true if the type is a controller; otherwise false. - protected override bool IsController(TypeInfo type) - { - return - type.IsClass - && !type.IsAbstract - && (/*type.IsPublic &&*/ !type.ContainsGenericParameters) - && (!type.IsDefined(typeof(NonControllerAttribute)) - && (type.Name.EndsWith("Controller", StringComparison.OrdinalIgnoreCase) || type.IsDefined(typeof(ControllerAttribute)))); - } + return + type.IsClass + && !type.IsAbstract + && (/*type.IsPublic &&*/ !type.ContainsGenericParameters) + && (!type.IsDefined(typeof(NonControllerAttribute)) + && (type.Name.EndsWith("Controller", StringComparison.OrdinalIgnoreCase) || type.IsDefined(typeof(ControllerAttribute)))); } } diff --git a/src/SMAPI.Web/Framework/JobDashboardAuthorizationFilter.cs b/src/SMAPI.Web/Framework/JobDashboardAuthorizationFilter.cs index 3c1405ebf..7cb4f088a 100644 --- a/src/SMAPI.Web/Framework/JobDashboardAuthorizationFilter.cs +++ b/src/SMAPI.Web/Framework/JobDashboardAuthorizationFilter.cs @@ -1,34 +1,33 @@ using Hangfire.Dashboard; -namespace StardewModdingAPI.Web.Framework +namespace StardewModdingAPI.Web.Framework; + +/// Authorizes requests to access the Hangfire job dashboard. +internal class JobDashboardAuthorizationFilter : IDashboardAuthorizationFilter { - /// Authorizes requests to access the Hangfire job dashboard. - internal class JobDashboardAuthorizationFilter : IDashboardAuthorizationFilter - { - /********* - ** Fields - *********/ - /// An authorization filter that allows local requests. - private static readonly LocalRequestsOnlyAuthorizationFilter LocalRequestsOnlyFilter = new(); + /********* + ** Fields + *********/ + /// An authorization filter that allows local requests. + private static readonly LocalRequestsOnlyAuthorizationFilter LocalRequestsOnlyFilter = new(); - /********* - ** Public methods - *********/ - /// Authorize a request. - /// The dashboard context. - public bool Authorize(DashboardContext context) - { - return - context.IsReadOnly // always allow readonly access - || JobDashboardAuthorizationFilter.IsLocalRequest(context); // else allow access from localhost - } + /********* + ** Public methods + *********/ + /// Authorize a request. + /// The dashboard context. + public bool Authorize(DashboardContext context) + { + return + context.IsReadOnly // always allow readonly access + || JobDashboardAuthorizationFilter.IsLocalRequest(context); // else allow access from localhost + } - /// Get whether a request originated from a user on the server machine. - /// The dashboard context. - public static bool IsLocalRequest(DashboardContext context) - { - return JobDashboardAuthorizationFilter.LocalRequestsOnlyFilter.Authorize(context); - } + /// Get whether a request originated from a user on the server machine. + /// The dashboard context. + public static bool IsLocalRequest(DashboardContext context) + { + return JobDashboardAuthorizationFilter.LocalRequestsOnlyFilter.Authorize(context); } } diff --git a/src/SMAPI.Web/Framework/LogParsing/LogMessageBuilder.cs b/src/SMAPI.Web/Framework/LogParsing/LogMessageBuilder.cs index a1384b8ff..aaceadc01 100644 --- a/src/SMAPI.Web/Framework/LogParsing/LogMessageBuilder.cs +++ b/src/SMAPI.Web/Framework/LogParsing/LogMessageBuilder.cs @@ -3,94 +3,93 @@ using System.Text; using StardewModdingAPI.Web.Framework.LogParsing.Models; -namespace StardewModdingAPI.Web.Framework.LogParsing +namespace StardewModdingAPI.Web.Framework.LogParsing; + +/// Handles constructing log message instances with minimal memory allocation. +internal class LogMessageBuilder { - /// Handles constructing log message instances with minimal memory allocation. - internal class LogMessageBuilder + /********* + ** Fields + *********/ + /// The local time when the next log was posted. + public string? Time { get; set; } + + /// The log level for the next log message. + public LogLevel Level { get; set; } + + /// The screen ID in split-screen mode. + public int ScreenId { get; set; } + + /// The mod name for the next log message. + public string? Mod { get; set; } + + /// The text for the next log message. + private readonly StringBuilder Text = new(); + + + /********* + ** Accessors + *********/ + /// Whether the next log message has been started. + [MemberNotNullWhen(true, nameof(LogMessageBuilder.Time), nameof(LogMessageBuilder.Mod))] + public bool Started { get; private set; } + + + /********* + ** Public methods + *********/ + /// Start accumulating values for a new log message. + /// The local time when the log was posted. + /// The log level. + /// The screen ID in split-screen mode. + /// The mod name. + /// The initial log text. + /// A log message is already started; call before starting a new message. + public void Start(string time, LogLevel level, int screenId, string mod, string text) + { + if (this.Started) + throw new InvalidOperationException("Can't start new message, previous log message isn't done yet."); + + this.Started = true; + + this.Time = time; + this.Level = level; + this.ScreenId = screenId; + this.Mod = mod; + this.Text.Append(text); + } + + /// Add a new line to the next log message being built. + /// The line to add. + /// A log message hasn't been started yet. + public void AddLine(string text) + { + if (!this.Started) + throw new InvalidOperationException("Can't add text, no log message started yet."); + + this.Text.Append("\n"); + this.Text.Append(text); + } + + /// Get a log message for the accumulated values. + public LogMessage? Build() + { + if (!this.Started) + return null; + + return new LogMessage( + time: this.Time, + level: this.Level, + screenId: this.ScreenId, + mod: this.Mod, + text: this.Text.ToString() + ); + } + + /// Reset to start a new log message. + public void Clear() { - /********* - ** Fields - *********/ - /// The local time when the next log was posted. - public string? Time { get; set; } - - /// The log level for the next log message. - public LogLevel Level { get; set; } - - /// The screen ID in split-screen mode. - public int ScreenId { get; set; } - - /// The mod name for the next log message. - public string? Mod { get; set; } - - /// The text for the next log message. - private readonly StringBuilder Text = new(); - - - /********* - ** Accessors - *********/ - /// Whether the next log message has been started. - [MemberNotNullWhen(true, nameof(LogMessageBuilder.Time), nameof(LogMessageBuilder.Mod))] - public bool Started { get; private set; } - - - /********* - ** Public methods - *********/ - /// Start accumulating values for a new log message. - /// The local time when the log was posted. - /// The log level. - /// The screen ID in split-screen mode. - /// The mod name. - /// The initial log text. - /// A log message is already started; call before starting a new message. - public void Start(string time, LogLevel level, int screenId, string mod, string text) - { - if (this.Started) - throw new InvalidOperationException("Can't start new message, previous log message isn't done yet."); - - this.Started = true; - - this.Time = time; - this.Level = level; - this.ScreenId = screenId; - this.Mod = mod; - this.Text.Append(text); - } - - /// Add a new line to the next log message being built. - /// The line to add. - /// A log message hasn't been started yet. - public void AddLine(string text) - { - if (!this.Started) - throw new InvalidOperationException("Can't add text, no log message started yet."); - - this.Text.Append("\n"); - this.Text.Append(text); - } - - /// Get a log message for the accumulated values. - public LogMessage? Build() - { - if (!this.Started) - return null; - - return new LogMessage( - time: this.Time, - level: this.Level, - screenId: this.ScreenId, - mod: this.Mod, - text: this.Text.ToString() - ); - } - - /// Reset to start a new log message. - public void Clear() - { - this.Started = false; - this.Text.Clear(); - } + this.Started = false; + this.Text.Clear(); } } diff --git a/src/SMAPI.Web/Framework/LogParsing/LogParseException.cs b/src/SMAPI.Web/Framework/LogParsing/LogParseException.cs index 3f815e3e1..9d8a66ee2 100644 --- a/src/SMAPI.Web/Framework/LogParsing/LogParseException.cs +++ b/src/SMAPI.Web/Framework/LogParsing/LogParseException.cs @@ -1,16 +1,15 @@ using System; -namespace StardewModdingAPI.Web.Framework.LogParsing +namespace StardewModdingAPI.Web.Framework.LogParsing; + +/// An error while parsing the log file which doesn't require a stack trace to troubleshoot. +internal class LogParseException : Exception { - /// An error while parsing the log file which doesn't require a stack trace to troubleshoot. - internal class LogParseException : Exception - { - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The user-friendly error message. - public LogParseException(string message) - : base(message) { } - } + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The user-friendly error message. + public LogParseException(string message) + : base(message) { } } diff --git a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs index 67ecea780..5367ee919 100644 --- a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs +++ b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs @@ -5,345 +5,354 @@ using System.Text.RegularExpressions; using StardewModdingAPI.Web.Framework.LogParsing.Models; -namespace StardewModdingAPI.Web.Framework.LogParsing +namespace StardewModdingAPI.Web.Framework.LogParsing; + +/// Parses SMAPI log files. +public class LogParser { - /// Parses SMAPI log files. - public class LogParser - { - /********* - ** Fields - *********/ - /// A regex pattern matching the start of a SMAPI message. - private readonly Regex MessageHeaderPattern = new(@"^\[(?

@@ -24,7 +21,7 @@
- Download SMAPI @Model.StableVersion.Version
+ Download SMAPI @Model.StableVersion.Version
-

For mod creators

- +

For mod creators

+ diff --git a/src/SMAPI.Web/Views/Index/Privacy.cshtml b/src/SMAPI.Web/Views/Index/Privacy.cshtml index fd78f908e..c8523ee78 100644 --- a/src/SMAPI.Web/Views/Index/Privacy.cshtml +++ b/src/SMAPI.Web/Views/Index/Privacy.cshtml @@ -1,12 +1,9 @@ -@using Microsoft.Extensions.Options @using StardewModdingAPI.Web.Framework -@using StardewModdingAPI.Web.Framework.ConfigModels -@inject IOptions SiteConfig @{ ViewData["Title"] = "SMAPI privacy notes"; } @section Head { - + } ← back to SMAPI page diff --git a/src/SMAPI.Web/Views/JsonValidator/Index.cshtml b/src/SMAPI.Web/Views/JsonValidator/Index.cshtml index cd1050bc3..a61f826b4 100644 --- a/src/SMAPI.Web/Views/JsonValidator/Index.cshtml +++ b/src/SMAPI.Web/Views/JsonValidator/Index.cshtml @@ -27,16 +27,16 @@ { } - - + + - - + + - - + + - +