diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c041d57 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,172 @@ +name: Release + +on: + push: + tags: + - '*' + +env: + CARGO_TERM_COLOR: always + +jobs: + prerelease: + runs-on: ubuntu-latest + outputs: + value: ${{ steps.prerelease.outputs.value }} + steps: + - name: Prerelease Check + id: prerelease + run: | + if [[ ${{ github.ref_name }} =~ ^v?[0-9]+[.][0-9]+[.][0-9]+$ ]]; then + echo value=false >> $GITHUB_OUTPUT + else + echo value=true >> $GITHUB_OUTPUT + fi + package: + strategy: + matrix: + job: + - target: aarch64-apple-darwin + os: macos-latest + - target: aarch64-unknown-linux-musl + os: ubuntu-latest + target_rustflags: '--codegen linker=aarch64-linux-gnu-gcc' + - target: aarch64-pc-windows-msvc + os: windows-latest + - target: x86_64-apple-darwin + os: macos-latest + - target: x86_64-pc-windows-msvc + os: windows-latest + target_rustflags: -C target-feature=+crt-static + - target: x86_64-unknown-linux-musl + os: ubuntu-latest + + runs-on: "${{ matrix.job.os }}" + + needs: + - prerelease + + steps: + - uses: actions/checkout@v4 + + - name: Install AArch64 Toolchain + if: ${{ matrix.job.target == 'aarch64-unknown-linux-musl' }} + run: | + sudo apt-get update + sudo apt-get install -y gcc-aarch64-linux-gnu libc6-dev-i386 + + - name: Install Musl Tools + if: ${{ contains(matrix.job.target, '-unknown-linux-musl') }} + run: | + sudo apt-get update + sudo apt-get install -y musl-tools + + - name: Package + id: package + shell: bash + run: | + VERSION=${{ github.ref_name }} + TARGET=${{ matrix.job.target }} + DIST=`pwd`/dist + EXECUTABLE=target/$TARGET/release/crtcli + + if [[ ${{ matrix.job.os }} == windows-latest ]]; then + EXECUTABLE=$EXECUTABLE.exe + fi + + echo "Packaging $VERSION for $TARGET..." + + test -f Cargo.lock || cargo generate-lockfile + + echo "Installing rust toolchain for $TARGET..." + + rustup target add $TARGET + + if [[ $TARGET == aarch64-unknown-linux-musl ]]; then + export CC=aarch64-linux-gnu-gcc + fi + + echo "Building..." + + RUSTFLAGS="--codegen target-feature=+crt-static ${{ matrix.job.target_rustflags }}" \ + cargo build --bin crtcli --target $TARGET --release + + mkdir $DIST + + cp -r \ + $EXECUTABLE \ + LICENSE \ + README.md \ + CHANGELOG.md \ + $DIST + + cd $DIST + echo "Creating release archive..." + case ${{ matrix.job.os }} in + ubuntu-latest | macos-latest) + ARCHIVE=crtcli-$VERSION-$TARGET.tar.gz + tar czf $ARCHIVE * + echo "archive=$DIST/$ARCHIVE" >> $GITHUB_OUTPUT + ;; + windows-latest) + ARCHIVE=crtcli-$VERSION-$TARGET.zip + 7z a $ARCHIVE * + echo "archive=`pwd -W`/$ARCHIVE" >> $GITHUB_OUTPUT + ;; + esac + + - name: Publish Release Archive + uses: softprops/action-gh-release@v2 + if: ${{ startsWith(github.ref, 'refs/tags/') }} + with: + draft: false + files: ${{ steps.package.outputs.archive }} + prerelease: ${{ needs.prerelease.outputs.value }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish Changelog + uses: softprops/action-gh-release@v2 + if: >- + ${{ + startsWith(github.ref, 'refs/tags/') + && matrix.job.target == 'x86_64-unknown-linux-musl' + }} + with: + draft: false + files: CHANGELOG.md + prerelease: ${{ needs.prerelease.outputs.value }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + checksum: + runs-on: ubuntu-latest + + needs: + - prerelease + - package + + steps: + - name: Download Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release download \ + --repo heabijay/crtcli \ + --pattern '*' \ + --dir release \ + ${{ github.ref_name }} + + - name: Create Checksums + run: | + cd release + shasum -a 256 * > ../SHA256SUMS + + - name: Publish Checksums + uses: softprops/action-gh-release@v2 + with: + draft: false + files: SHA256SUMS + prerelease: ${{ needs.prerelease.outputs.value }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a8b5cd3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Generated by Cargo +# will have compiled files and executables +debug/ +target/ + +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +Cargo.lock + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +# RustRover +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +.idea/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..83afa85 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,7 @@ +# crtcli Changelog + +## [0.1.0](https://github.com/heabijay/crtcli/releases/tag/0.1.0) (2025-01-03) + +### Added + + - Initial Release 🎉 \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..cb2efb9 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "crtcli" +description = "Command-line tool for interacting with Creatio and Creatio packages" +version = "0.1.0" +edition = "2021" +authors = ["heabijay "] +repository = "https://github.com/heabijay/crtcli" +license = "MIT" + +[dependencies] +anstream = "0.6.18" +bincode = "1.3.3" +clap = { version = "4.5.23", features = ["derive", "env", "suggestions", "usage"] } +clap_complete = "4.5.40" +dotenvy = "0.15.7" +flate2 = "1.0.35" +hyper-util = "0.1.10" +owo-colors = "4.1.0" +quick-xml = "0.37.2" +regex = "1.11.1" +serde = { version = "1.0.217", features = ["derive"] } +serde_json = { version = "1.0.134", features = ["preserve_order"] } +thiserror = "2.0.9" +time = { version = "0.3.37", features = ["formatting", "local-offset", "macros"] } +toml = "0.8.19" +walkdir = "2.5.0" +zip = "2.2.2" + +[dependencies.reqwest] +version = "0.12.12" +default-features = false +features = ["charset", "blocking", "http2", "macos-system-configuration", "json", "multipart", "rustls-tls"] + +[dev-dependencies] +pretty_assertions = "1.4.1" + +[profile.release] +lto = true +strip = true +codegen-units = 1 +opt-level = "z" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f009c2e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 heabijay + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..538de97 --- /dev/null +++ b/README.md @@ -0,0 +1,983 @@ +# crtcli + +Creatio Command Line Interface (crtcli) — A command-line tool for interacting with Creatio and Creatio packages, focusing on enhancing the developer experience. + +A tiny [clio](https://github.com/Advance-Technologies-Foundation/clio) utility alternative. + +> crtcli is under researching & development. The CLI interface may change, so exercise caution when using it in scripts and remember to check for updates. + +## Installation + +Download the archive from [releases](https://github.com/heabijay/crtcli/releases) for your platform, extract it, and run executable from terminal. + +To use crtcli from anywhere, add the directory containing the executable to your system's PATH environment variable. + +## Commands / Features + +- [x] [app](#app) + - [x] [compile](#app-compile) + - [x] [flush-redis](#app-flush-redis) + - [x] [fs](#app-fs) + - [x] [check](#app-fs-check) + - [x] [pull](#app-fs-pull) + - [x] [push](#app-fs-push) + - [x] [install-log](#app-install-log) + - [x] [pkg](#app-pkg) + - [x] [compile](#app-pkg-compile) + - [x] [download](#app-pkg-download) + - [x] [fs](#app-pkg-fs) + - [x] [pull](#app-pkg-fs-pull) + - [x] [push](#app-pkg-fs-push) + - [x] [install](#app-pkg-install) + - [x] [get-uid](#app-pkg-get-uid) + - [x] [lock](#app-pkg-lock) + - [x] [pull](#app-pkg-pull) + - [x] [push](#app-pkg-push) + - [x] [unlock](#app-pkg-unlock) + - [x] [pkgs](#app-pkgs) + - [x] [restart](#app-restart) + - [x] [request](#app-request) + - [x] [sql](#app-sql) +- [x] [pkg](#pkg) + - [x] [apply](#pkg-apply) + - [x] [pack](#pkg-pack) + - [x] [unpack](#pkg-unpack) + - [x] [unpack-all](#pkg-unpack-all) + + +### [Root Command] + +**Options:** + +- `--help | -h` — Print help for any command. + +- `--version | -V` — Print crtcli version. + +- `--completions ` — Generate shell completions config for your shell. This config should be added to your shell configuration file or folder. Currently, this completions config is getting generated using the 'clap_complete' crate. + + Possible values: bash, elvish, fish, powershell, zsh + + Defaults: trying to autodetect + + +### app + +Commands to interact with Creatio application instance. + +Please check [dotenv (.env) files](#dotenv-env-files) for simplified commands usage. + +**Arguments:** + +- `` (required) (env: `CRTCLI_APP_URL`) — The base URL of Creatio instance. + +- `` (required) (env: `CRTCLI_APP_USERNAME`) — Creatio Username. + +- `` (required) (env: `CRTCLI_APP_PASSWORD`) — Creatio Password. + +**Options:** + +- `--insecure | -i` (env: `CRTCLI_APP_INSECURE`) — Bypass SSL certificate verification. Use with caution, primarily for development or testing environments. + +- `--net-framework` (env: `CRTCLI_APP_NETFRAMEWORK`) — Use .NET Framework (IIS) Creatio compatibility + + By default, crtcli primary uses .NET Core / .NET (Kestrel) API routes to operate with remote. However, some features like "app restart" works by different API routes in both platforms. + + +### app compile + +Compiles the Creatio application (equivalent to the "Build" or "Rebuild" action in the Creatio Configuration section). + +**Options:** + +- `--force-rebuild | -f` — Perform a rebuild instead of a standard build. + +- `--restart | -r` — Restart the Creatio application after successful compilation. + +**Examples:** + +- `crtcli app https://localhost:5000 Supervisor Supervisor -i compile` — Compiles the Creatio instance at insecure https://localhost:5000. + +- `crtcli app compile -fr` — Compiles the Creatio instance specified by the $CRTCLI_APP_URL environment variable, using a forced rebuild and restarting afterward. + + +### app flush-redis + +Clears the Redis cache associated with the Creatio instance. + +**Examples:** + +- `crtcli app https://localhost:5000 Supervisor Supervisor -i flush-redis` — Flushes the Redis cache for the insecure Creatio instance at https://localhost:5000. + +- `crtcli app flush-redis` — Flushes Redis cache in Creatio '$CRTCLI_APP_URL'. + + +### app fs + +Commands for interacting with Creatio's File System Development (FSD) mode. + + +### app fs check + +Print if File System Development mode is enabled for the Creatio instance. + +**Examples:** + +- `crtcli app https://localhost:5000 Supervisor Supervisor -i fs check` — Check is File System Development mode status for the insecure Creatio 'https://localhost:5000'. (True/False) + +- `crtcli app fs check` — Check is File System Development mode enabled in Creatio '$CRTCLI_APP_URL'. + + +### app fs pull + +Unload packages from Creatio database into filesystem. + +**Arguments:** + +- `[PACKAGES]` — A space-separated or comma-separated list of package names to pull. If omitted, all* packages from database will be pulled. + + _\* Creatio pulls only unlocked packages that you can modify in Creatio Configuration._ + +**Examples:** + +- `crtcli app https://localhost:5000 Supervisor Supervisor -i fs pull` — Pulls all packages from database into filesystem at insecure Creatio 'https://localhost:5000'. + +- `crtcli app fs pull UsrPackage` — Pulls single package 'UsrPackage' from database into filesystem in Creatio '$CRTCLI_APP_URL'. + +- `crtcli app fs pull UsrPackage UsrPackage2` | `crtcli app fs pull UsrPackage,UsrPackage2` — Pulls packages 'UsrPackage' and 'UsrPackage2' from database into filesystem in Creatio '$CRTCLI_APP_URL'. + + +### app fs push + +Load packages from filesystem into Creatio database. + +**Arguments:** + +- `[PACKAGES]` — A space-separated or comma-separated list of package names to push. If omitted, all* packages from filesystem will be pushed. + + _\* Creatio pushes only unlocked packages that you can modify in Creatio Configuration._ + +**Examples:** + +- `crtcli app https://localhost:5000 Supervisor Supervisor -i fs push` — Pushes all packages from filesystem into database at insecure Creatio 'https://localhost:5000'. + +- `crtcli app fs push UsrPackage` — Pushes single package 'UsrPackage' from filesystem into database in Creatio '$CRTCLI_APP_URL'. + +- `crtcli app fs push UsrPackage UsrPackage2` | `crtcli app fs push UsrPackage,UsrPackage2` — Pushes packages 'UsrPackage' and 'UsrPackage2' from filesystem into database in Creatio '$CRTCLI_APP_URL'. + + +### app install-log + +Print last package installation log. + +**Examples:** + +- `crtcli app https://localhost:5000 Supervisor Supervisor -i install-log` — Gets last package installation log at insecure Creatio 'https://localhost:5000'. + +- `crtcli app install-log` — Gets last package installation log in Creatio '$CRTCLI_APP_URL'. + + +### app pkg + +Commands to manipulate with packages in Creatio. + +Many of these commands will attempt to infer the target package name from the current working directory if it's a package folder (contains a descriptor.json file). + + +### app pkg compile + +Compiles a specific package within the Creatio instance. + +**Arguments:** + +- `[PACKAGE_NAME]` — Name of package to compile. + + Defaults: If omitted, crtcli will try to determine the package name from the current directory (by looking for descriptor.json). + +**Options:** + +- `--restart | -r` — Restart the Creatio application after successful package compilation. + +**Examples:** + +For example current folder is '/Creatio_8.1.5.2176/Terrasoft.Configuration/Pkg/UsrPackage' which is package folder. + +- `crtcli app https://localhost:5000 Supervisor Supervisor -i pkg compile UsrCustomPkg` — Compiles package 'UsrCustomPkg' at insecure Creatio 'https://localhost:5000'. + +- `crtcli app pkg compile -r` — Compiles the UsrPackage (inferred from the current directory) in the Creatio instance defined by $CRTCLI_APP_URL and restarts the application. + + +### app pkg download + +Downloads one or more packages from the Creatio instance as a zip archive. + +**Arguments:** + +- `[PACKAGES]` — A space-separated or comma-separated list of package names to download. + + Defaults: If omitted, crtcli will try to determine the package name from the current directory (by looking for descriptor.json). + +**Options:** + +- `--output-folder | -f ` — Directory where the downloaded package archive will be saved. + + Defaults: Current directory. + +- `--output-filename | -n ` — Name of the output zip file. + + Defaults: Autogenerated — `PackageName_YYYY-MM-DD_HH-mm-ss.zip` for single package and `Packages_YYYY-MM-DD_HH-mm-ss.zip` for multiple packages. + +**Examples:** + +For example current folder is '/Creatio_8.1.5.2176/Terrasoft.Configuration/Pkg/UsrPackage' which is package folder. + +- `crtcli app https://localhost:5000 Supervisor Supervisor -i pkg download UsrCustomPkg` — Downloads package 'UsrCustomPkg' from insecure Creatio 'https://localhost:5000' to current directory. + +- `crtcli app pkg download -f /backups -n MyPackage.zip` — Downloads package 'UsrPackage' (cause current folder is this package) from Creatio '$CRTCLI_APP_URL' to '/backups' folder with filename 'MyPackage.zip'. + +- `crtcli app pkg download UsrPkg1 UsrPkg2` | `crtcli app pkg download UsrPkg1,UsrPkg2` — Downloads packages 'UsrPkg1' & 'UsrPkg2' from Creatio '$CRTCLI_APP_URL' to current folder. + + +### app pkg fs + +Commands/aliases to simplify manipulate with package insides File System Development mode (FSD) location. + +They are designed to be used from within a package directory located under the Creatio file system packages path, for example: + +`/Terrasoft.Configuration/Pkg/` + +And, of course, in this scenario, your Creatio should have File System Development mode enabled. + + +### app pkg fs pull + +Unload package in current folder from Creatio database into filesystem and applies any configured transforms (see [pkg apply](#pkg-apply)). + +Alternative to: + +```shell +crtcli app fs pull "{package_name}" # {package_name} is inferred from the current directory +crtcli pkg apply . +``` + +**Options:** + +- `--package-folder ` — Package folder where package is already pulled previously. + + Defaults: Current directory + + Sample: /Terrasoft.Configuration/Pkg/ + +And here you can use transforms from [pkg apply](#pkg-apply) command. + +\* Check [package.crtcli.toml](#packagecrtclitoml) to configure default apply transforms. + +**Examples:** + +For example current folder is '/Creatio_8.1.5.2176/Terrasoft.Configuration/Pkg/UsrPackage' which is package folder inside in Creatio (FSD mode enabled). + +- `crtcli app https://localhost:5000 Supervisor Supervisor -i pkg fs pull` — Pulls package 'UsrPackage' to filesystem from Creatio (using FSD) at insecure 'https://localhost:5000' and tries to apply configured transforms to it (for example from package.crtcli.toml file if exists). + +- `crtcli app pkg fs pull -S true` — Pulls package 'UsrPackage' from Creatio (using FSD) on '$CRTCLI_APP_URL' and applies sorting transform. + + +### app pkg fs push + +Load package in current folder from filesystem into Creatio database and optionally compiles it. + +Alternative to: + +```shell +crtcli app fs push "{package_name}" # {package_name} is inferred from the current directory +crtcli app pkg compile "{package_name}" -r +``` + +**Options:** + +- `--package-folder ` — Package folder where package is already pulled previously. + + Defaults: Current directory + + Sample: /Terrasoft.Configuration/Pkg/ + +- `--compile-package-after-push | -c` — Compile package after successful push. + +- `--restart-app-after-compile | -r` — Restart the Creatio application after successful compilation. + +**Examples:** + +For example current folder is '/Creatio_8.1.5.2176/Terrasoft.Configuration/Pkg/UsrPackage' which is package folder inside in Creatio (FSD mode enabled). + +- `crtcli app https://localhost:5000 Supervisor Supervisor -i pkg fs push` — Pushes package 'UsrPackage' from filesystem to Creatio (using FSD) at insecure 'https://localhost:5000'. + +- `crtcli app pkg fs push -cr` — Pushes package 'UsrPackage' from filesystem to Creatio (using FSD) on '$CRTCLI_APP_URL', compiles it after successfully push, and restarts Creatio application if compilation was successful. + + +### app pkg install + +Installs a package archive (.zip or .gz) into the Creatio instance. + +**Arguments:** + +- `` (required) — Path to the package archive file. + +**Options:** + +- `--restart | -r` — Restart the Creatio application after successful installation. + +- `--force | -f` (sql) — Overrides changed schemas in the database. Use this if you've modified schemas in an unlocked package within Creatio, and the installing process is preventing updates to those schemas. + + Under the hood, this option executes the following SQL script before package installation to mark all package schemas as unchanged: + + ```sql + UPDATE "SysSchema" + SET "IsChanged" = False, "IsLocked" = False + WHERE "SysPackageId" IN (SELECT "Id" FROM "SysPackage" WHERE "UId" = '{package_uid}'); + + UPDATE "SysPackageSchemaData" + SET "IsChanged" = False, "IsLocked" = False + WHERE "SysPackageId" IN (SELECT "Id" FROM "SysPackage" WHERE "UId" = '{package_uid}'); + + UPDATE "SysPackageSqlScript" + SET "IsChanged" = False, "IsLocked" = False + WHERE "SysPackageId" IN (SELECT "Id" FROM "SysPackage" WHERE "UId" = '{package_uid}'); + + UPDATE "SysPackageReferenceAssembly" + SET "IsChanged" = False, "IsLocked" = False + WHERE "SysPackageId" IN (SELECT "Id" FROM "SysPackage" WHERE "UId" = '{package_uid}'); + ``` + +- `--force-and-clear-localizations | -F` (sql) — Same as -f but also clears localization data. Use this if you want to remove outdated or unwanted localization strings. — _This options makes resources diffs less trashy during pull/push workflow._ + + Under the hood, this option executes the following SQL script before package installation: + + ```sql + -- SQL script from --force (-f) command here -- + + -- Then: + + DELETE FROM "SysLocalizableValue" + WHERE "SysPackageId" IN ( + SELECT "Id" FROM "SysPackage" WHERE "UId" = '{package_uid}' + ); + + DELETE FROM "SysPackageResourceChecksum" + WHERE "SysPackageId" IN ( + SELECT "Id" FROM "SysPackage" WHERE "UId" = '{package_uid}' + ); + + DELETE FROM "SysPackageDataLcz" WHERE "SysPackageSchemaDataId" IN ( + SELECT "Id" FROM "SysPackageSchemaData" WHERE "SysPackageId" IN ( + SELECT "Id" FROM "SysPackage" WHERE "UId" = '{package_uid}' + ) + ); + + DELETE FROM "SysPackageSchemaData" + WHERE "SysPackageId" IN ( + SELECT "Id" FROM "SysPackage" WHERE "UId" = '{package_uid}' + ); + ``` + +- `--clear-schemas-content` (sql) — Clears existing schema content and checksums before installation. Use this if schema content (e.g., C# code) is not updating correctly from the package. + + Under the hood, this option executes the following SQL script before package installation: + + ```sql + DELETE FROM "SysSchemaContent" WHERE "SysSchemaId" IN ( + SELECT "Id" FROM "SysSchema" WHERE "SysPackageId" IN ( + SELECT "Id" FROM "SysPackage" WHERE "UId" = '{package_uid}' + ) + ) + + UPDATE "SysSchema" + SET "Checksum" = '', + "MetaData" = NULL, + "Descriptor" = NULL, + "CreatedOn" = NULL, + "ModifiedById" = NULL, + "CreatedById" = NULL, + "ModifiedOn" = NULL, + "ClientContentModifiedOn" = NULL + WHERE "SysPackageId" IN ( + SELECT "Id" FROM "SysPackage" WHERE "UId" = '{package_uid}' + ) + ``` + +- `--disable-install-log-pooling` — Disables the display of the installation log. + + +\* (sql) — Requires an installed sql runner package in Creatio that is supported by crtcli. Please check [app sql](#app-sql) command documentation. + + +**Examples:** + +- `crtcli app https://localhost:5000 Supervisor Supervisor -i pkg install /repo/UsrPackage-latest.zip` — Installs package archive '/repo/UsrPackage-latest.zip' at insecure Creatio 'https://localhost:5000'. + +- `crtcli app pkg install UsrPackage.gz -fr` — Executes SQL to mark all 'UsrPackage' schemas as not changed, installs package 'UsrPackage.gz' in Creatio '$CRTCLI_APP_URL' and restarts it after successful installation. + +- `crtcli app pkg install UsrPackage.gz -Fr` — Executes SQL to mark all 'UsrPackage' schemas as not changed, clears all localization data of 'UsrPackage' schemas, installs package 'UsrPackage.gz' in Creatio '$CRTCLI_APP_URL' and restarts it after successful installation. + + +### app pkg get-uid + +Print installed package information by Package UId. + +**Arguments:** + +- `` (required) — UId of the package. + +**Options:** + +- `--json` — Display the output in JSON format. + +**Examples:** + +- `crtcli app https://localhost:5000 Supervisor Supervisor -i pkg get-uid ae8519c2-2aac-4a00-aa61-b0ffaac99ea3` — Prints information about package 'ae8519c2-2aac-4a00-aa61-b0ffaac99ea3' at insecure Creatio 'https://localhost:5000'. + + stdout: + ``` + ActionsDashboard (ae8519c2-2aac-4a00-aa61-b0ffaac99ea3) + | Id: 96adf8f9-652d-4382-843c-d91ff737478c + | Created on: 2020-05-27T12:09:53.095 + | Modified on: 2022-10-04T15:37:06.000 + | Maintainer: Terrasoft + | Type: 0 + ``` + +- `crtcli app pkg get-uid ae8519c2-2aac-4a00-aa61-b0ffaac99ea3 --json` — Prints information about package 'ae8519c2-2aac-4a00-aa61-b0ffaac99ea3' in Creatio '$CRTCLI_APP_URL' in JSON format. + + stdout: + ```json + {"id":"96adf8f9-652d-4382-843c-d91ff737478c","uId":"ae8519c2-2aac-4a00-aa61-b0ffaac99ea3","name":"ActionsDashboard","type":0,"maintainer":"Terrasoft","createdOn":"2020-05-27T12:09:53.095","modifiedOn":"2022-10-04T15:37:06.000"} + ``` + + +### app pkg lock + +Execute SQL to make package locked if it is unlocked in Creatio. + +```sql +UPDATE "SysPackage" +SET "InstallType" = 1, "IsLocked" = False, "IsChanged" = False +WHERE "Name" = '{package_name}'; +``` + +\* Requires an installed sql runner package in Creatio that is supported by crtcli. Please check [app sql](#app-sql) command documentation. + +**Arguments:** + +- `[PACKAGE_NAME]` — Package name to lock. + + Defaults: Tries to determine package name from current folder as package folder. (From file ./descriptor.json) + +**Examples:** + +For example current folder is '/Creatio_8.1.5.2176/Terrasoft.Configuration/Pkg/UsrPackage' which is package folder. + +- `crtcli app https://localhost:5000 Supervisor Supervisor -i pkg lock UsrCustomPackage` — Locks package 'UsrCustomPackage' at insecure Creatio 'https://localhost:5000'. + +- `crtcli app pkg lock` — Locks package 'UsrPackage' (cause current folder is this package) in Creatio '$CRTCLI_APP_URL'. + + +### app pkg pull + +Downloads a package from Creatio, unpacks it to a destination folder, and applies configured transforms. This is a more efficient alternative to manually downloading, unpacking, and applying transforms. + +Alternative to: + +```shell +crtcli app pkg download "{package_name}" --output-filename "tmp-pkg.zip" +crtcli pkg unpack "tmp-pkg.zip" . --merge +crtcli pkg apply . +rm "tmp-pkg.zip" +``` + +but faster due to in memory processing, merging only changes and more feature-rich. + + +**Options:** + +- `--package | -p ` — Package name to pull. + + Defaults: Tries to determine package name from destination folder. (From file ./descriptor.json) + +- `--destination-folder | -d ` — Destination folder where the package files will be unpacked using merge. + + Defaults: Current directory + +And here you can use transforms from [pkg apply](#pkg-apply) command. + +\* Check [package.crtcli.toml](#packagecrtclitoml) to configure default apply transforms. + +**Examples:** + +For example current folder is '/Creatio_8.1.5.2176/Terrasoft.Configuration/Pkg/UsrPackage' which is package folder. + +- `crtcli app https://localhost:5000 Supervisor Supervisor -i pkg pull -p UsrCustomPackage -d /repos/UsrCustomPackage -S true` — Downloads package 'UsrCustomPackage' from insecure Creatio 'https://localhost:5000' and unpacks it into /repos/UsrCustomPackage folder with sorting transform. + +- `crtcli app pkg pull` — Downloads package 'UsrPackage' (cause current folder is this package) from Creatio '$CRTCLI_APP_URL' and unpacks it into current folder using merge with default applied transforms. + + +### app pkg push + +Packs a package from a source folder and installs it into the Creatio instance. This is a more efficient alternative to manually packing and installing. + +Alternative to: + +```shell +crtcli pkg pack . --format gzip --output-filename "tmp-package.gz" +crtcli app install "tmp-package.gz" +rm "tmp-package.gz" +``` + +but it works faster due to in memory processing and merging only changes and also has additional features. + +**Options:** + +- `--source-folder | -s ` — Folder containing the package to be packed and installed. You can specify multiple source folders to install several packages at once. + + Defaults: Current directory + +And here you can use options from [app pkg install](#app-pkg-install) command like --restart, --force, ... + +**Examples:** + +For example current folder is '/Creatio_8.1.5.2176/Terrasoft.Configuration/Pkg/UsrPackage' which is package folder. + +- `crtcli app https://localhost:5000 Supervisor Supervisor -i pkg push -s /repos/UsrCustomPackage` — Packs and installs package 'UsrCustomPackage' into insecure Creatio 'https://localhost:5000'. + +- `crtcli app pkg push -Fr` — Packs and installs package 'UsrPackage' (cause current folder is this package) into Creatio '$CRTCLI_APP_URL' with executing sql scripts to mark package schemas as unchanged, schema localization cleanup and restarts application after install. + +- `crtcli app pkg push -s /repos/UsrCustomPackage1 -s /repos/UsrCustomPackage2` — Packs and installs packages 'UsrCustomPackage1' and 'UsrCustomPackage2' into Creatio '$CRTCLI_APP_URL' at once. + + +### app pkg unlock + +Execute SQL to make package unlocked if it is locked in Creatio. + +```sql +UPDATE "SysPackage" +SET "InstallType" = 0, "IsLocked" = True, "IsChanged" = True +WHERE "Name" = '{package_name}'; +``` + +\* Requires an installed sql runner package in Creatio that is supported by crtcli. Please check [app sql](#app-sql) command documentation. + +\** Note: To be able to edit the unlocked package, ensure that the Maintainer in kagethe pac matches the Maintainer system setting in Creatio. You may need to log out and log back in after change the Maintainer system setting. + +**Arguments:** + +- `[PACKAGE_NAME]` — Name of the package to unlock. + + Defaults: Tries to determine package name from current folder as package folder. (From file ./descriptor.json) + +**Examples:** + +For example current folder is '/Creatio_8.1.5.2176/Terrasoft.Configuration/Pkg/UsrPackage' which is package folder. + +- `crtcli app https://localhost:5000 Supervisor Supervisor -i pkg unlock UsrCustomPackage` — Unlocks package 'UsrCustomPackage' at insecure Creatio 'https://localhost:5000'. + +- `crtcli app pkg unlock` — Unlocks package 'UsrPackage' (cause current folder is this package) in Creatio '$CRTCLI_APP_URL'. + + +### app pkgs + +Lists the installed packages in the Creatio instance. + +**Options:** + +- `--json` — Display the output in JSON format. + +**Examples:** + +- `crtcli app https://localhost:5000 Supervisor Supervisor -i pkgs` — Prints list of installed packages at insecure Creatio 'https://localhost:5000'. + + stdout: + ``` + ActionsDashboard (UId: ae8519c2-2aac-4a00-aa61-b0ffaac99ea3) + AnalyticsDashboard (UId: 02abeaad-7dcc-4f15-86c9-cd6090362e82) + Approval (UId: 1eefea8c-efe3-53d9-6397-3aac9cc9e785) + ... + ``` + +- `crtcli app pkgs --json` — Prints list of installed packages in Creatio '$CRTCLI_APP_URL' in JSON format. + + stdout: + ```json + [{"uId":"ae8519c2-2aac-4a00-aa61-b0ffaac99ea3","name":"ActionsDashboard"},{"uId":"02abeaad-7dcc-4f15-86c9-cd6090362e82","name":"AnalyticsDashboard"},{"uId":"1eefea8c-efe3-53d9-6397-3aac9cc9e785","name":"Approval"},... + ``` + + +### app restart + +Restarts the Creatio application. + +Important: If your Creatio instance is running on .NET Framework (IIS), you must use the --net-framework flag with the app command. Otherwise, the restart will not be executed, and you won't receive an error. + +**Examples:** + +- `crtcli app https://localhost:5000 Supervisor Supervisor -i restart` — Restarts Creatio application at insecure 'https://localhost:5000'. + +- `crtcli app restart` — Restarts Creatio application '$CRTCLI_APP_URL'. + + +### app request + +Sends authenticated HTTP requests to the Creatio instance, similar to curl. + +**Arguments:** + +- `` (required) — HTTP method (e.g., GET, POST, PUT, DELETE, etc.). + +- `` (required) — URL to request (can be absolute or relative to the Creatio base URL). + +**Options:** + +- `--anonymous | -a` — Send the request without authentication. + +- `--data | -d ` — Request body data (for methods like POST). + +- `--data-stdin | -D` — Read the request body data from standard input. Use a double Enter to signal the end of input. + +- `--header | -H
` — Add a custom header to the request (format: Key: Value). The default Content-Type is application/json. + +- `--output | -o ` — Save the response body to a file. + +**Examples:** + +- `crtcli app https://localhost:5000 Supervisor Supervisor -i request GET 0/rest/UsrService/UsrMethod` — Sends an authenticated GET request to 'https://localhost:5000/0/rest/UsrService/UsrMethod' at insecure Creatio. + +- `crtcli app request POST 0/rest/UsrService/UsrPostMethod -d '{"request": "test"}'` — Sends an authenticated POST request to '0/rest/UsrService/UsrPostMethod' to Creatio '$CRTCLI_APP_URL' with body '{"request": "test"}'. + +- `crtcli app request POST 0/rest/UsrService/UsrPostMethod -D` — Sends an authenticated POST request to '0/rest/UsrService/UsrPostMethod' to Creatio '$CRTCLI_APP_URL' with body read from stdin. + + stdin & stdout: + ```shell + Please enter request data (body) below: + -=-=- -=-=- -=-=- -=-=- -=-=- + + {"request":"test"} + + -=-=- -=-=- -=-=- -=-=- -=-=- + + Status: 404 Not Found + Content: 0 bytes read + ``` + +- `crtcli app request GET 0/ServiceModel/PublicService.svc/UsrPubMethod -a -H "X-Access-Token: 123"` — Sends an anonymous GET request to '0/ServiceModel/PublicService.svc/UsrPubMethod' to Creatio '$CRTCLI_APP_URL' with custom header 'X-Access-Token: 123'. + + +### app sql + +Executes SQL queries in the Creatio database using a supported SQL runner package installed in Creatio. + +_Beta: this command is still under development._ + +**Supported SQL packages:** + +- [cliogate](https://raw.githubusercontent.com/Advance-Technologies-Foundation/clio/refs/heads/master/cliogate.gz) +- SqlConsole + +**Arguments:** + +- `[SQL]` — SQL query to execute. + + Defaults: If omitted and the --file option is not used, crtcli will prompt you to enter the query from standard input (use a double Enter to finish). + +**Options:** + +- `--file | -f ` — Read the SQL query from a file. + +- `--runner | -r ` — Specify the SQL runner to use. + + Possible values: cliogate, sql-console + + Defaults: Autodetect + +- `--json` — Display the results in JSON format. + +**Examples:** + +- `crtcli app https://localhost:5000 Supervisor Supervisor -i sql 'SELECT COUNT(*) FROM "SysPackage"'` — Executes SQL query 'SELECT COUNT(*) FROM "SysPackage"' at insecure Creatio 'https://localhost:5000' with automatically detected sql runner. + + stdout: + ```json + [ + { + "count": 359 + } + ] + ``` + +- `crtcli app sql'` — Executes SQL query from stdin in Creatio '$CRTCLI_APP_URL' with automatically detected sql runner. + + stdin & stdout: + ```shell + Please enter SQL query below: + -=-=- -=-=- -=-=- -=-=- -=-=- + + select count(*) from "Contact" + + -=-=- -=-=- -=-=- -=-=- -=-=- + + [ + { + "count": 18 + } + ] + ``` + +- `crtcli app sql -r sql-console -f query.sql'` — Executes SQL query from file 'query.sql' in Creatio '$CRTCLI_APP_URL' with sql-console runner. + + +### pkg + +Commands for working with Creatio package files (.zip, .gz) or package folders locally, without interacting with a Creatio instance. + + +### pkg apply + +Applies transformations to the contents of a package folder. + +This is useful for standardizing package structure, cleaning up localization files, and improving version control diffs, etc. + +**Arguments:** + +- `` (required) — Path to the package folder. + +**Options:** + +- `--file | -f ` — Apply transforms only to a specific file within the package folder. The path should be relative to the package folder or absolute (but still within the package folder). Useful for pre-commit git hooks. + +**Transforms (also options):** + +- `--apply-sorting | -S ` — Sorts the contents of specific files within the package. This is useful when you work with any VCS cause it prevents you from check some inconsistent diffs. + + _Affects:_ + - descriptor.json + - Data/**/*.json + - Data/**/Localization/*.json + - Files/*.csproj + +- `--apply-localization-cleanup | -L ` — Removes localization files except for the specified cultures (comma-separated list). + + _Affects_: + - Data/**/Localization/data.*.json + - Resources/**/resource.*.xml + +**Examples:** + +- `crtcli pkg apply . --apply-sorting true` — Applies sorting transform to the current package folder. + +- `crtcli pkg apply /Creatio_8.1.5.2176/Terrasoft.Configuration/Pkg/UsrPackage -S true -L 'en-US,uk-UA'` — Applies sorting and localization cleanup transforms to package '/Creatio_8.1.5.2176/Terrasoft.Configuration/Pkg/UsrPackage'. Localization cleanup deletes all localization files in this folder except for 'en-US' and 'uk-UA' cultures. + + +### pkg pack + +Creates a package archive (.zip or .gz) from a package folder. + +Included Paths: + +- `Assemblies/*` +- `Data/*` +- `Files/*` +- `Resources/*` +- `Schemas/*` +- `SqlScripts/*` +- `descriptor.json` + +Excluded: Hidden folders and files (names starting with .). + +**Arguments:** + +- `` (required) — Source folder containing the package files to be packaged. + +**Options:** + +- `--output-folder | -f ` — Destination folder where the output package archive will be saved. + + Defaults: Current Directory + +- `--output-filename | -n ` — Filename of the output package archive file. + + Defaults: Autogenerated — `PackageName_YYYY-MM-DD_HH-mm-ss.zip` for zip format and `PackageName.gz` for gzip. + +- `--format ` — Archive format. + + Possible values: gzip, zip + + Defaults: zip + +- `--compression ` — Compression level (fast, normal, best) + + Possible values: fast, normal, best + + Defaults: fast + +**Examples:** + +For example current folder is '/Creatio_8.1.5.2176/Terrasoft.Configuration/Pkg/UsrPackage' which is package folder. + +- `crtcli pkg pack .` — Packs current folder as package and outputs package file 'UsrPackage_2024-12-01_21-00-00.zip' to current directory. + +- `crtcli pkg pack /Creatio_8.1.5.2176/Terrasoft.Configuration/Pkg/UsrPackage2 --format gzip --compression best` — Packs folder '/Creatio_8.1.5.2176/Terrasoft.Configuration/Pkg/UsrPackage2' as package and outputs package file 'UsrPackage2.gz' to current directory. + +- `crtcli pkg pack . -f /backups/` — Packs current folder as package and outputs package file 'UsrPackage_2024-12-01_21-00-00.zip' to '/backups/' folder. + +- `crtcli pkg pack . -f /backups/ -n 'UsrPackage-latest.zip'` — Packs current folder as package and outputs package file 'UsrPackage-latest.zip' to '/backups/' folder. If file already exists — it will be replaced. + + +### pkg unpack + +Extract a single package from a package archive (.zip or .gz). To extract multiple packages from a zip archive, use [pkg unpack-all](#pkg-unpack-all). + +**Arguments:** + +- `` (required) — Path to the package archive file. + +- `[DESTINATION_FOLDER]` — Destination folder where the extracted package files will be saved. + + Defaults: '{Filename without extension}' folder in current folder. If this folder already exists — creates a new one with suffix '_1' and so on. + +**Options:** + +- `--package | -p ` — If the archive is a zip file containing multiple packages, specify the name of the package to extract. + +- `--merge | -m` — If destination_folder already exists you will receive error about this by default. However, you can use this merge option to extract to same exist folder with overwriting only different files. + +And here you can use transforms from [pkg apply](#pkg-apply) command. + +**Examples:** + +- `crtcli pkg unpack UsrPackage_2024-12-01_21-00-00.zip` — Extracts single package from 'UsrPackage_2024-12-01_21-00-00.zip' file to folder './UsrPackage_2024-12-01_21-00-00/' + +- `crtcli pkg unpack UsrPackage.gz /repos/UsrPackage -mS true` — Extracts single package from 'UsrPackage.gz' file to folder './repos/UsrPackage' with merging and sorting transform. + +- `crtcli pkg unpack UsrMultiplePackages_2024-12-01_21-00-00.zip UsrPackageSources -p UsrPackage` — Extracts single package 'UsrPackage' (file UsrPackage.gz) from 'UsrMultiplePackages_2024-12-01_21-00-00.zip' file to folder './UsrPackageSources/'. + + +### pkg unpack-all + +Extract all packages from a zip archive. + +**Arguments:** + +- `` (required) — Path to the zip package archive file. + +- `[DESTINATION_FOLDER]` — Destination folder where all extracted package files will be saved. + + Defaults: '{Filename without extension}' folder in current folder. If this folder already exists — creates a new one with suffix '_1' and so on. + +**Options:** + +- `--merge | -m` — If destination_folder already exists you will receive error about this by default. However, you can use this merge option to extract to same exist folder with overwriting only different files. + +And here you can use transforms from [pkg apply](#pkg-apply) command. + +**Examples:** + +For example, file 'MyPackage.zip' contains one 'UsrPackage' package, and file 'MyMultiplePackages.zip' contains 'UsrPackage1' package and 'UsrPackage2' package. + +- `crtcli pkg unpack-all MyPackage.zip` — Extracts packages from 'MyPackage.zip' file to folder './MyPackage/'. + + The output folder structure will be: + - MyPackage/ + - UsrPackage/ + - ... + +- `crtcli pkg unpack-all MyMultiplePackages.zip /repos/ -mL 'en-US'` — Extracts packages from 'MyMultiplePackages.zip' file to folder '/repos/' with merging and localization cleanup transform except 'en-US' culture. + + The output folder structure will be: + - /repos/ + - UsrPackage1/ + - ... + - UsrPackage2/ + - ... + + +## Config files + + +### dotenv (.env) files + +crtcli supports .env files for storing environment variables, simplifying command usage by avoiding repetitive argument entry. + +Locations: '.env', '.crtcli.env' in current directory or any parent folders. + +**Variables:** + +- `CRTCLI_APP_URL` — The base URL of Creatio instance by default. + +- `CRTCLI_APP_USERNAME` — Creatio username by default. + +- `CRTCLI_APP_PASSWORD` — Creatio password by default. + +- `CRTCLI_APP_INSECURE` — Set to 'true' to disable SSL certificate validation by default. + +- `CRTCLI_APP_NETFRAMEWORK` — Set to 'true' if your Creatio instance is running on .NET Framework (IIS) by default. + +**Examples:** + +For example, current folder is '/Creatio_8.1.5.2176/Terrasoft.Configuration/Pkg/UsrPackage' which is package folder inside in Creatio. + +You could have a .env file at /Creatio_8.1.5.2176/.env with the following content: + +``` +CRTCLI_APP_URL="https://localhost:88" +CRTCLI_APP_USERNAME="Supervisor" +CRTCLI_APP_PASSWORD="Supervisor@1" +CRTCLI_APP_INSECURE="true" +``` + +Now, from within /Creatio_8.1.5.2176/Terrasoft.Configuration/Pkg/UsrPackage or any of its parent directories, you can run commands like: + +- `crtcli app pkgs` — This will list the packages from https://localhost:88 because $CRTCLI_APP_URL is defined in the .env file. + +- `crtcli app ...` — Any other app command will similarly use the environment variables from the .env file. + + +### package.crtcli.toml + +The package.crtcli.toml file is an optional configuration file that allows you to customize crtcli's behavior for a specific package. + +Location: ./package.crtcli.toml within the package folder. + +Check [toml syntax here](https://toml.io/en/v1.0.0). + +**Parameters:** + +- `apply.sorting = ` — Enable/disable sorting transform by default in [pkg apply](#pkg-apply) command. + +- `apply.localization_cleanup = ` — Enable/disable localization cleanup transform by default in [pkg apply](#pkg-apply) command. + +**Examples:** + +1. For example, current folder is '/Creatio_8.1.5.2176/Terrasoft.Configuration/Pkg/UsrPackage' which is package folder inside in Creatio. + + You could have a package.crtcli.toml file in this directory with the following content: + + ```toml + [apply] + sorting = true + localization_cleanup = ["en-US", "uk-UA"] + ``` + + The package folder structure would look like: + - /Creatio_8.1.5.2176/Terrasoft.Configuration/Pkg/UsrPackage + - Data/* + - Resources/* + - Schemas/* + - ... + - descriptor.json + - package.crtcli.toml + + With this configuration: + + - `crtcli pkg apply .` — Will apply both sorting and localization cleanup (keeping only en-US and uk-UA cultures) because they are enabled in package.crtcli.toml. + + - `crtcli app pkg pull` — Will download UsrPackage, unpack it, and apply the sorting and localization cleanup transforms defined in package.crtcli.toml. + + - `crtcli app pkg fs pull` — Will download UsrPackage to the file system and apply the sorting and localization cleanup transforms defined in package.crtcli.toml. + +--- + +Stay tuned! diff --git a/src/app/app_installer.rs b/src/app/app_installer.rs new file mode 100644 index 0000000..d05a589 --- /dev/null +++ b/src/app/app_installer.rs @@ -0,0 +1,244 @@ +use crate::app::client::{CrtClient, CrtClientGenericError}; +use crate::app::{CrtRequestBuilderReauthorize, StandardServiceError, StandardServiceResponse}; +use reqwest::Method; +use serde::Deserialize; +use serde_json::json; + +pub struct AppInstallerService<'c>(&'c CrtClient); + +impl<'c> AppInstallerService<'c> { + pub fn new(client: &'c CrtClient) -> Self { + Self(client) + } + + pub fn restart_app(&self) -> Result<(), CrtClientGenericError> { + let response = self + .0 + .request( + Method::POST, + match self.0.is_net_framework() { + true => "0/ServiceModel/AppInstallerService.svc/UnloadAppDomain", + false => "0/ServiceModel/AppInstallerService.svc/RestartApp", + }, + ) + .header(reqwest::header::CONTENT_LENGTH, "0") + .send_with_session(self.0)? + .error_for_status()?; + + response.json::()?.into_result()?; + + Ok(()) + } + + pub fn clear_redis_db(&self) -> Result<(), CrtClientGenericError> { + let response = self + .0 + .request( + Method::POST, + "0/ServiceModel/AppInstallerService.svc/ClearRedisDb", + ) + .header(reqwest::header::CONTENT_LENGTH, "0") + .send_with_session(self.0)? + .error_for_status()?; + + response.json::()?.into_result()?; + + Ok(()) + } + + #[allow(dead_code)] + pub fn install_app_from_file( + &self, + code: &str, + name: &str, + package_filename: &str, + ) -> Result<(), CrtClientGenericError> { + let response = self + .0 + .request( + Method::POST, + "0/ServiceModel/AppInstallerService.svc/InstallAppFromFile", + ) + .json(&json!({ + "Code": code, + "Name": name, + "ZipPackageName": package_filename + })) + .header(reqwest::header::CONTENT_LENGTH, "0") + .send_with_session(self.0)? + .error_for_status()?; + + response.json::()?.into_result()?; + + Ok(()) + } + + pub fn load_packages_to_db( + &self, + package_names: Option<&[&str]>, + ) -> Result { + let response = self + .0 + .request( + Method::POST, + "0/ServiceModel/AppInstallerService.svc/LoadPackagesToDB", + ) + .json(&json!(package_names)) + .send_with_session(self.0)? + .error_for_status()?; + + Ok(response + .json::()? + .into_result()?) + } + + pub fn load_packages_to_fs( + &self, + package_names: Option<&[&str]>, + ) -> Result { + let response = self + .0 + .request( + Method::POST, + "0/ServiceModel/AppInstallerService.svc/LoadPackagesToFileSystem", + ) + .json(&json!(package_names)) + .send_with_session(self.0)? + .error_for_status()?; + + Ok(response + .json::()? + .into_result()?) + } +} + +#[derive(Debug, Deserialize)] +pub struct FileSystemSynchronizationResultResponse { + pub changes: Vec, + + pub errors: Vec, + + #[serde(flatten)] + pub base: StandardServiceResponse, +} + +impl FileSystemSynchronizationResultResponse { + pub fn into_result( + self, + ) -> Result { + if !self.base.success { + if let Some(err) = self.base.error_info { + return Err(err); + } + } + + Ok(self) + } +} + +#[derive(Debug, Deserialize)] +pub struct FileSystemSynchronizationWorkspaceItem { + pub name: String, + + pub state: FileSystemSynchronizationObjectState, + + #[serde(rename = "type")] + pub object_type: FileSystemSynchronizationObjectType, + + #[allow(dead_code)] + #[serde(rename = "uId")] + pub uid: String, + + #[serde(rename = "cultureName")] + pub culture_name: Option, +} + +#[derive(Debug, Deserialize)] +pub struct FileSystemSynchronizationPackage { + #[serde(flatten)] + pub workspace_item: FileSystemSynchronizationWorkspaceItem, + + pub items: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct FileSystemSynchronizationError { + #[serde(rename = "workspaceItem")] + pub workspace_item: FileSystemSynchronizationWorkspaceItem, + + #[serde(rename = "errorInfo")] + pub error_info: StandardServiceError, +} + +#[derive(Debug)] +pub enum FileSystemSynchronizationObjectState { + NotChanged = 0, + New = 1, + Changed = 2, + Deleted = 3, + Reverted = 4, + Conflicted = 5, +} + +#[derive(Debug)] +pub enum FileSystemSynchronizationObjectType { + Package = 0, + Schema = 1, + Assembly = 2, + SqlScript = 3, + SchemaData = 4, + CoreResource = 5, + SchemaResource = 6, + FileContent = 7, +} + +impl FileSystemSynchronizationObjectType { + pub fn get_fs_order_index(&self) -> i8 { + match self { + FileSystemSynchronizationObjectType::Package => 0, + FileSystemSynchronizationObjectType::CoreResource => 1, + FileSystemSynchronizationObjectType::Assembly => 2, + FileSystemSynchronizationObjectType::SchemaData => 3, + FileSystemSynchronizationObjectType::FileContent => 4, + FileSystemSynchronizationObjectType::SchemaResource => 5, + FileSystemSynchronizationObjectType::Schema => 6, + FileSystemSynchronizationObjectType::SqlScript => 7, + } + } +} + +impl<'de> Deserialize<'de> for FileSystemSynchronizationObjectState { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + match i8::deserialize(deserializer)? { + 0 => Ok(FileSystemSynchronizationObjectState::NotChanged), + 1 => Ok(FileSystemSynchronizationObjectState::New), + 2 => Ok(FileSystemSynchronizationObjectState::Changed), + 3 => Ok(FileSystemSynchronizationObjectState::Deleted), + 4 => Ok(FileSystemSynchronizationObjectState::Reverted), + 5 => Ok(FileSystemSynchronizationObjectState::Conflicted), + _ => Err(serde::de::Error::custom("Expected 0-5 for state")), + } + } +} + +impl<'de> Deserialize<'de> for FileSystemSynchronizationObjectType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + match i8::deserialize(deserializer)? { + 0 => Ok(FileSystemSynchronizationObjectType::Package), + 1 => Ok(FileSystemSynchronizationObjectType::Schema), + 2 => Ok(FileSystemSynchronizationObjectType::Assembly), + 3 => Ok(FileSystemSynchronizationObjectType::SqlScript), + 4 => Ok(FileSystemSynchronizationObjectType::SchemaData), + 5 => Ok(FileSystemSynchronizationObjectType::CoreResource), + 6 => Ok(FileSystemSynchronizationObjectType::SchemaResource), + 7 => Ok(FileSystemSynchronizationObjectType::FileContent), + _ => Err(serde::de::Error::custom("Expected 0-7 for type")), + } + } +} diff --git a/src/app/auth.rs b/src/app/auth.rs new file mode 100644 index 0000000..729dbe9 --- /dev/null +++ b/src/app/auth.rs @@ -0,0 +1,106 @@ +use crate::app::client::CrtClient; +use crate::app::session::CrtSession; +use crate::app::utils::{collect_set_cookies, find_cookie_by_name, CookieParsingError}; +use crate::app::CrtClientGenericError; +use reqwest::Method; +use serde::Deserialize; +use serde_json::json; +use thiserror::Error; + +pub struct AuthService<'c>(&'c CrtClient); + +impl<'c> AuthService<'c> { + pub fn new(client: &'c CrtClient) -> Self { + Self(client) + } + + pub fn login(&self, username: &str, password: &str) -> Result { + let mut response = self + .0 + .request(Method::POST, "ServiceModel/AuthService.svc/Login") + .header("ForceUseSession", "true") + .json(&json!({ + "UserName": username, + "UserPassword": password + })) + .send()? + .error_for_status()?; + + read_login_response(&mut response)?.into_result()?; + + let set_cookies = collect_set_cookies(&response)?; + + let aspxauth = find_cookie_by_name(&set_cookies, ".ASPXAUTH") + .ok_or_else(|| LoginError::CookieNotFound(".ASPXAUTH"))?; + + let bpmcrsf = find_cookie_by_name(&set_cookies, "BPMCSRF") + .ok_or_else(|| LoginError::CookieNotFound("BPMCSRF"))?; + + let csrftoken = find_cookie_by_name(&set_cookies, "CsrfToken"); + + return Ok(CrtSession::new(aspxauth, bpmcrsf, csrftoken, None)); + + fn read_login_response( + response: &mut impl std::io::Read, + ) -> Result { + let mut body = vec![]; + + response.read_to_end(&mut body)?; + + serde_json::from_slice(&body) + .map_err(|err| LoginError::ResponseParse { body, source: err }) + } + } +} + +#[derive(Error, Debug)] +pub enum LoginError { + #[error("{0}")] + Request(#[from] CrtClientGenericError), + + #[error("response read error: {0}")] + ResponseRead(#[from] std::io::Error), + + #[error("response parse error: {source}")] + ResponseParse { + body: Vec, + + #[source] + source: serde_json::Error, + }, + + #[error("login response error: {0}")] + ResponseError(#[from] LoginResponse), + + #[error("{0}")] + CookieParsingFailed(#[from] CookieParsingError), + + #[error("expected cookie {0} in response not found!")] + CookieNotFound(&'static str), +} + +impl From for LoginError { + fn from(value: reqwest::Error) -> Self { + LoginError::Request(CrtClientGenericError::from(value)) + } +} + +#[derive(Debug, Deserialize, Error)] +#[error("{message} (code: {code})")] +pub struct LoginResponse { + #[serde(rename = "Code")] + code: i32, + + #[serde(rename = "Message")] + message: String, +} + +impl LoginResponse { + pub fn into_result(self) -> Result<(), LoginResponse> { + if self.code == 0 { + Ok(()) + } else { + Err(self) + } + } +} diff --git a/src/app/client.rs b/src/app/client.rs new file mode 100644 index 0000000..5588dc3 --- /dev/null +++ b/src/app/client.rs @@ -0,0 +1,395 @@ +use crate::app::app_installer::AppInstallerService; +use crate::app::auth::AuthService; +use crate::app::cookie_cache::{get_cookie_cache_entry, set_cookie_cache_entry}; +use crate::app::credentials::CrtCredentials; +use crate::app::package::PackageService; +use crate::app::package_installer::PackageInstallerService; +use crate::app::session::CrtSession; +use crate::app::utils::iter_set_cookies; +use crate::app::workspace_explorer::WorkspaceExplorerService; +use crate::app::{auth, sql}; +use reqwest::blocking::{RequestBuilder, Response}; +use serde::{Deserialize, Serialize}; +use std::error::Error; +use std::fmt::{Display, Formatter}; +use std::sync::RwLock; +use thiserror::Error; + +const CRTCLI_CLIENT_USER_AGENT: &str = + concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")); + +#[derive(Debug, Default)] +pub struct CrtClientFlags { + net_framework: bool, +} + +pub struct CrtClientBuilder { + credentials: CrtCredentials, + flags: CrtClientFlags, + session: Option, + inner_client_builder: reqwest::blocking::ClientBuilder, +} + +impl CrtClientBuilder { + pub fn new(credentials: CrtCredentials) -> Self { + Self { + credentials, + flags: Default::default(), + session: None, + inner_client_builder: reqwest::blocking::ClientBuilder::new() + .user_agent(CRTCLI_CLIENT_USER_AGENT) + .timeout(std::time::Duration::from_secs(1800)) + .redirect(reqwest::redirect::Policy::custom( + CrtClientBuilder::custom_redirect_policy, + )), + } + } + + fn custom_redirect_policy(attempt: reqwest::redirect::Attempt) -> reqwest::redirect::Action { + let previous = attempt.previous(); + + if previous.len() > 10 { + let is_same_url_all_times = previous.iter().skip(1).all(|x| x == attempt.url()); + + if is_same_url_all_times { + attempt.error(CrtClientRedirectError::Unauthorized) + } else { + attempt.error("too many redirects") + } + } else if (attempt.url().path() == "/Login/Login.html" + || attempt.url().path() == "/Login/NuiLogin.aspx") + && attempt.url().query_pairs().any(|(k, _)| k == "ReturnUrl") + { + attempt.error(CrtClientRedirectError::Unauthorized) + } else { + attempt.follow() + } + } + + pub fn use_net_framework_mode(mut self, value: bool) -> Self { + self.flags.net_framework = value; + self + } + + pub fn danger_accept_invalid_certs(mut self, value: bool) -> Self { + self.inner_client_builder = self.inner_client_builder.danger_accept_invalid_certs(value); + self + } + + pub fn build(self) -> Result { + Ok(CrtClient { + credentials: self.credentials, + flags: self.flags, + inner_client: self.inner_client_builder.build()?, + session: RwLock::new(self.session), + sql_runner: RwLock::new(None), + db_type: RwLock::new(None), + }) + } +} + +pub struct CrtClient { + credentials: CrtCredentials, + flags: CrtClientFlags, + inner_client: reqwest::blocking::Client, + session: RwLock>, + sql_runner: RwLock>>, + db_type: RwLock>, +} + +#[derive(Debug, Copy, Clone, Eq, PartialEq, Error)] +enum CrtClientRedirectError { + #[error("unauthorized")] + Unauthorized, +} + +#[derive(Debug, Eq, PartialEq, Clone, Copy)] +pub enum CrtDbType { + MsSql, + Oracle, + Postgres, +} + +impl CrtClient { + pub fn builder(credentials: CrtCredentials) -> CrtClientBuilder { + CrtClientBuilder::new(credentials) + } + + pub fn base_url(&self) -> &str { + self.credentials.url() + } + + pub fn is_net_framework(&self) -> bool { + self.flags.net_framework + } + + pub fn request(&self, method: reqwest::Method, relative_url: &str) -> RequestBuilder { + self.inner_client + .request(method, format!("{}/{}", self.base_url(), relative_url)) + } + + pub fn auth_service(&self) -> AuthService<'_> { + AuthService::new(self) + } + + pub fn app_installer_service(&self) -> AppInstallerService<'_> { + AppInstallerService::new(self) + } + + pub fn workspace_explorer_service(&self) -> WorkspaceExplorerService<'_> { + WorkspaceExplorerService::new(self) + } + + pub fn package_service(&self) -> PackageService<'_> { + PackageService::new(self) + } + + pub fn package_installer_service(&self) -> PackageInstallerService<'_> { + PackageInstallerService::new(self) + } + + pub fn sql_scripts(&self) -> sql::SqlScripts<'_> { + sql::SqlScripts::new(self) + } + + pub fn db_type(&self) -> Result { + if self.db_type.read().unwrap().is_none() { + let db_type = sql::detect_db_type(self)?; + + self.db_type.write().unwrap().replace(db_type); + + return Ok(db_type); + } + + Ok(*self.db_type.read().unwrap().as_ref().unwrap()) + } + + pub fn sql(&self, sql: &str) -> Result { + if self.sql_runner.read().unwrap().is_none() { + let (executor, result) = sql::AutodetectSqlRunner::detect_and_run_sql(self, sql) + .ok_or(sql::SqlRunnerError::NotFound)?; + + self.sql_runner.write().unwrap().replace(executor); + + return Ok(result?); + } + + Ok(self + .sql_runner + .read() + .unwrap() + .as_ref() + .unwrap() + .sql(self, sql)?) + } +} + +pub trait CrtRequestBuilderReauthorize { + fn send_with_session(self, client: &CrtClient) -> Result; +} + +impl CrtRequestBuilderReauthorize for RequestBuilder { + fn send_with_session(self, client: &CrtClient) -> Result { + if client.session.read().unwrap().is_none() { + setup_session_with_cache(client)? + } + + let response = self + .header( + "Cookie", + client + .session + .read() + .unwrap() + .as_ref() + .unwrap() + .to_cookie_value(), + ) + .header( + "BPMCSRF", + client.session.read().unwrap().as_ref().unwrap().bpmcsrf(), + ) + .send(); + + return match response { + Ok(response) => { + try_enrich_bpmsessionid_from_response(client, &response); + + Ok(response) + } + Err(err) + if err.status() == Some(reqwest::StatusCode::UNAUTHORIZED) + || err.is_redirect() + && err.source().is_some_and(|x| { + x.downcast_ref::() + .is_some_and(|x| matches!(x, CrtClientRedirectError::Unauthorized)) + }) => + { + login_and_store(client)?; + + Err(CrtClientGenericError::Unauthorized) + } + Err(err) => return Err(CrtClientGenericError::ReqwestError(err)), + }; + + fn setup_session_with_cache(client: &CrtClient) -> Result<(), CrtClientGenericError> { + match get_cookie_cache_entry(&client.credentials) { + Some(new_session) => { + client.session.write().unwrap().replace(new_session); + + Ok(()) + } + None => login_and_store(client), + } + } + + fn try_enrich_bpmsessionid_from_response(client: &CrtClient, response: &Response) { + let bmpsessionid = iter_set_cookies(response) + .find(|x| x.as_ref().is_ok_and(|x| x.0 == "BPMSESSIONID")); + + if let Some(bmpsessionid) = bmpsessionid { + let bmpsessionid = bmpsessionid.unwrap(); + let mut session = client.session.read().unwrap().as_ref().unwrap().to_owned(); + + if session.bpmsessionid() != Some(bmpsessionid.1) { + session.set_bpmsessionid(Some(bmpsessionid.1.to_owned())); + + set_and_save_to_cache_session(client, session) + } + } + } + + fn login_and_store(client: &CrtClient) -> Result<(), CrtClientGenericError> { + let new_session = client + .auth_service() + .login(client.credentials.username(), client.credentials.password()) + .map_err(|err| CrtClientGenericError::LoginFailed(Box::new(err)))?; + + set_and_save_to_cache_session(client, new_session); + + Ok(()) + } + + fn set_and_save_to_cache_session(client: &CrtClient, new_session: CrtSession) { + set_cookie_cache_entry(&client.credentials, new_session.clone()); + + client.session.write().unwrap().replace(new_session); + } + + // fn verify_session_valid(client: &CrtClient, session: &CrtSession) -> bool { + // let response = client + // .request(reqwest::Method::HEAD, "0/DataService/json/SyncReply/PostClientLog") + // .header("Cookie", session.to_cookie_value()) + // .header("BPMCSRF", session.bpmcsrf()) + // .body(r#"{"LogItems":[]}"#) + // .send(); + // + // match response { + // Ok(r) if { r.status() == reqwest::StatusCode::METHOD_NOT_ALLOWED } => true, + // _ => false, + // } + // } + } +} + +#[derive(Debug, Error)] +pub enum CrtClientGenericError { + #[error("request error: {0}")] + ReqwestError(#[source] reqwest::Error), + + #[error("login failed: {0}")] + LoginFailed(#[from] Box), + + #[error("request connection error: {inner_message}")] + ConnectionFailed { + #[source] + source: reqwest::Error, + + inner_message: String, + }, + + #[error("unauthorized, please try send request again")] + Unauthorized, + + #[error("service returned error: {0}")] + ServiceReturnedErrorInfo(#[from] StandardServiceError), + + #[error("sql runner error: {0}")] + SqlRunner(#[from] Box), + // #[error("failed to access the cache: {0}")] + // AccessCache(#[from] AccessCacheError) +} + +impl From for CrtClientGenericError { + fn from(value: auth::LoginError) -> Self { + CrtClientGenericError::LoginFailed(Box::new(value)) + } +} + +impl From for CrtClientGenericError { + fn from(value: sql::SqlRunnerError) -> Self { + CrtClientGenericError::SqlRunner(Box::new(value)) + } +} + +impl From for CrtClientGenericError { + fn from(value: reqwest::Error) -> Self { + if value.is_request() { + if let Some(source) = value.source() { + if let Some(hyper_error) = + source.downcast_ref::() + { + if hyper_error.is_connect() { + if let Some(inner_error) = hyper_error.source() { + return CrtClientGenericError::ConnectionFailed { + inner_message: inner_error.to_string(), + source: value, + }; + } + } + } + } + } + + CrtClientGenericError::ReqwestError(value) + } +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct StandardServiceResponse { + pub success: bool, + + #[serde(rename = "errorInfo")] + pub error_info: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct StandardServiceError { + pub message: String, + + #[serde(rename = "errorCode")] + pub error_code: String, + + #[serde(rename = "stackTrace")] + pub stack_trace: Option, +} + +impl Display for StandardServiceError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}: {}", self.error_code, self.message) + } +} + +impl std::error::Error for StandardServiceError {} + +impl StandardServiceResponse { + pub fn into_result(self) -> Result<(), StandardServiceError> { + if !self.success { + if let Some(err) = self.error_info { + return Err(err); + } + } + + Ok(()) + } +} diff --git a/src/app/cookie_cache.rs b/src/app/cookie_cache.rs new file mode 100644 index 0000000..863dd51 --- /dev/null +++ b/src/app/cookie_cache.rs @@ -0,0 +1,77 @@ +use crate::app::{CrtCredentials, CrtSession}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::env::temp_dir; +use std::fs::File; +use std::hash::{DefaultHasher, Hash, Hasher}; +use std::path::PathBuf; +use time::OffsetDateTime; + +#[derive(Debug, Deserialize, Serialize)] +struct CookieEntry { + created_timestamp: i64, + value: CrtSession, +} + +fn get_cache_filepath() -> PathBuf { + temp_dir().join("crtcli_cookie_cache.bin") +} + +fn hash_credentials(credentials: &CrtCredentials) -> u64 { + let mut hasher = DefaultHasher::new(); + credentials.hash(&mut hasher); + hasher.finish() +} + +fn get_cookie_cache() -> Option> { + let file = match File::open(get_cache_filepath()) { + Err(_) => return None, + Ok(file) => file, + }; + + let cache: HashMap = match bincode::deserialize_from(file) { + Err(_) => return None, + Ok(cache) => cache, + }; + + Some(cache) +} + +pub fn get_cookie_cache_entry(credentials: &CrtCredentials) -> Option { + let mut cache = match get_cookie_cache() { + Some(cache) => cache, + None => return None, + }; + + let hash = hash_credentials(credentials); + let past_hour_timestamp = + (OffsetDateTime::now_utc() - time::Duration::hours(1)).unix_timestamp(); + + cache + .remove(&hash) + .filter(|x| x.created_timestamp > past_hour_timestamp) + .map(|x| x.value) +} + +pub fn set_cookie_cache_entry(credentials: &CrtCredentials, session: CrtSession) { + let cache = get_cookie_cache().unwrap_or_default(); + let hash = hash_credentials(credentials); + let past_hour_timestamp = + (OffsetDateTime::now_utc() - time::Duration::hours(1)).unix_timestamp(); + + let mut cache: HashMap = HashMap::from_iter( + cache + .into_iter() + .filter(|(_, e)| e.created_timestamp > past_hour_timestamp), + ); + + cache.insert( + hash, + CookieEntry { + created_timestamp: OffsetDateTime::now_utc().unix_timestamp(), + value: session, + }, + ); + + let _ = File::create(get_cache_filepath()).map(|file| bincode::serialize_into(file, &cache)); +} diff --git a/src/app/credentials.rs b/src/app/credentials.rs new file mode 100644 index 0000000..620e3f9 --- /dev/null +++ b/src/app/credentials.rs @@ -0,0 +1,30 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Ord, PartialOrd, Eq, PartialEq, Serialize, Deserialize, Hash)] +pub struct CrtCredentials { + url: String, + username: String, + password: String, +} + +impl CrtCredentials { + pub fn new(url: &str, username: &str, password: &str) -> CrtCredentials { + CrtCredentials { + url: url.trim_end_matches("/").to_owned(), + username: username.to_owned(), + password: password.to_owned(), + } + } + + pub fn url(&self) -> &str { + &self.url + } + + pub fn username(&self) -> &str { + &self.username + } + + pub fn password(&self) -> &str { + &self.password + } +} diff --git a/src/app/install_log_watcher.rs b/src/app/install_log_watcher.rs new file mode 100644 index 0000000..b174a6c --- /dev/null +++ b/src/app/install_log_watcher.rs @@ -0,0 +1,233 @@ +use crate::app::{CrtClient, CrtClientGenericError}; +use std::sync::mpsc::{channel, Receiver, Sender}; +use std::sync::{Arc, Condvar, Mutex}; +use std::thread::{spawn, JoinHandle}; + +pub struct InstallLogWatcher { + crt_client: Arc, + handler: Option)>, + + pooling_delay: std::time::Duration, + wait_for_clear_on_start: bool, + fetch_last_log_on_stop: bool, +} + +pub struct InstallLogWatcherHandle { + cancellation_sender: Sender<()>, + iteration_cvar: Arc<(Mutex, Condvar)>, + _worker: JoinHandle<()>, +} + +struct WorkerContext { + crt_client: Arc, + handler: Option)>, + cancellation_receiver: Receiver<()>, + + pooling_delay: std::time::Duration, + wait_for_clear_on_start: bool, + fetch_last_log_on_stop: bool, + iteration_cvar: Arc<(Mutex, Condvar)>, +} + +#[derive(Debug, Clone)] +pub enum InstallLogWatcherEvent<'c> { + Clear(), + Append(&'c str), +} + +impl InstallLogWatcher { + pub fn new(crt_client: Arc) -> Self { + Self { + crt_client, + pooling_delay: std::time::Duration::from_millis(1000), + wait_for_clear_on_start: false, + fetch_last_log_on_stop: false, + handler: None, + } + } + + #[allow(dead_code)] + pub fn pooling_delay(mut self, value: std::time::Duration) -> Self { + self.pooling_delay = value; + self + } + + #[allow(dead_code)] + pub fn wait_for_clear_on_start(mut self, value: bool) -> Self { + self.wait_for_clear_on_start = value; + self + } + + pub fn fetch_last_log_on_stop(mut self, value: bool) -> Self { + self.fetch_last_log_on_stop = value; + self + } + + pub fn with_handler(mut self, value: fn(InstallLogWatcherEvent<'_>)) -> Self { + self.handler = Some(value); + self + } + + pub fn start(self) -> InstallLogWatcherHandle { + let cancellation_channel = channel(); + let iteration_cvar = Arc::new((Mutex::new(false), Condvar::new())); + + let context = WorkerContext { + crt_client: self.crt_client, + handler: self.handler, + + cancellation_receiver: cancellation_channel.1, + iteration_cvar: Arc::clone(&iteration_cvar), + pooling_delay: self.pooling_delay, + wait_for_clear_on_start: self.wait_for_clear_on_start, + fetch_last_log_on_stop: self.fetch_last_log_on_stop, + }; + + InstallLogWatcherHandle { + iteration_cvar, + cancellation_sender: cancellation_channel.0, + _worker: spawn(move || { + context.main(); + }), + } + } +} + +impl InstallLogWatcherHandle { + pub fn wait_next_check_complete(&self) { + let (lock, cvar) = &*self.iteration_cvar; + let finished = lock.lock().unwrap(); + + if !*finished { + drop(cvar.wait(finished).unwrap()) + } + } + + pub fn stop(&self) { + let _ = self.cancellation_sender.send(()); + } +} + +impl Drop for InstallLogWatcherHandle { + fn drop(&mut self) { + self.stop() + } +} + +impl WorkerContext { + fn main(&self) { + let mut iteration_ctx = WorkerIterationContext { + current_log: None, + timeout_received: false, + clear_event_received: !self.wait_for_clear_on_start, + }; + + loop { + let _ = process_iteration(self, &mut iteration_ctx); + + notify_iteration_complete(self); + + if iteration_ctx.timeout_received { + break; + } + + if self + .cancellation_receiver + .recv_timeout(self.pooling_delay) + .is_ok() + { + iteration_ctx.timeout_received = true; + + if !self.fetch_last_log_on_stop { + break; + } + } + } + + notify_iterations_finished(self); + + return; + + fn process_iteration( + worker_ctx: &WorkerContext, + iteration_ctx: &mut WorkerIterationContext, + ) -> Result<(), CrtClientGenericError> { + let log_file = worker_ctx + .crt_client + .package_installer_service() + .get_log_file()?; + + if log_file.is_empty() && !iteration_ctx.clear_event_received { + iteration_ctx.clear_event_received = true; + } + + if iteration_ctx.current_log.is_none() { + if !log_file.is_empty() { + handler_invoke( + worker_ctx, + iteration_ctx, + InstallLogWatcherEvent::Append(&log_file), + ); + } + } else if log_file.starts_with(iteration_ctx.current_log.as_ref().unwrap()) { + let delta = log_file + .strip_prefix(iteration_ctx.current_log.as_ref().unwrap()) + .unwrap(); + + if !delta.is_empty() { + handler_invoke( + worker_ctx, + iteration_ctx, + InstallLogWatcherEvent::Append(delta), + ); + } + } else { + handler_invoke(worker_ctx, iteration_ctx, InstallLogWatcherEvent::Clear()); + + if !iteration_ctx.clear_event_received { + iteration_ctx.clear_event_received = true; + } + + handler_invoke( + worker_ctx, + iteration_ctx, + InstallLogWatcherEvent::Append(&log_file), + ); + } + + iteration_ctx.current_log = Some(log_file); + + Ok(()) + } + + fn handler_invoke( + worker_ctx: &WorkerContext, + iteration_ctx: &WorkerIterationContext, + event: InstallLogWatcherEvent, + ) { + if let Some(handler) = worker_ctx.handler { + if iteration_ctx.clear_event_received { + handler(event); + } + } + } + + fn notify_iteration_complete(worker_ctx: &WorkerContext) { + let (_, cvar) = &*worker_ctx.iteration_cvar; + cvar.notify_all(); + } + + fn notify_iterations_finished(worker_ctx: &WorkerContext) { + let (lock, cvar) = &*worker_ctx.iteration_cvar; + let mut finished = lock.lock().unwrap(); + *finished = true; + cvar.notify_all(); + } + } +} + +struct WorkerIterationContext { + current_log: Option, + timeout_received: bool, + clear_event_received: bool, +} diff --git a/src/app/mod.rs b/src/app/mod.rs new file mode 100644 index 0000000..50e5602 --- /dev/null +++ b/src/app/mod.rs @@ -0,0 +1,30 @@ +mod auth; + +mod client; +pub use client::*; + +mod session; +pub use session::*; + +mod credentials; +pub use credentials::*; + +mod workspace_explorer; +pub use workspace_explorer::BuildResponse; + +mod package_installer; +pub use app_installer::{ + FileSystemSynchronizationObjectState, FileSystemSynchronizationResultResponse, +}; + +mod app_installer; + +mod package; + +mod install_log_watcher; +pub use install_log_watcher::*; + +mod cookie_cache; + +pub mod sql; +mod utils; diff --git a/src/app/package.rs b/src/app/package.rs new file mode 100644 index 0000000..e1433da --- /dev/null +++ b/src/app/package.rs @@ -0,0 +1,78 @@ +use crate::app::{ + CrtClient, CrtClientGenericError, CrtRequestBuilderReauthorize, StandardServiceResponse, +}; +use reqwest::Method; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +pub struct PackageService<'c>(&'c CrtClient); + +impl<'c> PackageService<'c> { + pub fn new(client: &'c CrtClient) -> Self { + Self(client) + } + + pub fn get_package_properties( + &self, + package_uid: &str, + ) -> Result { + let response = self + .0 + .request( + Method::POST, + "0/ServiceModel/PackageService.svc/GetPackageProperties", + ) + .json(&json!(package_uid)) + .send_with_session(self.0)? + .error_for_status() + .map_err(CrtClientGenericError::from)?; + + let response: GetPackagePropertiesResponse = + response.json().map_err(CrtClientGenericError::from)?; + + response.into_result() + } +} + +#[derive(Debug, Deserialize)] +struct GetPackagePropertiesResponse { + package: Option, + + #[serde(flatten)] + base: StandardServiceResponse, +} + +impl GetPackagePropertiesResponse { + pub fn into_result(self) -> Result { + if self.base.success { + Ok(self.package.expect( + "get_package_properties response success, but package info is not received", + )) + } else { + Err(CrtClientGenericError::from(self.base.error_info.expect( + "get_package_properties response not success, but error is not received", + ))) + } + } +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct GetPackagePropertiesModel { + pub id: String, + + #[serde(rename = "uId")] + pub uid: String, + + pub name: String, + + #[serde(rename = "type")] + pub package_type: u32, + + pub maintainer: String, + + #[serde(rename = "createdOn")] + pub created_on: String, + + #[serde(rename = "modifiedOn")] + pub modified_on: String, +} diff --git a/src/app/package_installer.rs b/src/app/package_installer.rs new file mode 100644 index 0000000..ee0b197 --- /dev/null +++ b/src/app/package_installer.rs @@ -0,0 +1,119 @@ +use crate::app::client::{CrtClient, CrtClientGenericError}; +use crate::app::{CrtRequestBuilderReauthorize, StandardServiceResponse}; +use reqwest::header::HeaderMap; +use reqwest::Method; +use serde_json::json; +use std::io::{Read, Seek}; + +pub struct PackageInstallerService<'c>(&'c CrtClient); + +impl<'c> PackageInstallerService<'c> { + pub fn new(client: &'c CrtClient) -> Self { + Self(client) + } + + pub fn get_log_file(&self) -> Result { + Ok(self + .0 + .request( + Method::GET, + "0/ServiceModel/PackageInstallerService.svc/GetLogFile", + ) + .send_with_session(self.0)? + .error_for_status()? + .text()?) + } + + pub fn get_zip_packages( + &self, + package_names: &[&str], + ) -> Result { + let response = self + .0 + .request( + Method::POST, + "0/ServiceModel/PackageInstallerService.svc/GetZipPackages", + ) + .json(&json!(package_names)) + .send_with_session(self.0)? + .error_for_status()?; + + Ok(response) + } + + pub fn upload_package( + &self, + mut package_reader: impl Read + Send + Seek + 'static, + package_filename: String, + ) -> Result<(), CrtClientGenericError> { + let mut file_header_map = HeaderMap::new(); + + let content_type = + match crate::pkg::utils::is_gzip_stream(&mut package_reader).unwrap_or(false) { + true => "application/x-gzip".parse().unwrap(), + false => "application/x-zip-compressed".parse().unwrap(), + }; + + file_header_map.insert("Content-Type", content_type); + + let response = self + .0 + .request( + Method::POST, + "0/ServiceModel/PackageInstallerService.svc/UploadPackage", + ) + .multipart( + reqwest::blocking::multipart::Form::new().part( + "files", + reqwest::blocking::multipart::Part::reader(package_reader) + .file_name(package_filename) + .headers(file_header_map), + ), + ) + .send_with_session(self.0)? + .error_for_status()?; + + response.json::()?.into_result()?; + + Ok(()) + } + + pub fn install_package(&self, package_filename: &str) -> Result<(), CrtClientGenericError> { + let response = self + .0 + .request( + Method::POST, + "0/ServiceModel/PackageInstallerService.svc/InstallPackage", + ) + .json(&json!(package_filename)) + .send_with_session(self.0)?; + + response.json::()?.into_result()?; + + Ok(()) + } + + #[allow(dead_code)] + pub fn validate_package( + &self, + code: &str, + package_filename: &str, + ) -> Result<(), CrtClientGenericError> { + let response = self + .0 + .request( + Method::POST, + "0/ServiceModel/PackageInstallerService.svc/Validate", + ) + .json(&json!({ + "Code": code, + "ZipPackageName": package_filename + })) + .send_with_session(self.0)? + .error_for_status()?; + + response.json::()?.into_result()?; + + Ok(()) + } +} diff --git a/src/app/session.rs b/src/app/session.rs new file mode 100644 index 0000000..0bd4da4 --- /dev/null +++ b/src/app/session.rs @@ -0,0 +1,52 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +pub struct CrtSession { + aspxauth: String, + bpmcsrf: String, + csrftoken: Option, + bpmsessionid: Option, +} + +impl CrtSession { + pub fn new( + aspxauth: String, + bpmcsrf: String, + csrftoken: Option, + bpmsessionid: Option, + ) -> CrtSession { + Self { + aspxauth, + bpmcsrf, + csrftoken, + bpmsessionid, + } + } + + pub fn to_cookie_value(&self) -> String { + let mut cookie_value = + format!(".ASPXAUTH={};BPMCSRF={};", self.aspxauth, self.bpmcsrf).to_owned(); + + if let Some(csrftoken) = &self.csrftoken { + cookie_value = format!("{}CsrfToken={};", cookie_value, csrftoken); + } + + if let Some(bpmsessionid) = &self.bpmsessionid { + cookie_value = format!("{}BPMSESSIONID={};", cookie_value, bpmsessionid); + } + + cookie_value + } + + pub fn bpmcsrf(&self) -> &str { + &self.bpmcsrf + } + + pub fn bpmsessionid(&self) -> Option<&str> { + self.bpmsessionid.as_deref() + } + + pub fn set_bpmsessionid(&mut self, bpmsessionid: Option) { + self.bpmsessionid = bpmsessionid; + } +} diff --git a/src/app/sql/mod.rs b/src/app/sql/mod.rs new file mode 100644 index 0000000..d6fba71 --- /dev/null +++ b/src/app/sql/mod.rs @@ -0,0 +1,46 @@ +mod runner; +pub use runner::*; + +mod scripts; +pub use scripts::*; + +use crate::app::{CrtClient, CrtClientGenericError, CrtDbType}; +use std::ops::Deref; + +pub fn detect_db_type(client: &CrtClient) -> Result { + return match client.sql("SELECT version();") { + Ok(r) => { + let output = r.table.ok_or_else(get_unexpected_output_error)?; + + let output_str = output + .first() + .ok_or_else(get_unexpected_output_error)? + .iter() + .next() + .ok_or_else(get_unexpected_output_error)? + .1 + .as_str() + .ok_or_else(get_unexpected_output_error)?; + + return match output_str + .to_lowercase() + .starts_with(&"PostgreSQL".to_lowercase()) + { + true => Ok(CrtDbType::Postgres), + false => Ok(CrtDbType::Oracle), + }; + } + Err(CrtClientGenericError::SqlRunner(sql_err)) + if { matches!(sql_err.deref(), SqlRunnerError::NotFound) } => + { + Ok(CrtDbType::MsSql) + } + Err(err) => Err(err), + }; + + fn get_unexpected_output_error() -> CrtClientGenericError { + CrtClientGenericError::SqlRunner(Box::new(SqlRunnerError::DbTypeDetection { + err: "unexpected empty sql output".into(), + })) + } +} diff --git a/src/app/sql/runner.rs b/src/app/sql/runner.rs new file mode 100644 index 0000000..b63471b --- /dev/null +++ b/src/app/sql/runner.rs @@ -0,0 +1,220 @@ +use crate::app::{CrtClient, CrtClientGenericError, CrtRequestBuilderReauthorize}; +use reqwest::StatusCode; +use serde::Deserialize; +use serde_json::json; +use thiserror::Error; + +pub trait SqlRunner: Send + Sync { + fn sql(&self, client: &CrtClient, sql: &str) -> Result; +} + +#[derive(Debug)] +pub struct SqlRunnerResult { + pub rows_affected: u64, + pub table: Option>>, +} + +#[derive(Debug, Error)] +pub enum SqlRunnerError { + #[error("cannot detect db type: {err}")] + DbTypeDetection { err: String }, + + #[error("sql request error: {0}")] + Request(#[from] CrtClientGenericError), + + #[error("sql runner not found on target server")] + NotFound, + + #[error("sql execution returned error: {err}")] + Execution { err: String }, + + #[error("failed to execute sql: {0}")] + Other(#[source] Box), +} + +pub struct ClioGateSqlRunner; + +impl SqlRunner for ClioGateSqlRunner { + fn sql(&self, client: &CrtClient, sql: &str) -> Result { + let response = client + .request( + reqwest::Method::POST, + "0/rest/CreatioApiGateway/ExecuteSqlScript", + ) + .json(&json!({ + "script": sql + })) + .send_with_session(client)?; + + if response.status() == StatusCode::NOT_FOUND { + return Err(SqlRunnerError::NotFound); + } + + if response.status() == StatusCode::BAD_REQUEST { + let response_text = response + .text() + .map_err(CrtClientGenericError::ReqwestError)?; + + return Err(SqlRunnerError::Execution { err: response_text }); + } + + let response_body: serde_json::Value = response + .error_for_status() + .map_err(CrtClientGenericError::ReqwestError)? + .json() + .map_err(CrtClientGenericError::ReqwestError)?; + + let response_body = response_body.as_str().ok_or_else(|| { + SqlRunnerError::Other("failed to parse response body as json string".into()) + })?; + + let rows_affected: Result = response_body.parse(); + if let Ok(rows_affected) = rows_affected { + return Ok(SqlRunnerResult { + rows_affected, + table: None, + }); + } + + let response_body: Vec> = + serde_json::from_str(response_body) + .map_err(|err| SqlRunnerError::Other(Box::new(err)))?; + + Ok(SqlRunnerResult { + rows_affected: 0, + table: Some(response_body), + }) + } +} + +pub struct SqlConsoleSqlRunner; + +impl SqlRunner for SqlConsoleSqlRunner { + fn sql(&self, client: &CrtClient, sql: &str) -> Result { + let response = client + .request( + reqwest::Method::POST, + "0/rest/SqlConsoleService/ExecuteSqlScript", + ) + .json(&json!({ + "sqlScript": sql + })) + .send_with_session(client)?; + + if response.status() == StatusCode::NOT_FOUND { + return Err(SqlRunnerError::NotFound); + } + + let response_body: SqlConsoleResponse = response + .error_for_status() + .map_err(CrtClientGenericError::ReqwestError)? + .json() + .map_err(CrtClientGenericError::ReqwestError)?; + + let response_body = response_body.execute_sql_script_result_root; + + if !response_body.success { + return Err(SqlRunnerError::Execution { + err: response_body + .error_message + .unwrap_or("unknown error".to_owned()), + }); + } + + Ok(SqlRunnerResult { + rows_affected: response_body.rows_affected, + table: match response_body.query_results { + None => None, + Some(query_result) => { + match query_result.len() { + 0 => Some(Vec::new()), + len => { + if len > 1 { + eprintln!("more than one table returned, this currently unsupported, the first table will out"); + } + + todo!() + + //todo column strings should be separated + + // let table = query_result.remove(0); + // + // table.rows + // .into_iter() + // .map(|r| { + // let map = HashMap::new(); + // + // for (i, rv) in r.into_iter().enumerate() { + // + // } + // + // + // }) + // .collect() + } + } + } + }, + }) + } +} + +#[derive(Debug, Deserialize)] +struct SqlConsoleResponse { + #[serde(rename = "ExecuteSqlScriptResult")] + execute_sql_script_result_root: SqlConsoleRootResponse, +} + +#[derive(Debug, Deserialize)] +struct SqlConsoleRootResponse { + #[serde(rename = "Success")] + success: bool, + + #[serde(rename = "ErrorMessage")] + error_message: Option, + + // #[serde(rename = "SecurityError")] + // security_error: bool, + #[serde(rename = "RowsAffected")] + rows_affected: u64, + + #[serde(rename = "QueryResults")] + query_results: Option>, +} + +#[derive(Debug, Deserialize)] +struct SqlConsoleQueryResult { + #[serde(rename = "Columns")] + columns: Vec, + + #[serde(rename = "Rows")] + rows: Vec>, +} + +pub struct AutodetectSqlRunner; + +macro_rules! next_if_not_found { + ($client:expr, $sql: expr, $left_runner: expr, $next_runner: expr) => { + match $left_runner.sql($client, $sql) { + Err(SqlRunnerError::NotFound) => $next_runner, + r => return Some((Box::new($left_runner), r)), + } + }; + ($client:expr, $sql: expr, $left_runner: expr) => { + match $left_runner.sql($client, $sql) { + Err(SqlRunnerError::NotFound) => return None, + r => return Some((Box::new($left_runner), r)), + } + }; +} + +impl AutodetectSqlRunner { + pub fn detect_and_run_sql( + client: &CrtClient, + sql: &str, + ) -> Option<(Box, Result)> { + let next = next_if_not_found!(client, sql, ClioGateSqlRunner, SqlConsoleSqlRunner); + + next_if_not_found!(client, sql, next); + } +} diff --git a/src/app/sql/scripts.rs b/src/app/sql/scripts.rs new file mode 100644 index 0000000..e20990a --- /dev/null +++ b/src/app/sql/scripts.rs @@ -0,0 +1,154 @@ +use crate::app::{CrtClient, CrtClientGenericError, CrtDbType}; + +pub struct SqlScripts<'c>(&'c CrtClient); + +impl<'c> SqlScripts<'c> { + pub fn new(client: &'c CrtClient) -> Self { + Self(client) + } + + pub fn mark_package_as_not_changed( + &self, + package_uid: &str, + ) -> Result { + let query = match self.0.db_type()? { + CrtDbType::MsSql => format!( + r#"UPDATE "SysSchema" + SET "IsChanged" = 0, "IsLocked" = 0 + WHERE "SysPackageId" IN (SELECT "Id" FROM "SysPackage" WHERE "UId" = '{package_uid}'); + + UPDATE "SysPackageSchemaData" + SET "IsChanged" = 0, "IsLocked" = 0 + WHERE "SysPackageId" IN (SELECT "Id" FROM "SysPackage" WHERE "UId" = '{package_uid}'); + + UPDATE "SysPackageSqlScript" + SET "IsChanged" = 0, "IsLocked" = 0 + WHERE "SysPackageId" IN (SELECT "Id" FROM "SysPackage" WHERE "UId" = '{package_uid}'); + + UPDATE "SysPackageReferenceAssembly" + SET "IsChanged" = 0, "IsLocked" = 0 + WHERE "SysPackageId" IN (SELECT "Id" FROM "SysPackage" WHERE "UId" = '{package_uid}'); + "# + ), + CrtDbType::Oracle | CrtDbType::Postgres => format!( + r#"UPDATE "SysSchema" + SET "IsChanged" = False, "IsLocked" = False + WHERE "SysPackageId" IN (SELECT "Id" FROM "SysPackage" WHERE "UId" = '{package_uid}'); + + UPDATE "SysPackageSchemaData" + SET "IsChanged" = False, "IsLocked" = False + WHERE "SysPackageId" IN (SELECT "Id" FROM "SysPackage" WHERE "UId" = '{package_uid}'); + + UPDATE "SysPackageSqlScript" + SET "IsChanged" = False, "IsLocked" = False + WHERE "SysPackageId" IN (SELECT "Id" FROM "SysPackage" WHERE "UId" = '{package_uid}'); + + UPDATE "SysPackageReferenceAssembly" + SET "IsChanged" = False, "IsLocked" = False + WHERE "SysPackageId" IN (SELECT "Id" FROM "SysPackage" WHERE "UId" = '{package_uid}'); + "# + ), + }; + + Ok(self.0.sql(&query)?.rows_affected) + } + + pub fn delete_package_localizations( + &self, + package_uid: &str, + ) -> Result { + let query = match self.0.db_type()? { + _ => &format!( + r#"DELETE FROM "SysLocalizableValue" + WHERE "SysPackageId" IN ( + SELECT "Id" FROM "SysPackage" WHERE "UId" = '{package_uid}' + ); + + DELETE FROM "SysPackageResourceChecksum" + WHERE "SysPackageId" IN ( + SELECT "Id" FROM "SysPackage" WHERE "UId" = '{package_uid}' + ); + + DELETE FROM "SysPackageDataLcz" WHERE "SysPackageSchemaDataId" IN ( + SELECT "Id" FROM "SysPackageSchemaData" WHERE "SysPackageId" IN ( + SELECT "Id" FROM "SysPackage" WHERE "UId" = '{package_uid}' + ) + ); + + DELETE FROM "SysPackageSchemaData" + WHERE "SysPackageId" IN ( + SELECT "Id" FROM "SysPackage" WHERE "UId" = '{package_uid}' + ); + "# + ), + }; + + Ok(self.0.sql(query)?.rows_affected) + } + + pub fn reset_schema_content(&self, package_uid: &str) -> Result { + let query = match self.0.db_type()? { + _ => &format!( + r#"DELETE FROM "SysSchemaContent" WHERE "SysSchemaId" IN ( + SELECT "Id" FROM "SysSchema" WHERE "SysPackageId" IN ( + SELECT "Id" FROM "SysPackage" WHERE "UId" = '{package_uid}' + ) + ) + + UPDATE "SysSchema" + SET "Checksum" = '', + "MetaData" = NULL, + "Descriptor" = NULL, + "CreatedOn" = NULL, + "ModifiedById" = NULL, + "CreatedById" = NULL, + "ModifiedOn" = NULL, + "ClientContentModifiedOn" = NULL + WHERE "SysPackageId" IN ( + SELECT "Id" FROM "SysPackage" WHERE "UId" = '{package_uid}' + ) + "# + ), + }; + + Ok(self.0.sql(query)?.rows_affected) + } + + pub fn lock_package(&self, package_name: &str) -> Result { + let query = match self.0.db_type()? { + CrtDbType::MsSql => &format!( + r#"UPDATE "SysPackage" + SET "InstallType" = 1, "IsLocked" = 0, "IsChanged" = 0 + WHERE "Name" = '{package_name}'; + "# + ), + CrtDbType::Oracle | CrtDbType::Postgres => &format!( + r#"UPDATE "SysPackage" + SET "InstallType" = 1, "IsLocked" = False, "IsChanged" = False + WHERE "Name" = '{package_name}'; + "# + ), + }; + + Ok(self.0.sql(query)?.rows_affected) + } + + pub fn unlock_package(&self, package_name: &str) -> Result { + let query = match self.0.db_type()? { + CrtDbType::MsSql => &format!( + r#"UPDATE "SysPackage" + SET "InstallType" = 0, "IsLocked" = 1, "IsChanged" = 1 + WHERE "Name" = '{package_name}'; + "# + ), + CrtDbType::Oracle | CrtDbType::Postgres => &format!( + r#"UPDATE "SysPackage" + SET "InstallType" = 0, "IsLocked" = True, "IsChanged" = True + WHERE "Name" = '{package_name}'; + "# + ), + }; + + Ok(self.0.sql(query)?.rows_affected) + } +} diff --git a/src/app/utils.rs b/src/app/utils.rs new file mode 100644 index 0000000..90d7a7d --- /dev/null +++ b/src/app/utils.rs @@ -0,0 +1,36 @@ +use reqwest::blocking::Response; +use thiserror::Error; + +#[derive(Error, Debug)] +#[error("response cookie has invalid format, parsing failed")] +pub struct CookieParsingError; + +pub fn iter_set_cookies( + response: &Response, +) -> impl Iterator> { + response + .headers() + .iter() + .filter(|(name, _)| *name == "set-cookie") + .map(|(_, value)| { + value + .to_str() + .map_err(|_| CookieParsingError)? + .split_once(";") + .ok_or(CookieParsingError)? + .0 + .split_once("=") + .ok_or(CookieParsingError) + }) +} + +pub fn collect_set_cookies(response: &Response) -> Result, CookieParsingError> { + iter_set_cookies(response).collect::, CookieParsingError>>() +} + +pub fn find_cookie_by_name(set_cookies: &[(&str, &str)], name: &str) -> Option { + set_cookies + .iter() + .find(|(cookie_name, _)| *cookie_name == name) + .map(|(_, value)| (*value).to_owned()) +} diff --git a/src/app/workspace_explorer.rs b/src/app/workspace_explorer.rs new file mode 100644 index 0000000..1608463 --- /dev/null +++ b/src/app/workspace_explorer.rs @@ -0,0 +1,185 @@ +use crate::app::client::{CrtClient, CrtClientGenericError}; +use crate::app::{CrtRequestBuilderReauthorize, StandardServiceResponse}; +use reqwest::Method; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::fmt::{Debug, Display, Formatter}; + +pub struct WorkspaceExplorerService<'c>(&'c CrtClient); + +impl<'c> WorkspaceExplorerService<'c> { + pub fn new(client: &'c CrtClient) -> Self { + Self(client) + } + + pub fn build(&self) -> Result { + let response = self + .0 + .request( + Method::POST, + "0/ServiceModel/WorkspaceExplorerService.svc/Build", + ) + .header(reqwest::header::CONTENT_LENGTH, "0") + .send_with_session(self.0)? + .error_for_status()?; + + Ok(response.json()?) + } + + pub fn rebuild(&self) -> Result { + let response = self + .0 + .request( + Method::POST, + "0/ServiceModel/WorkspaceExplorerService.svc/Rebuild", + ) + .header(reqwest::header::CONTENT_LENGTH, "0") + .send_with_session(self.0)? + .error_for_status()?; + + Ok(response.json()?) + } + + pub fn build_package( + &self, + package_name: &str, + ) -> Result { + let response = self + .0 + .request( + Method::POST, + "0/ServiceModel/WorkspaceExplorerService.svc/BuildPackage", + ) + .json(&json!({ + "packageName": package_name + })) + .send_with_session(self.0)? + .error_for_status()?; + + Ok(response.json()?) + } + + pub fn get_packages(&self) -> Result, CrtClientGenericError> { + let response = self + .0 + .request( + Method::POST, + "0/ServiceModel/WorkspaceExplorerService.svc/GetPackages", + ) + .header(reqwest::header::CONTENT_LENGTH, "0") + .send_with_session(self.0)? + .error_for_status()?; + + let response: GetPackagesResponse = response.json()?; + + Ok(response.packages) + } + + pub fn get_is_file_system_development_mode(&self) -> Result { + let response = self + .0 + .request( + Method::POST, + "0/ServiceModel/WorkspaceExplorerService.svc/GetIsFileDesignMode", + ) + .header(reqwest::header::CONTENT_LENGTH, "0") + .send_with_session(self.0)? + .error_for_status()?; + + let response = response.json::()?; + + response.base.into_result()?; + + Ok(response.value) + } +} + +#[derive(Deserialize, Debug)] +pub struct BuildResponse { + pub success: bool, + + // #[serde(rename = "buildResult")] + // pub build_result: u32, + pub message: Option, + + #[serde(rename = "errorInfo")] + pub error_info: Option, + + pub errors: Option>, +} + +impl BuildResponse { + pub fn has_any_error(&self) -> bool { + self.errors + .as_ref() + .is_some_and(|x| x.iter().any(|x| !x.warning)) + } +} + +#[derive(Deserialize, Debug)] +pub struct BuildPackageError { + pub line: u32, + pub column: u32, + pub warning: bool, + + #[serde(rename = "fileName")] + pub filename: String, + + #[serde(rename = "errorNumber")] + pub error_number: String, + + #[serde(rename = "errorText")] + pub error_text: String, +} + +#[derive(Deserialize, Debug)] +pub struct BuildPackageErrorInfo { + pub message: String, +} + +impl Display for BuildPackageError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let prefix = match self.warning { + true => "WARN", + false => "ERROR", + }; + + let filename_substr = match self.filename.as_str() { + "" => "", + _ => &format!(" {}({}:{}):", self.filename, self.line, self.column), + }; + + write!( + f, + "{}{} {}: {}", + prefix, filename_substr, self.error_number, self.error_text + ) + } +} + +#[derive(Deserialize, Debug)] +pub struct GetPackagesResponse { + packages: Vec, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct GetPackagesResponseItem { + #[serde(rename = "uId")] + uid: String, + + name: String, +} + +impl Display for GetPackagesResponseItem { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{} (UId: {})", self.name, self.uid) + } +} + +#[derive(Debug, Deserialize)] +struct GetIsFileDesignModeResponse { + #[serde(flatten)] + base: StandardServiceResponse, + + value: bool, +} diff --git a/src/cmd/app/compile.rs b/src/cmd/app/compile.rs new file mode 100644 index 0000000..f4a76a0 --- /dev/null +++ b/src/cmd/app/compile.rs @@ -0,0 +1,81 @@ +use crate::app::{BuildResponse, CrtClientGenericError}; +use crate::cmd::app::{AppCommand, AppCommandArgs}; +use clap::Args; +use owo_colors::OwoColorize; +use std::error::Error; +use thiserror::Error; + +#[derive(Args, Debug)] +pub struct CompileCommand { + /// Use Rebuild method instead of just Build + #[arg(short = 'f', long)] + force_rebuild: bool, + + /// Restart application after successful compilation + #[arg(short, long)] + restart: bool, +} + +#[derive(Debug, Error)] +pub enum CompileCommandError { + #[error("App restart error: {0}")] + AppRestart(#[source] CrtClientGenericError), +} + +impl AppCommand for CompileCommand { + fn run(&self, app: &AppCommandArgs) -> Result<(), Box> { + let client = app.build_client()?; + + let response = match self.force_rebuild { + true => client.workspace_explorer_service().rebuild()?, + false => client.workspace_explorer_service().build()?, + }; + + print_build_response(&response)?; + + if self.restart { + client + .app_installer_service() + .restart_app() + .map_err(CompileCommandError::AppRestart)?; + + eprintln!("Application restart has been requested"); + + if !client.is_net_framework() { + eprintln!("Note: if restart does not work, please check if you need to use --net-framework flag"); + } + } + + Ok(()) + } +} + +pub fn print_build_response(response: &BuildResponse) -> Result<(), Box> { + if let Some(errors) = &response.errors { + for error in errors { + println!("{error}"); + } + } + + if let Some(error_info) = &response.error_info { + println!("{}", error_info.message) + } + + if let Some(message) = &response.message { + println!("--> {message}") + } + + match ( + response.success, + response.has_any_error(), + &response.error_info, + ) { + (true, _, _) => {} + (false, false, None) => {} + _ => return Err("compile was finished with errors".into()), + } + + eprintln!("{}", "Compiled successfully!".green()); + + Ok(()) +} diff --git a/src/cmd/app/flush_redis.rs b/src/cmd/app/flush_redis.rs new file mode 100644 index 0000000..1246f3f --- /dev/null +++ b/src/cmd/app/flush_redis.rs @@ -0,0 +1,16 @@ +use crate::cmd::app::{AppCommand, AppCommandArgs}; +use clap::Args; +use std::error::Error; + +#[derive(Args, Debug)] +pub struct FlushRedisCommand; + +impl AppCommand for FlushRedisCommand { + fn run(&self, app: &AppCommandArgs) -> Result<(), Box> { + app.build_client()? + .app_installer_service() + .clear_redis_db()?; + + Ok(()) + } +} diff --git a/src/cmd/app/fs/check_fs.rs b/src/cmd/app/fs/check_fs.rs new file mode 100644 index 0000000..a99c3ba --- /dev/null +++ b/src/cmd/app/fs/check_fs.rs @@ -0,0 +1,26 @@ +use crate::cmd::app::{AppCommand, AppCommandArgs}; +use clap::Args; +use owo_colors::OwoColorize; +use std::error::Error; + +#[derive(Args, Debug)] +pub struct CheckFsCommand; + +impl AppCommand for CheckFsCommand { + fn run(&self, app: &AppCommandArgs) -> Result<(), Box> { + let result = app + .build_client()? + .workspace_explorer_service() + .get_is_file_system_development_mode()?; + + eprintln!( + "File System Development mode (FSD): {}", + match result { + true => "Enabled".green().to_string(), + false => "Disabled".red().to_string(), + } + ); + + Ok(()) + } +} diff --git a/src/cmd/app/fs/mod.rs b/src/cmd/app/fs/mod.rs new file mode 100644 index 0000000..5822662 --- /dev/null +++ b/src/cmd/app/fs/mod.rs @@ -0,0 +1,138 @@ +mod check_fs; +pub mod pull_fs; +pub mod push_fs; + +use crate::app::{FileSystemSynchronizationObjectState, FileSystemSynchronizationResultResponse}; +use crate::cmd::app::{AppCommand, AppCommandArgs}; +use clap::Subcommand; +use owo_colors::OwoColorize; +use std::error::Error; + +#[derive(Debug, Subcommand)] +pub enum FsCommands { + /// Check is File System Development mode is enabled for the Creatio instance + Check(check_fs::CheckFsCommand), + + /// Unload packages from Creatio database into filesystem + Pull(pull_fs::PullFsCommand), + + /// Load packages from filesystem into Creatio database + Push(push_fs::PushFsCommand), +} + +impl AppCommand for FsCommands { + fn run(&self, app: &AppCommandArgs) -> Result<(), Box> { + match self { + FsCommands::Check(command) => command.run(app), + FsCommands::Pull(command) => command.run(app), + FsCommands::Push(command) => command.run(app), + } + } +} + +fn print_fs_sync_result(result: &FileSystemSynchronizationResultResponse) { + if !result.changes.is_empty() { + for package in &result.changes { + eprintln!( + "Package {} - {:?}", + package.workspace_item.name.bold(), + package.workspace_item.state + ); + + let mut sorted_items_refs = package.items.iter().collect::>(); + + sorted_items_refs.sort_by(|i1, i2| { + i1.object_type + .get_fs_order_index() + .cmp(&i2.object_type.get_fs_order_index()) + .then( + i1.name + .cmp(&i2.name) + .then(i1.culture_name.cmp(&i2.culture_name)), + ) + }); + + for item in sorted_items_refs { + match item.state { + FileSystemSynchronizationObjectState::NotChanged => {} + FileSystemSynchronizationObjectState::New => eprintln!( + "\t{status}\t{object_type:?} {arrow} {name}{culture}", + status = "created:".green(), + object_type = item.object_type.green(), + arrow = "->".green(), + name = item.name.green(), + culture = item + .culture_name + .as_ref() + .map(|x| format!(", {}", x)) + .unwrap_or_default() + .green() + ), + FileSystemSynchronizationObjectState::Deleted => eprintln!( + "\t{status}\t{object_type:?} {arrow} {name}{culture}", + status = "deleted:".red(), + object_type = item.object_type.red(), + arrow = "->".red(), + name = item.name.red(), + culture = item + .culture_name + .as_ref() + .map(|x| format!(", {}", x)) + .unwrap_or_default() + .red() + ), + FileSystemSynchronizationObjectState::Changed => eprintln!( + "\t{status}\t{object_type:?} -> {name}{culture}", + status = "modified:", + object_type = item.object_type, + name = item.name, + culture = item + .culture_name + .as_ref() + .map(|x| format!(", {}", x)) + .unwrap_or_default(), + ), + _ => eprintln!( + "\t{status:?}{colon}\t{object_type:?} -> {name}{culture}", + status = item.state, + colon = ":", + object_type = item.object_type, + name = item.name, + culture = item + .culture_name + .as_ref() + .map(|x| format!(", {}", x)) + .unwrap_or_default(), + ), + } + } + } + } + + if !result.errors.is_empty() { + if !result.changes.is_empty() { + eprintln!(); + eprintln!(); + } + + eprintln!("Errors ({}):", result.errors.len()); + + for error in &result.errors { + eprintln!( + "{}{} {}: {}", + error.workspace_item.name.red(), + error + .workspace_item + .culture_name + .as_ref() + .map(|x| format!(", {}", x)) + .unwrap_or_default() + .red(), + format!("({:?})", error.workspace_item.object_type).red(), + error.error_info.red() + ); + } + } + + // eprintln!("{}", "Done!".bold().green()); +} diff --git a/src/cmd/app/fs/pull_fs.rs b/src/cmd/app/fs/pull_fs.rs new file mode 100644 index 0000000..7890eab --- /dev/null +++ b/src/cmd/app/fs/pull_fs.rs @@ -0,0 +1,29 @@ +use crate::cmd::app::fs::print_fs_sync_result; +use crate::cmd::app::{AppCommand, AppCommandArgs}; +use clap::Args; +use std::error::Error; + +#[derive(Args, Debug)] +pub struct PullFsCommand { + /// A space-separated or comma-separated list of package names to pull. Example: "CrtBase,CrtCore" + #[arg(value_delimiter = ',', value_hint = clap::ValueHint::Other)] + pub packages: Option>, +} + +impl AppCommand for PullFsCommand { + fn run(&self, app: &AppCommandArgs) -> Result<(), Box> { + let result = app + .build_client()? + .app_installer_service() + .load_packages_to_fs( + self.packages + .as_ref() + .map(|vec| vec.iter().map(|s| s.as_str()).collect::>()) + .as_deref(), + )?; + + print_fs_sync_result(&result); + + Ok(()) + } +} diff --git a/src/cmd/app/fs/push_fs.rs b/src/cmd/app/fs/push_fs.rs new file mode 100644 index 0000000..92f82e3 --- /dev/null +++ b/src/cmd/app/fs/push_fs.rs @@ -0,0 +1,29 @@ +use crate::cmd::app::fs::print_fs_sync_result; +use crate::cmd::app::{AppCommand, AppCommandArgs}; +use clap::Args; +use std::error::Error; + +#[derive(Args, Debug)] +pub struct PushFsCommand { + /// A space-separated or comma-separated list of package names to push. Example: "CrtBase,CrtCore" + #[arg(value_delimiter = ',', value_hint = clap::ValueHint::Other)] + pub packages: Option>, +} + +impl AppCommand for PushFsCommand { + fn run(&self, app: &AppCommandArgs) -> Result<(), Box> { + let result = app + .build_client()? + .app_installer_service() + .load_packages_to_db( + self.packages + .as_ref() + .map(|vec| vec.iter().map(|s| s.as_str()).collect::>()) + .as_deref(), + )?; + + print_fs_sync_result(&result); + + Ok(()) + } +} diff --git a/src/cmd/app/install_log.rs b/src/cmd/app/install_log.rs new file mode 100644 index 0000000..d36db88 --- /dev/null +++ b/src/cmd/app/install_log.rs @@ -0,0 +1,19 @@ +use crate::cmd::app::{AppCommand, AppCommandArgs}; +use clap::Args; +use std::error::Error; + +#[derive(Args, Debug)] +pub struct InstallLogCommand; + +impl AppCommand for InstallLogCommand { + fn run(&self, app: &AppCommandArgs) -> Result<(), Box> { + let log_file = app + .build_client()? + .package_installer_service() + .get_log_file()?; + + println!("{}", log_file.trim_end()); + + Ok(()) + } +} diff --git a/src/cmd/app/mod.rs b/src/cmd/app/mod.rs new file mode 100644 index 0000000..378d31b --- /dev/null +++ b/src/cmd/app/mod.rs @@ -0,0 +1,137 @@ +macro_rules! detect_target_package_name { + ($specified_package_name: expr, $destination_folder: expr) => { + match &$specified_package_name { + Some(p) => p, + None => &crate::pkg::utils::get_package_name_from_folder($destination_folder).map_err( + crate::cmd::app::pkg::DetectTargetPackageNameError::GetPackageNameFromFolder, + )?, + } + }; + ($specified_package_name:expr) => { + detect_target_package_name!( + $specified_package_name, + &std::env::current_dir() + .map_err(crate::cmd::app::pkg::DetectTargetPackageNameError::GetCurrentDirError)? + ) + }; + () => { + crate::pkg::utils::get_package_name_from_folder( + &std::env::current_dir() + .map_err(crate::cmd::app::pkg::DetectTargetPackageNameError::GetCurrentDirError)?, + ) + .map_err(crate::cmd::app::pkg::DetectTargetPackageNameError::GetPackageNameFromFolder)? + }; +} + +mod compile; +pub use compile::print_build_response; + +mod flush_redis; +mod fs; +mod install_log; +mod pkg; +mod pkgs; +mod request; +mod restart; +mod sql; + +use crate::app::{CrtClient, CrtClientGenericError, CrtCredentials}; +use clap::{Args, Subcommand}; +use std::error::Error; + +#[derive(Debug, Args)] +pub struct AppCommandArgs { + /// Creatio Base URL + #[arg(value_hint = clap::ValueHint::Url, env = "CRTCLI_APP_URL")] + url: String, + + /// Creatio Username + #[arg(value_hint = clap::ValueHint::Other, env = "CRTCLI_APP_USERNAME")] + username: String, + + /// Creatio Password + #[arg(value_hint = clap::ValueHint::Other, env = "CRTCLI_APP_PASSWORD")] + password: String, + + /// Ignore SSL certificate errors + #[arg(long, short, env = "CRTCLI_APP_INSECURE")] + insecure: bool, + + /// Use .NET Framework (IIS) Creatio compatibility + /// + /// By default, crtcli primary uses .NET Core / .NET (Kestrel) API routes to operate with remote. + /// However, some features like "app restart" works by different API routes in both platforms. + #[arg(long = "net-framework", env = "CRTCLI_APP_NETFRAMEWORK")] + net_framework: bool, +} + +impl AppCommandArgs { + pub fn build_client(&self) -> Result { + let client = CrtClient::builder(CrtCredentials::new( + &self.url, + &self.username, + &self.password, + )) + .danger_accept_invalid_certs(self.insecure) + .use_net_framework_mode(self.net_framework) + .build()?; + + Ok(client) + } +} + +pub trait AppCommand { + fn run(&self, app: &AppCommandArgs) -> Result<(), Box>; +} + +#[derive(Debug, Subcommand)] +pub enum AppCommands { + /// Compiles the Creatio application + Compile(compile::CompileCommand), + + /// Clears the Redis cache associated with the Creatio instance + FlushRedis(flush_redis::FlushRedisCommand), + + /// Commands for interacting with Creatio's File System Development (FSD) mode + Fs { + #[command(subcommand)] + command: fs::FsCommands, + }, + + /// Print last package installation log + InstallLog(install_log::InstallLogCommand), + + /// Commands to manipulate with packages in Creatio + Pkg { + #[command(subcommand)] + command: pkg::PkgCommands, + }, + + /// Lists the installed packages in the Creatio instance + Pkgs(pkgs::PkgsCommand), + + /// Restarts the Creatio application + Restart(restart::RestartCommand), + + /// Sends authenticated HTTP requests to the Creatio instance, similar to curl + Request(request::RequestCommand), + + /// Executes SQL queries in the Creatio database using a supported SQL runner package installed in Creatio + Sql(sql::SqlCommand), +} + +impl AppCommand for AppCommands { + fn run(&self, app: &AppCommandArgs) -> Result<(), Box> { + match self { + AppCommands::Compile(command) => command.run(app), + AppCommands::FlushRedis(command) => command.run(app), + AppCommands::Fs { command } => command.run(app), + AppCommands::InstallLog(command) => command.run(app), + AppCommands::Pkg { command } => command.run(app), + AppCommands::Pkgs(command) => command.run(app), + AppCommands::Restart(command) => command.run(app), + AppCommands::Request(command) => command.run(app), + AppCommands::Sql(command) => command.run(app), + } + } +} diff --git a/src/cmd/app/pkg/compile_pkg.rs b/src/cmd/app/pkg/compile_pkg.rs new file mode 100644 index 0000000..bcbc8e7 --- /dev/null +++ b/src/cmd/app/pkg/compile_pkg.rs @@ -0,0 +1,50 @@ +use crate::app::CrtClientGenericError; +use crate::cmd::app::{print_build_response, AppCommand, AppCommandArgs}; +use clap::Args; +use std::error::Error; +use thiserror::Error; + +#[derive(Args, Debug)] +pub struct CompilePkgCommand { + /// Name of package to compile (default: package name from ./descriptor.json) + #[arg(value_hint = clap::ValueHint::Other)] + pub package_name: Option, + + /// Restart the Creatio application after successful package compilation + #[arg(short, long)] + pub restart: bool, +} + +#[derive(Debug, Error)] +pub enum CompilePkgCommandError { + #[error("App restart error: {0}")] + AppRestart(#[source] CrtClientGenericError), +} + +impl AppCommand for CompilePkgCommand { + fn run(&self, app: &AppCommandArgs) -> Result<(), Box> { + let package_name = detect_target_package_name!(&self.package_name); + let client = app.build_client()?; + + let result = client + .workspace_explorer_service() + .build_package(package_name)?; + + print_build_response(&result)?; + + if self.restart { + client + .app_installer_service() + .restart_app() + .map_err(CompilePkgCommandError::AppRestart)?; + + eprintln!("Application restart has been requested"); + + if !client.is_net_framework() { + eprintln!("Note: if restart does not work, please check if you need to use --net-framework flag"); + } + } + + Ok(()) + } +} diff --git a/src/cmd/app/pkg/download_pkg.rs b/src/cmd/app/pkg/download_pkg.rs new file mode 100644 index 0000000..010f1c1 --- /dev/null +++ b/src/cmd/app/pkg/download_pkg.rs @@ -0,0 +1,71 @@ +use crate::cmd::app::{AppCommand, AppCommandArgs}; +use crate::cmd::utils::{generate_zip_package_filename, get_next_filename_if_exists}; +use clap::Args; +use std::error::Error; +use std::fs::File; +use std::path::PathBuf; +use thiserror::Error; + +#[derive(Args, Debug)] +pub struct DownloadPkgCommand { + /// A space-separated or comma-separated list of package names to download. Example: "CrtBase,CrtCore" + #[arg(value_delimiter = ',', value_hint = clap::ValueHint::Other)] + packages: Vec, + + /// Directory where the downloaded package archive will be saved (default: current directory) + #[arg(short = 'f', long, value_hint = clap::ValueHint::DirPath)] + output_folder: Option, + + /// Name of the output zip file (optional, will be auto-generated if not specified) + #[arg(short = 'n', long, value_hint = clap::ValueHint::FilePath)] + output_filename: Option, +} + +#[derive(Error, Debug)] +enum DownloadPkgCommandError { + #[error("failed to get valid current directory (also you can specify output_folder arg): {0}")] + GetCurrentDir(#[source] std::io::Error), +} + +impl AppCommand for DownloadPkgCommand { + fn run(&self, app: &AppCommandArgs) -> Result<(), Box> { + let output_folder = match &self.output_folder { + Some(path) => path, + None => &std::env::current_dir().map_err(DownloadPkgCommandError::GetCurrentDir)?, + }; + + let packages = match self.packages.len() { + 0 => &vec![detect_target_package_name!()], + _ => &self.packages, + }; + + let default_filename = match packages.len() { + 1 => packages.iter().next().unwrap(), + _ => "Packages", + }; + + let output_filename = match &self.output_filename { + Some(filename) => filename, + None => &generate_zip_package_filename(default_filename), + }; + + let output_path = output_folder.join(output_filename); + let output_path = match self.output_filename.is_none() { + true => get_next_filename_if_exists(output_path), + false => output_path, + }; + + let mut result = app + .build_client()? + .package_installer_service() + .get_zip_packages(&packages.iter().map(String::as_str).collect::>())?; + + let mut file = File::create(&output_path)?; + + std::io::copy(&mut result, &mut file)?; + + println!("{}", output_path.display()); + + Ok(()) + } +} diff --git a/src/cmd/app/pkg/fs/mod.rs b/src/cmd/app/pkg/fs/mod.rs new file mode 100644 index 0000000..b8d95a8 --- /dev/null +++ b/src/cmd/app/pkg/fs/mod.rs @@ -0,0 +1,79 @@ +use crate::cmd::app::{AppCommand, AppCommandArgs}; +use clap::Subcommand; +use std::error::Error; +use std::path::Path; +use thiserror::Error; +use walkdir::WalkDir; + +mod pull_pkg_fs; + +mod push_pkg_fs; + +#[derive(Debug, Subcommand)] +pub enum PkgFsCommands { + /// Unload package in current folder from Creatio database into filesystem and applies any configured transforms + Pull(pull_pkg_fs::PullPkgFsCommand), + + /// Load package in current folder from filesystem into Creatio database and optionally compiles it + Push(push_pkg_fs::PushPkgFsCommand), +} + +impl AppCommand for PkgFsCommands { + fn run(&self, app: &AppCommandArgs) -> Result<(), Box> { + match self { + PkgFsCommands::Pull(command) => command.run(app), + PkgFsCommands::Push(command) => command.run(app), + } + } +} + +#[derive(Debug, Error)] +#[error("prepare fs package folder failed: {0}")] +pub struct PreparePkgFsFolderError(#[from] std::io::Error); + +fn prepare_pkg_fs_folder(package_folder: impl AsRef) -> Result<(), PreparePkgFsFolderError> { + delete_empty_folders_in_package_schemas(package_folder)?; + + return Ok(()); + + fn delete_empty_folders_in_package_schemas( + package_folder: impl AsRef, + ) -> Result<(), std::io::Error> { + [ + crate::pkg::paths::ASSEMBLIES_FOLDER, + crate::pkg::paths::DATA_FOLDER, + crate::pkg::paths::RESOURCES_FOLDER, + crate::pkg::paths::SCHEMAS_FOLDER, + crate::pkg::paths::SQL_SCRIPTS_FOLDER, + ] + .into_iter() + .map(|p| package_folder.as_ref().join(p)) + .filter(|p| p.exists()) + .try_for_each(delete_empty_folders_in_folder)?; + + return Ok(()); + + fn delete_empty_folders_in_folder(folder: impl AsRef) -> Result<(), std::io::Error> { + for entry in folder.as_ref().read_dir()? { + let entry = entry?; + let path = entry.path(); + + if !path.is_dir() { + continue; + } + + let has_any_file_recursive = WalkDir::new(path) + .contents_first(true) + .into_iter() + .next() + .is_some_and(|x| x.is_ok_and(|x| x.file_type().is_file())); + + if !has_any_file_recursive { + std::fs::remove_dir_all(entry.path())?; + } + } + + Ok(()) + } + } +} diff --git a/src/cmd/app/pkg/fs/pull_pkg_fs.rs b/src/cmd/app/pkg/fs/pull_pkg_fs.rs new file mode 100644 index 0000000..c6eb54d --- /dev/null +++ b/src/cmd/app/pkg/fs/pull_pkg_fs.rs @@ -0,0 +1,48 @@ +use crate::cmd::app::pkg::fs::prepare_pkg_fs_folder; +use crate::cmd::app::{AppCommand, AppCommandArgs}; +use crate::cmd::cli::CliCommand; +use crate::pkg::utils::get_package_name_from_folder; +use clap::Args; +use std::error::Error; +use std::path::PathBuf; + +#[derive(Args, Debug)] +pub struct PullPkgFsCommand { + /// Package folder where package is already pulled previously (default: current directory) + /// (Sample: Terrasoft.Configuration/Pkg/.../) + #[arg(long, value_hint = clap::ValueHint::DirPath)] + package_folder: Option, + + #[command(flatten)] + apply_features: Option, +} + +impl AppCommand for PullPkgFsCommand { + fn run(&self, app: &AppCommandArgs) -> Result<(), Box> { + let package_folder = match &self.package_folder { + Some(f) => f, + None => &std::env::current_dir()?, + }; + + let package_name = get_package_name_from_folder(package_folder)?; + + prepare_pkg_fs_folder(package_folder)?; + + crate::cmd::app::fs::pull_fs::PullFsCommand { + packages: Some(vec![package_name.clone()]), + } + .run(app)?; + + eprintln!("Package {} pulled successfully!", &package_name); + + crate::cmd::pkg::apply::ApplyCommand { + package_folder: package_folder.to_owned(), + file: None, + apply_features: self.apply_features.clone(), + no_feature_present_warning_disabled: true, + } + .run()?; + + Ok(()) + } +} diff --git a/src/cmd/app/pkg/fs/push_pkg_fs.rs b/src/cmd/app/pkg/fs/push_pkg_fs.rs new file mode 100644 index 0000000..42f86e6 --- /dev/null +++ b/src/cmd/app/pkg/fs/push_pkg_fs.rs @@ -0,0 +1,54 @@ +use crate::cmd::app::pkg::fs::prepare_pkg_fs_folder; +use crate::cmd::app::{AppCommand, AppCommandArgs}; +use crate::pkg::utils::get_package_name_from_folder; +use clap::Args; +use std::error::Error; +use std::path::PathBuf; + +#[derive(Args, Debug)] +pub struct PushPkgFsCommand { + /// Package folder where package is already pulled previously (default: current directory) + /// (Sample: Terrasoft.Configuration/Pkg/.../) + #[arg(long, value_hint = clap::ValueHint::DirPath)] + package_folder: Option, + + /// Compile package in Creatio after successful push + #[arg(short, long)] + compile_package_after_push: bool, + + /// Restart application after successful package compilation in Creatio + #[arg(short, long)] + restart_app_after_compile: bool, +} + +impl AppCommand for PushPkgFsCommand { + fn run(&self, app: &AppCommandArgs) -> Result<(), Box> { + let destination_folder = match &self.package_folder { + Some(f) => f, + None => &std::env::current_dir()?, + }; + + let package_name = get_package_name_from_folder(destination_folder)?; + + prepare_pkg_fs_folder(destination_folder)?; + + crate::cmd::app::fs::push_fs::PushFsCommand { + packages: Some(vec![package_name.clone()]), + } + .run(app)?; + + eprintln!("Package {} pushed successfully!", &package_name); + + if self.compile_package_after_push { + eprintln!("Compiling package..."); + + crate::cmd::app::pkg::compile_pkg::CompilePkgCommand { + package_name: Some(package_name), + restart: self.restart_app_after_compile, + } + .run(app)?; + } + + Ok(()) + } +} diff --git a/src/cmd/app/pkg/get_uid_pkg.rs b/src/cmd/app/pkg/get_uid_pkg.rs new file mode 100644 index 0000000..781bc08 --- /dev/null +++ b/src/cmd/app/pkg/get_uid_pkg.rs @@ -0,0 +1,37 @@ +use crate::cmd::app::{AppCommand, AppCommandArgs}; +use clap::Args; +use std::error::Error; + +#[derive(Args, Debug)] +pub struct GetUidPkgCommand { + /// UId of the package. + #[arg(value_hint = clap::ValueHint::Other)] + package_uid: String, + + /// Display the output in JSON format. + #[arg(long)] + json: bool, +} + +impl AppCommand for GetUidPkgCommand { + fn run(&self, app: &AppCommandArgs) -> Result<(), Box> { + let package = app + .build_client()? + .package_service() + .get_package_properties(&self.package_uid)?; + + match &self.json { + true => println!("{}", serde_json::json!(package)), + false => { + println!("{} ({})", package.name, package.uid); + println!("| Id: {}", package.id); + println!("| Created on: {}", package.created_on); + println!("| Modified on: {}", package.modified_on); + println!("| Maintainer: {}", package.maintainer); + println!("| Type: {}", package.package_type); + } + } + + Ok(()) + } +} diff --git a/src/cmd/app/pkg/install_pkg.rs b/src/cmd/app/pkg/install_pkg.rs new file mode 100644 index 0000000..1a258ae --- /dev/null +++ b/src/cmd/app/pkg/install_pkg.rs @@ -0,0 +1,202 @@ +use crate::app::{CrtClient, CrtClientGenericError, InstallLogWatcher, InstallLogWatcherEvent}; +use crate::cmd::app::{AppCommand, AppCommandArgs}; +use clap::Args; +use std::error::Error; +use std::fs::File; +use std::io::{Read, Seek}; +use std::path::PathBuf; +use std::sync::Arc; +use thiserror::Error; + +#[derive(Args, Debug)] +pub struct InstallPkgCommand { + /// Path to the package archive file + #[arg(value_hint = clap::ValueHint::FilePath)] + filepath: PathBuf, + + #[command(flatten)] + install_pkg_options: InstallPkgCommandOptions, +} + +#[derive(Debug, Default, Args)] +pub struct InstallPkgCommandOptions { + /// Restart the Creatio application after successful installation + #[arg(short, long)] + restart: bool, + + /// Overrides changed schemas in the database: executes SQL to mark package schemas as not changed before installation + #[arg(short, long)] + force: bool, + + /// Same as -f but also clears localization data + #[arg(short = 'F', long)] + force_and_clear_localizations: bool, + + /// Clears existing schema content and checksums before installation + #[arg(long)] + clear_schemas_content: bool, + + /// Disables the display of the installation log + #[arg(long)] + disable_install_log_pooling: bool, +} + +#[derive(Debug, Error)] +pub enum InstallPkgCommandError { + #[error("failed to read package descriptors: {0}")] + ReadDescriptor(#[from] crate::pkg::utils::GetPackageDescriptorFromReaderError), + + #[error("failed to apply SQL options before package install: {0}")] + SqlBeforePackage(#[source] CrtClientGenericError), + + #[error("package descriptor.json was found, but the package uid value is null")] + PackageUidValueNull, + + #[error("failed to upload package: {0}")] + Upload(#[source] CrtClientGenericError), + + #[error("failed to install package: {0}")] + Install(#[source] CrtClientGenericError), + + #[error("failed to restart app: {0}")] + AppRestart(#[source] CrtClientGenericError), +} + +impl AppCommand for InstallPkgCommand { + fn run(&self, app: &AppCommandArgs) -> Result<(), Box> { + let client = Arc::new(app.build_client()?); + + install_package_from_stream_command( + client, + File::open(&self.filepath)?, + self.filepath + .file_name() + .ok_or("unable to get filename of specified path")? + .to_str() + .ok_or("unable to get filename str of specified path")?, + &self.install_pkg_options, + )?; + + Ok(()) + } +} + +pub fn install_package_from_stream_command( + client: Arc, + mut package_reader: impl Read + Send + Seek + 'static, + package_name: &str, + options: &InstallPkgCommandOptions, +) -> Result<(), InstallPkgCommandError> { + let descriptors = + crate::pkg::utils::get_package_descriptors_from_package_reader(&mut package_reader) + .map_err(InstallPkgCommandError::ReadDescriptor)?; + + apply_options_before_install(&client, options, &descriptors)?; + + client + .package_installer_service() + .upload_package(package_reader, package_name.to_owned()) + .map_err(InstallPkgCommandError::Upload)?; + + let log_watcher = (!options.disable_install_log_pooling).then(|| { + InstallLogWatcher::new(Arc::clone(&client)) + .with_handler(|event| match event { + InstallLogWatcherEvent::Clear() => {} + InstallLogWatcherEvent::Append(text) => print!("{text}"), + }) + .fetch_last_log_on_stop(true) + .start() + }); + + let install_result = client + .package_installer_service() + .install_package(package_name) + .map_err(InstallPkgCommandError::Install); + + if let Some(log_watcher) = log_watcher { + log_watcher.stop(); + log_watcher.wait_next_check_complete(); + } + + install_result?; + + if options.restart { + client + .app_installer_service() + .restart_app() + .map_err(InstallPkgCommandError::AppRestart)?; + + eprintln!("Application restart has been requested"); + + if !client.is_net_framework() { + eprintln!("Note: if restart does not work, please check if you need to use --net-framework flag"); + } + } + + return Ok(()); + + fn apply_options_before_install( + client: &Arc, + options: &InstallPkgCommandOptions, + descriptors: &Vec, + ) -> Result<(), InstallPkgCommandError> { + if options.force || options.force_and_clear_localizations { + for descriptor in descriptors { + let rows_affected = client + .sql_scripts() + .mark_package_as_not_changed( + descriptor + .uid() + .ok_or(InstallPkgCommandError::PackageUidValueNull)?, + ) + .map_err(InstallPkgCommandError::SqlBeforePackage)?; + + eprintln!( + "Package content {} has been marked as not changed, affected {} rows", + descriptor.name().unwrap_or("_"), + rows_affected + ); + } + } + + if options.force_and_clear_localizations { + for descriptor in descriptors { + let rows_affected = client + .sql_scripts() + .delete_package_localizations( + descriptor + .uid() + .ok_or(InstallPkgCommandError::PackageUidValueNull)?, + ) + .map_err(InstallPkgCommandError::SqlBeforePackage)?; + + eprintln!( + "Package localizations {} has been deleted, affected {} rows", + descriptor.name().unwrap_or("_"), + rows_affected + ); + } + } + + if options.clear_schemas_content { + for descriptor in descriptors { + let rows_affected = client + .sql_scripts() + .reset_schema_content( + descriptor + .uid() + .ok_or(InstallPkgCommandError::PackageUidValueNull)?, + ) + .map_err(InstallPkgCommandError::SqlBeforePackage)?; + + eprintln!( + "Schema content has been reset for package {}, affected {} rows", + descriptor.name().unwrap_or("_"), + rows_affected + ); + } + } + + Ok(()) + } +} diff --git a/src/cmd/app/pkg/lock_pkg.rs b/src/cmd/app/pkg/lock_pkg.rs new file mode 100644 index 0000000..e225600 --- /dev/null +++ b/src/cmd/app/pkg/lock_pkg.rs @@ -0,0 +1,25 @@ +use crate::cmd::app::{AppCommand, AppCommandArgs}; +use clap::Args; +use std::error::Error; + +#[derive(Args, Debug)] +pub struct LockPkgCommand { + /// Package name to lock + #[arg(value_hint = clap::ValueHint::Other)] + package_name: Option, +} + +impl AppCommand for LockPkgCommand { + fn run(&self, app: &AppCommandArgs) -> Result<(), Box> { + let package_name = detect_target_package_name!(&self.package_name); + + let result = app + .build_client()? + .sql_scripts() + .lock_package(package_name)?; + + eprintln!("Rows affected: {}", result); + + Ok(()) + } +} diff --git a/src/cmd/app/pkg/mod.rs b/src/cmd/app/pkg/mod.rs new file mode 100644 index 0000000..e7f815a --- /dev/null +++ b/src/cmd/app/pkg/mod.rs @@ -0,0 +1,81 @@ +use crate::cmd::app::{AppCommand, AppCommandArgs}; +use crate::pkg::utils::GetPackageNameFromFolderError; +use clap::Subcommand; +use std::error::Error; +use thiserror::Error; + +mod fs; + +mod compile_pkg; + +mod download_pkg; + +mod install_pkg; + +mod get_uid_pkg; + +mod pull_pkg; + +mod push_pkg; + +mod lock_pkg; + +mod unlock_pkg; + +#[derive(Debug, Subcommand)] +pub enum PkgCommands { + /// Compiles a specific package within the Creatio instance + Compile(compile_pkg::CompilePkgCommand), + + /// Downloads one or more packages from the Creatio instance as a zip archive + Download(download_pkg::DownloadPkgCommand), + + /// Commands/aliases to simplify manipulate with package insides File System Development mode (FSD) location + Fs { + #[command(subcommand)] + command: fs::PkgFsCommands, + }, + + /// Installs a package archive (.zip or .gz) into the Creatio instance + Install(install_pkg::InstallPkgCommand), + + /// Print installed package information by Package UId + GetUid(get_uid_pkg::GetUidPkgCommand), + + /// Execute SQL to make package locked if it is unlocked in Creatio + Lock(lock_pkg::LockPkgCommand), + + /// Downloads a package from Creatio, unpacks it to a destination folder, and applies configured transforms + Pull(pull_pkg::PullPkgCommand), + + /// Packs a package from a source folder and installs it into the Creatio instance + Push(push_pkg::PushPkgCommand), + + /// Execute SQL to make package unlocked if it is locked in Creatio + Unlock(unlock_pkg::UnlockPkgCommand), +} + +impl AppCommand for PkgCommands { + fn run(&self, app: &AppCommandArgs) -> Result<(), Box> { + match self { + PkgCommands::Compile(command) => command.run(app), + PkgCommands::Download(command) => command.run(app), + PkgCommands::Fs { command } => command.run(app), + PkgCommands::Install(command) => command.run(app), + PkgCommands::GetUid(command) => command.run(app), + PkgCommands::Lock(command) => command.run(app), + PkgCommands::Pull(command) => command.run(app), + PkgCommands::Push(command) => command.run(app), + PkgCommands::Unlock(command) => command.run(app), + } + } +} + +#[derive(Debug, Error)] +pub enum DetectTargetPackageNameError { + #[error("failed to detect package name in folder (also you can specify package name as argument): {0}")] + GetPackageNameFromFolder(#[from] GetPackageNameFromFolderError), + + #[error("failed to get valid current directory: {0}")] + GetCurrentDirError(#[from] std::io::Error), +} diff --git a/src/cmd/app/pkg/pull_pkg.rs b/src/cmd/app/pkg/pull_pkg.rs new file mode 100644 index 0000000..325add6 --- /dev/null +++ b/src/cmd/app/pkg/pull_pkg.rs @@ -0,0 +1,85 @@ +use crate::app::CrtClientGenericError; +use crate::cmd::app::pkg::DetectTargetPackageNameError; +use crate::cmd::app::{AppCommand, AppCommandArgs}; +use crate::cmd::pkg::config_file::CrtCliPkgConfig; +use crate::pkg::bundling::extractor::*; +use clap::Args; +use std::error::Error; +use std::io::Read; +use std::path::PathBuf; +use thiserror::Error; + +#[derive(Args, Debug)] +pub struct PullPkgCommand { + /// Package name to pull (default: package name in ./descriptor.json of destination folder) + #[arg(short, long = "package", value_hint = clap::ValueHint::Other)] + package_name: Option, + + /// Destination folder where package will be unpacked (default: current directory) + #[arg(short, long, value_hint = clap::ValueHint::DirPath)] + destination_folder: Option, + + #[command(flatten)] + apply_features: Option, +} + +#[derive(Debug, Error)] +pub enum PullPkgCommandError { + #[error("failed to get valid current directory (also you can specify --destination-folder arg): {0}")] + GetCurrentDir(#[source] std::io::Error), + + #[error("{0}")] + DetectPackageName(#[from] DetectTargetPackageNameError), + + #[error("cannot download package from remote: {0}")] + DownloadPackage(#[from] CrtClientGenericError), + + #[error("cannot unpack package: {0}")] + ExtractPackage(#[from] ExtractSingleZipPackageError), +} + +impl AppCommand for PullPkgCommand { + fn run(&self, app: &AppCommandArgs) -> Result<(), Box> { + let destination_folder = match &self.destination_folder { + Some(f) => f, + None => &std::env::current_dir().map_err(PullPkgCommandError::GetCurrentDir)?, + }; + + let pkg_config = CrtCliPkgConfig::from_package_folder(destination_folder)?; + + let apply_features = pkg_config + .map(|c| c.apply().combine(self.apply_features.as_ref())) + .or_else(|| self.apply_features.clone()) + .unwrap_or_default(); + + let package_name = detect_target_package_name!(self.package_name, destination_folder); + + let mut package = app + .build_client() + .map_err(PullPkgCommandError::DownloadPackage)? + .package_installer_service() + .get_zip_packages(&[package_name]) + .map_err(PullPkgCommandError::DownloadPackage)?; + + let mut package_data = vec![]; + + package.read_to_end(&mut package_data)?; + + let extract_config = PackageToFolderExtractorConfig::default() + .with_files_already_exists_in_folder_strategy( + FilesAlreadyExistsInFolderStrategy::SmartMerge, + ) + .print_merge_log(true) + .with_converter(apply_features.build_combined_converter()); + + extract_single_zip_package_to_folder( + std::io::Cursor::new(package_data), + destination_folder, + Some(package_name), + &extract_config, + ) + .map_err(PullPkgCommandError::ExtractPackage)?; + + Ok(()) + } +} diff --git a/src/cmd/app/pkg/push_pkg.rs b/src/cmd/app/pkg/push_pkg.rs new file mode 100644 index 0000000..2fbbf24 --- /dev/null +++ b/src/cmd/app/pkg/push_pkg.rs @@ -0,0 +1,103 @@ +use crate::cmd::app::pkg::install_pkg::*; +use crate::cmd::app::pkg::DetectTargetPackageNameError; +use crate::cmd::app::{AppCommand, AppCommandArgs}; +use crate::pkg::bundling::packer::*; +use clap::Args; +use flate2::Compression; +use std::error::Error; +use std::io::Cursor; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use thiserror::Error; + +#[derive(Args, Debug)] +pub struct PushPkgCommand { + /// Folders containing packages to be packed and installed (default: current directory) + #[arg(short = 's', long, value_name = "SOURCE_FOLDERS", value_hint = clap::ValueHint::DirPath)] + source_folder: Option>, + + #[command(flatten)] + install_pkg_options: InstallPkgCommandOptions, +} + +#[derive(Debug, Error)] +pub enum PushPkgCommandError { + #[error( + "failed to get valid current directory (also you can specify --package_folder arg): {0}" + )] + GetCurrentDir(#[source] std::io::Error), + + #[error("{0}")] + DetectPackageName(#[from] DetectTargetPackageNameError), + + #[error("cannot pack gzip package: {0}")] + PackGzipPackage(#[from] PackGzipPackageFromFolderError), + + #[error("cannot pack zip package: {0}")] + PackZipPackage(#[from] PackZipPackageFromFolderError), + + #[error("package installation failed: {0}")] + InstallPackage(#[from] InstallPkgCommandError), +} + +impl AppCommand for PushPkgCommand { + fn run(&self, app: &AppCommandArgs) -> Result<(), Box> { + let source_folder = match &self.source_folder { + Some(f) => f, + None => &vec![std::env::current_dir().map_err(PushPkgCommandError::GetCurrentDir)?], + }; + + let (package_filename, package_content) = match source_folder.len() { + 1 => pack_folder_as_gzip(source_folder.iter().next().unwrap())?, + _ => pack_folders_as_zip(source_folder)?, + }; + + let client = Arc::new(app.build_client()?); + + install_package_from_stream_command( + client, + std::io::Cursor::new(package_content), + &package_filename, + &self.install_pkg_options, + ) + .map_err(PushPkgCommandError::InstallPackage)?; + + return Ok(()); + + fn pack_folder_as_gzip(folder: &Path) -> Result<(String, Vec), Box> { + let package_name = detect_target_package_name!(None, folder); + let mut package_gzip = vec![]; + + pack_gzip_package_from_folder( + folder, + &mut package_gzip, + &GZipPackageFromFolderPackerConfig { + compression: Some(Compression::fast()), + }, + ) + .map_err(PushPkgCommandError::PackGzipPackage)?; + + Ok((format!("{package_name}.gz"), package_gzip)) + } + + fn pack_folders_as_zip( + source_folders: &Vec, + ) -> Result<(String, Vec), Box> { + let mut package_zip_cursor = Cursor::new(vec![]); + + pack_zip_package_from_folders( + source_folders, + &mut package_zip_cursor, + &ZipPackageFromFolderPackerConfig { + gzip_config: GZipPackageFromFolderPackerConfig { + compression: Some(Compression::fast()), + }, + zip_compression_method: Some(zip::CompressionMethod::Deflated), + }, + ) + .map_err(PushPkgCommandError::PackZipPackage)?; + + Ok(("Packages.zip".to_owned(), package_zip_cursor.into_inner())) + } + } +} diff --git a/src/cmd/app/pkg/unlock_pkg.rs b/src/cmd/app/pkg/unlock_pkg.rs new file mode 100644 index 0000000..568e0a1 --- /dev/null +++ b/src/cmd/app/pkg/unlock_pkg.rs @@ -0,0 +1,25 @@ +use crate::cmd::app::{AppCommand, AppCommandArgs}; +use clap::Args; +use std::error::Error; + +#[derive(Args, Debug)] +pub struct UnlockPkgCommand { + /// Name of the package to unlock + #[arg(value_hint = clap::ValueHint::Other)] + package_name: Option, +} + +impl AppCommand for UnlockPkgCommand { + fn run(&self, app: &AppCommandArgs) -> Result<(), Box> { + let package_name = detect_target_package_name!(&self.package_name); + + let result = app + .build_client()? + .sql_scripts() + .unlock_package(package_name)?; + + eprintln!("Rows affected: {}", result); + + Ok(()) + } +} diff --git a/src/cmd/app/pkgs.rs b/src/cmd/app/pkgs.rs new file mode 100644 index 0000000..25d8920 --- /dev/null +++ b/src/cmd/app/pkgs.rs @@ -0,0 +1,34 @@ +use crate::cmd::app::{AppCommand, AppCommandArgs}; +use clap::Args; +use std::error::Error; + +#[derive(Args, Debug)] +pub struct PkgsCommand { + /// Display the output in JSON format + #[arg(long)] + json: bool, +} + +impl AppCommand for PkgsCommand { + fn run(&self, app: &AppCommandArgs) -> Result<(), Box> { + let packages = app + .build_client()? + .workspace_explorer_service() + .get_packages()?; + + match self.json { + true => { + println!("{}", serde_json::json!(packages)) + } + false => { + for package in &packages { + println!("{package}"); + } + } + } + + eprintln!("Total: {} packages", packages.len()); + + Ok(()) + } +} diff --git a/src/cmd/app/request.rs b/src/cmd/app/request.rs new file mode 100644 index 0000000..c6b1b59 --- /dev/null +++ b/src/cmd/app/request.rs @@ -0,0 +1,187 @@ +use crate::app::{CrtClientGenericError, CrtRequestBuilderReauthorize}; +use crate::cmd::app::{AppCommand, AppCommandArgs}; +use clap::builder::{ValueParser, ValueParserFactory}; +use clap::Args; +use reqwest::blocking::Response; +use reqwest::Method; +use std::error::Error; +use std::fs::File; +use std::io::{stdin, BufRead, ErrorKind, Read}; +use std::path::PathBuf; +use thiserror::Error; + +#[derive(Args, Debug)] +pub struct RequestCommand { + /// HTTP method (e.g., GET, POST, PUT, DELETE, etc.) + #[arg(value_hint = clap::ValueHint::Other)] + method: Method, + + /// URL to request (can be absolute or relative to the Creatio base URL) + #[arg(value_hint = clap::ValueHint::Url)] + url: String, + + /// Send the request without authentication + #[arg(short, long)] + anonymous: bool, + + /// Request body data (for methods like POST). + #[arg(short, long, value_hint = clap::ValueHint::Other)] + data: Option, + + /// Read the request body data from standard input. Use a double Enter to signal the end of input + #[arg(short = 'D', long)] + data_stdin: bool, + + /// Add a custom header to the request (format: Key: Value). The default Content-Type is application/json + #[arg(short = 'H', long, value_hint = clap::ValueHint::Other, value_delimiter = ',')] + header: Vec, + + /// Save the response body to file + #[arg(short, long = "output", value_name = "FILE", value_hint = clap::ValueHint::FilePath)] + output_file: Option, +} + +#[derive(Debug, Clone)] +struct HeaderArg { + key: String, + value: String, +} + +#[derive(Error, Debug)] +enum HeaderArgParsingError { + #[error("value cannot be empty")] + EmptyValue, + + #[error("expected format is \"Key: Value\"")] + InvalidFormat, +} + +impl TryFrom<&str> for HeaderArg { + type Error = HeaderArgParsingError; + + fn try_from(value: &str) -> Result { + if value.is_empty() { + return Err(HeaderArgParsingError::EmptyValue); + } + + let header = value + .split_once(":") + .ok_or(HeaderArgParsingError::InvalidFormat)?; + + Ok(Self { + key: header.0.trim().to_owned(), + value: header.1.trim().to_owned(), + }) + } +} + +impl ValueParserFactory for HeaderArg { + type Parser = ValueParser; + + fn value_parser() -> Self::Parser { + ValueParser::new(|s: &str| HeaderArg::try_from(s)) + } +} + +impl AppCommand for RequestCommand { + fn run(&self, app: &AppCommandArgs) -> Result<(), Box> { + let url = self + .url + .strip_prefix(&app.url) + .unwrap_or(&self.url) + .trim_start_matches('/'); + + let data = match (&self.data, self.data_stdin) { + (Some(_), true) => return Err("you cannot use --data and --data-stdin arguments together, please select one of them".into()), + (None, false) => None, + (Some(str), false) => Some(str.clone()), + (None, true) => Some(read_data_from_stdin()?), + }; + + let client = app.build_client()?; + let mut request = client.request(self.method.clone(), url); + + for header in &self.header { + request = request.header(&header.key, &header.value); + } + + if !self + .header + .iter() + .any(|x| x.key.to_lowercase() == "content-type") + { + request = request.header("Content-Type", "application/json"); + } + + if let Some(data) = data { + request = request.body(data); + } + + let mut response = match self.anonymous { + true => request.send().map_err(CrtClientGenericError::from)?, + false => request + .send_with_session(&client) + .map_err(CrtClientGenericError::from)?, + }; + + eprintln!("Status: {}", response.status()); + + if let Some(location) = response.headers().get(reqwest::header::LOCATION) { + if let Ok(location) = location.to_str() { + eprintln!("Location: {location}"); + } + } + + match &self.output_file { + Some(output_file) => { + let mut file = File::create(output_file)?; + let bytes = std::io::copy(&mut response, &mut file)?; + + eprintln!("Content: Written {bytes} bytes"); + } + None => try_read_response_to_stdout(&mut response)?, + } + + return Ok(()); + + fn try_read_response_to_stdout(response: &mut Response) -> Result<(), Box> { + let mut response_str = String::new(); + + match response.read_to_string(&mut response_str) { + Ok(bytes) => { + eprintln!("Content: {bytes} bytes read"); + + if !response_str.is_empty() { + eprintln!(); + println!("{response_str}"); + } + }, + Err(err) if err.kind() == ErrorKind::InvalidData => return Err("response body seems like not valid utf8 string, consider to use --output-file parameter to save response body to file".into()), + Err(err) => return Err(err.into()), + } + + Ok(()) + } + + fn read_data_from_stdin() -> Result { + eprintln!("Please enter request data (body) below: "); + eprintln!("-=-=- -=-=- -=-=- -=-=- -=-=-"); + eprintln!(); + + let mut data = String::new(); + + loop { + if stdin().lock().read_line(&mut data)? == 1 { + break; + } + } + + data.truncate(data.len() - 2); + + eprintln!("-=-=- -=-=- -=-=- -=-=- -=-=-"); + eprintln!(); + + Ok(data) + } + } +} diff --git a/src/cmd/app/restart.rs b/src/cmd/app/restart.rs new file mode 100644 index 0000000..9cd11b7 --- /dev/null +++ b/src/cmd/app/restart.rs @@ -0,0 +1,22 @@ +use crate::cmd::app::{AppCommand, AppCommandArgs}; +use clap::Args; +use std::error::Error; + +#[derive(Args, Debug)] +pub struct RestartCommand; + +impl AppCommand for RestartCommand { + fn run(&self, app: &AppCommandArgs) -> Result<(), Box> { + let client = app.build_client()?; + + client.app_installer_service().restart_app()?; + + eprintln!("Application restart has been requested"); + + if !client.is_net_framework() { + eprintln!("Note: if restart does not work, please check if you need to use --net-framework flag"); + } + + Ok(()) + } +} diff --git a/src/cmd/app/sql.rs b/src/cmd/app/sql.rs new file mode 100644 index 0000000..b877024 --- /dev/null +++ b/src/cmd/app/sql.rs @@ -0,0 +1,88 @@ +use crate::app::sql::SqlRunner; +use crate::cmd::app::{AppCommand, AppCommandArgs}; +use clap::{Args, ValueEnum}; +use serde::Serialize; +use std::error::Error; +use std::io::{stdin, BufRead}; +use std::path::PathBuf; + +#[derive(Args, Debug)] +pub struct SqlCommand { + /// SQL query to execute + #[arg(value_hint = clap::ValueHint::Other)] + sql: Option, + + /// Read the SQL query from a file + #[arg(short, long, value_hint = clap::ValueHint::FilePath)] + file: Option, + + /// Specify the SQL runner to use (default: autodetect) + #[arg(long, value_enum)] + runner: Option, + + /// Display the results in JSON format + #[arg(long)] + json: bool, +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize, ValueEnum)] +enum SqlRunnerSelect { + Cliogate, + SqlConsole, +} + +impl AppCommand for SqlCommand { + fn run(&self, app: &AppCommandArgs) -> Result<(), Box> { + let sql = match (self.sql.as_ref(), self.file.as_ref()) { + (Some(_), Some(_)) => return Err("sql command and --file argument cannot be specified at the same time, consider to remove one of them".into()), + (Some(sql), None) => sql, + (None, Some(file)) => &std::fs::read_to_string(file)?, + (None, None) => &read_data_from_stdin()?, + }; + + let client = app.build_client()?; + + let result = match &self.runner { + None => client.sql(sql)?, + Some(SqlRunnerSelect::Cliogate) => { + crate::app::sql::ClioGateSqlRunner.sql(&client, sql)? + } + Some(SqlRunnerSelect::SqlConsole) => { + crate::app::sql::SqlConsoleSqlRunner.sql(&client, sql)? + } + }; + + if let Some(table) = result.table { + let mut buf = vec![]; + + table.serialize(&mut serde_json::Serializer::pretty(&mut buf))?; + + println!("{}", String::from_utf8(buf)?); + } else { + println!("Rows affected: {}", result.rows_affected); + } + + return Ok(()); + + fn read_data_from_stdin() -> Result { + eprintln!("Please enter SQL query below: "); + eprintln!("-=-=- -=-=- -=-=- -=-=- -=-=-"); + eprintln!(); + + let mut data = String::new(); + + loop { + if stdin().lock().read_line(&mut data)? == 1 { + break; + } + } + + data.truncate(data.len() - 2); + + eprintln!("-=-=- -=-=- -=-=- -=-=- -=-=-"); + eprintln!(); + + Ok(data) + } + } +} diff --git a/src/cmd/cli.rs b/src/cmd/cli.rs new file mode 100644 index 0000000..0d90bb5 --- /dev/null +++ b/src/cmd/cli.rs @@ -0,0 +1,166 @@ +use crate::cmd::app::{AppCommand, AppCommandArgs}; +use clap::{Command, CommandFactory, Parser, Subcommand}; +use regex::Regex; +use std::borrow::Cow; +use std::error::Error; +use std::io::Write; +use std::process::exit; + +#[derive(Parser, Debug)] +#[command( + version, + about, + author = "heabijay (heabijay@gmail.com)", + long_about = None)] +pub struct Cli { + /// Print debug-view of exception if it is occurred + #[arg(long, hide = true)] + debug: bool, + + /// Generate terminal completions config for your shell + #[arg(long, value_enum, value_name = "SHELL")] + completions: Option>, + + #[command(subcommand)] + command: Option, +} + +impl Cli { + pub fn run(self) -> Result<(), Box> { + if let Some(completions) = self.completions { + return run_completions_command(completions); + } + + match self.command { + None => { + Self::command().print_help()?; + exit(2); + } + Some(command) => command.run()?, + } + + Ok(()) + } + + pub fn debug(&self) -> bool { + self.debug + } +} + +pub trait CliCommand { + fn run(self) -> Result<(), Box>; +} + +#[derive(Debug, Subcommand)] +enum Commands { + /// Commands to interact with Creatio application instance + /// + /// This is the collection of subcommands that are related to concrete Creatio instance. + /// You should specify Creatio connection parameters like URL, USERNAME, PASSWORD through command arguments or you could set ENV variables (as well as create .env file) for better UX. + /// + /// Example use cases: + /// `crtcli app restart` -- Restarts Creatio instance. + /// `crtcli app pkg download CrtBase,CrtCore` -- Downloads CrtBase and CrtCore packages from Creatio to single zip file. + /// `crtcli app pkg push` -- Immediate packs current folder as package and installs it to Creatio instance. + #[clap(verbatim_doc_comment)] + App { + #[command(flatten)] + args: AppCommandArgs, + + #[command(subcommand)] + command: crate::cmd::app::AppCommands, + }, + + /// Commands for working with Creatio package files (.zip, .gz) or package folders locally + /// + /// This is the collection of subcommands that are related to package files and not related to any Creatio instance. + /// + /// Example use cases: + /// `crtcli pkg pack .` -- Packs current folder as package to single gzip/zip file. + /// `crtcli pkg apply . --apply-localization-cleanup 'en-US'` -- Deletes all localization files in current folder as package except en-US. + #[clap(verbatim_doc_comment)] + Pkg { + #[command(subcommand)] + command: crate::cmd::pkg::PkgCommands, + }, +} + +impl CliCommand for Commands { + fn run(self) -> Result<(), Box> { + match self { + Commands::App { args, command } => command.run(&args), + Commands::Pkg { command } => command.run(), + } + } +} + +fn print_completions(shell: clap_complete::Shell, cmd: &mut Command) { + match shell { + clap_complete::Shell::Fish => print_fish_completions(cmd), + _ => clap_complete::generate( + shell, + cmd, + cmd.get_name().to_string(), + &mut std::io::stdout(), + ), + } +} + +fn print_fish_completions(cmd: &mut Command) { + let mut completions = vec![]; + + clap_complete::generate( + clap_complete::Shell::Fish, + cmd, + cmd.get_name().to_string(), + &mut completions, + ); + + let completions_str = String::from_utf8_lossy(&mut completions); + let completions_str = postprocess_fish_completions(&completions_str); + + std::io::stdout() + .lock() + .write_all(completions_str.as_bytes()) + .unwrap(); + + return; + + fn postprocess_fish_completions<'a>(completions_str: &'a Cow) -> Cow<'a, str> { + return fix_app_subcommand_completions(completions_str); + + /// Patches suggestions for `crtcli app ...` subcommands for fish shell. + /// + /// Due to some limitations in clap_complete crate: + /// "fish completions currently only support named arguments (e.g. -o or –opt), not positional arguments." + /// Source: https://docs.rs/clap/latest/clap/enum.ValueHint.html#fnref1 ↩ + /// + /// This cause after use suggestions for `crtcli app ` you receive also file suggestions. + /// After this patch, you will receive only suggestions for `crtcli app ` for this. + fn fix_app_subcommand_completions<'a>(completions_str: &'a Cow) -> Cow<'a, str> { + let app_subcommand_completions_regex = Regex::new( + r#"(complete -c crtcli -n "__fish_crtcli_using_subcommand app; and not __fish_seen_subcommand_from .+?") -a ""# + ) + .unwrap(); // Due to Regex is called once per execution -- no need to make it static + + app_subcommand_completions_regex.replace_all(completions_str, "$1 -f -a \"") + } + } +} + +fn run_completions_command( + completions: Option, +) -> Result<(), Box> { + let shell = completions.or_else(clap_complete::Shell::from_env); + + if let Some(shell) = shell { + print_completions(shell, &mut Cli::command()); + + Ok(()) + } else { + Err( + "failed to detect shell, please specify shell in --completions [SHELL] arguments" + .into(), + ) + } +} diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs new file mode 100644 index 0000000..ef812bd --- /dev/null +++ b/src/cmd/mod.rs @@ -0,0 +1,8 @@ +mod cli; +pub use cli::Cli; + +mod app; + +mod pkg; + +mod utils; diff --git a/src/cmd/pkg/apply.rs b/src/cmd/pkg/apply.rs new file mode 100644 index 0000000..0bb7e94 --- /dev/null +++ b/src/cmd/pkg/apply.rs @@ -0,0 +1,173 @@ +use crate::cmd::cli::CliCommand; +use crate::cmd::pkg::config_file::CrtCliPkgConfig; +use crate::pkg::bundling; +use crate::pkg::converters::*; +use crate::pkg::utils::{walk_over_package_files, WalkOverPackageFilesContentError}; +use clap::Args; +use owo_colors::OwoColorize; +use serde::Deserialize; +use std::collections::HashSet; +use std::error::Error; +use std::path::PathBuf; +use thiserror::Error; + +#[derive(Debug, Args)] +pub struct ApplyCommand { + /// Path to the package folder + #[arg(value_hint = clap::ValueHint::DirPath)] + pub package_folder: PathBuf, + + #[command(flatten)] + pub apply_features: Option, + + /// Apply transforms only to a specific file within the package folder + #[arg(short = 'f', long, value_hint = clap::ValueHint::FilePath)] + pub file: Option, + + #[clap(skip)] + pub no_feature_present_warning_disabled: bool, +} + +#[derive(Args, Debug, Default, Deserialize, Clone)] +pub struct PkgApplyFeatures { + /// Sorts files like in the "Data/../*.json", "descriptor.json", ... by some property to simplify merge operations in Git, SVN, etc. + #[arg(short = 'S', long)] + #[serde(rename = "sorting")] + apply_sorting: Option, + + /// Removes localization files except for the specified cultures (comma-separated list). + /// Example: --apply-localization-cleanup "en-US,uk-UA" + #[arg( + short = 'L', + long, + value_name = "EXCEPT-LOCALIZATIONS", + value_delimiter = ',', + value_hint = clap::ValueHint::Other)] + #[serde(rename = "localization_cleanup")] + apply_localization_cleanup: Option>, +} + +impl PkgApplyFeatures { + pub fn combine(&self, other: Option<&PkgApplyFeatures>) -> PkgApplyFeatures { + PkgApplyFeatures { + apply_sorting: self + .apply_sorting + .or(other.as_ref().and_then(|x| x.apply_sorting)), + apply_localization_cleanup: self.apply_localization_cleanup.clone().or(other + .as_ref() + .and_then(|x| x.apply_localization_cleanup.clone())), + } + } + + pub fn build_combined_converter(&self) -> CombinedPkgFileConverter { + let mut combined = CombinedPkgFileConverter::new(); + + if self.apply_sorting.is_some_and(|x| x) { + combined.add(SortingPkgFileConverter); + } + + if let Some(localization_cultures) = &self.apply_localization_cleanup { + combined.add(LocalizationCleanupPkgFileConverter::new( + HashSet::from_iter(localization_cultures.iter().cloned()), + )); + } + + combined + } +} + +#[derive(Error, Debug)] +enum ApplyCommandError { + #[error("failed to access package file path: {0}")] + WalkOverPackageFilesContent(#[from] WalkOverPackageFilesContentError), + + #[error("unable to apply features to {0}: {1}")] + ApplyConverters(String, #[source] CombinedPkgFileConverterError), + + #[error("unable to change file {0}: {1}")] + FileChangeAccessError(PathBuf, #[source] std::io::Error), +} + +impl CliCommand for ApplyCommand { + fn run(self) -> Result<(), Box> { + let pkg_config = CrtCliPkgConfig::from_package_folder(&self.package_folder)?; + + let features = pkg_config + .map(|c| c.apply().combine(self.apply_features.as_ref())) + .or_else(|| self.apply_features.clone()); + + let features = match features { + Some(f) => f, + None if self.no_feature_present_warning_disabled => return Ok(()), + None => { + return Err( + "Please pass any feature(s) to apply like --apply-sorting, ... to continue" + .into(), + ) + } + }; + + let converter = features.build_combined_converter(); + + match &self.file { + None => { + for file in walk_over_package_files(self.package_folder.clone()) { + let file_path = file + .map_err(WalkOverPackageFilesContentError::FolderAccess) + .map_err(ApplyCommandError::WalkOverPackageFilesContent)?; + + apply_file(&self, &converter, file_path)?; + } + } + Some(for_single_file) => { + apply_file(&self, &converter, for_single_file.to_owned())?; + } + } + + return Ok(()); + + fn apply_file( + _self: &ApplyCommand, + converter: &CombinedPkgFileConverter, + file_path: PathBuf, + ) -> Result<(), Box> { + let relative_path = file_path + .strip_prefix(&_self.package_folder) + .unwrap_or(&file_path); + + if !converter.is_applicable(relative_path.to_str().unwrap()) { + return Ok(()); + } + + let file = + bundling::PkgGZipFile::open_fs_file_relative(&_self.package_folder, relative_path) + .map_err(|err| WalkOverPackageFilesContentError::FileAccess { + path: file_path.clone(), + source: err, + }) + .map_err(ApplyCommandError::WalkOverPackageFilesContent)?; + + let converted_content = converter + .convert(&file.get_escaped_filename(), file.content.clone()) + .map_err(|err| { + ApplyCommandError::ApplyConverters(relative_path.display().to_string(), err) + })?; + + if let Some(content) = converted_content { + if content != file.content { + std::fs::write(&file_path, content) + .map_err(|err| ApplyCommandError::FileChangeAccessError(file_path, err))?; + + eprintln!("\t{}\t{}", "modified:", file.filename); + } + } else { + std::fs::remove_file(&file_path) + .map_err(|err| ApplyCommandError::FileChangeAccessError(file_path, err))?; + + eprintln!("\t{}\t{}", "deleted:".red(), file.filename.red()); + } + + Ok(()) + } + } +} diff --git a/src/cmd/pkg/config_file.rs b/src/cmd/pkg/config_file.rs new file mode 100644 index 0000000..036e30e --- /dev/null +++ b/src/cmd/pkg/config_file.rs @@ -0,0 +1,49 @@ +use crate::cmd::pkg::PkgApplyFeatures; +use serde::Deserialize; +use std::path::Path; +use thiserror::Error; + +pub const PKG_CONFIG_FILENAME: &str = "package.crtcli.toml"; + +#[derive(Debug, Deserialize)] +pub struct CrtCliPkgConfig { + apply: PkgApplyFeatures, +} + +#[derive(Debug, Error)] +pub enum CrtCliPkgConfigError { + #[error("failed to read {} file: {}", PKG_CONFIG_FILENAME, .0)] + Read(#[from] std::io::Error), + + #[error("failed to parse {} config: {}", PKG_CONFIG_FILENAME, .0)] + Parse(#[from] toml::de::Error), +} + +impl CrtCliPkgConfig { + pub fn apply(&self) -> &PkgApplyFeatures { + &self.apply + } + + pub fn from_str(config_str: &str) -> Result { + let config: CrtCliPkgConfig = + toml::from_str(config_str).map_err(CrtCliPkgConfigError::Parse)?; + + Ok(config) + } + + pub fn from_package_folder( + package_folder: impl AsRef, + ) -> Result, CrtCliPkgConfigError> { + let config_filepath = package_folder.as_ref().join(PKG_CONFIG_FILENAME); + + match config_filepath.exists() { + false => Ok(None), + true => { + let config_str = + std::fs::read_to_string(config_filepath).map_err(CrtCliPkgConfigError::Read)?; + + Ok(Some(CrtCliPkgConfig::from_str(&config_str)?)) + } + } + } +} diff --git a/src/cmd/pkg/mod.rs b/src/cmd/pkg/mod.rs new file mode 100644 index 0000000..220eb8b --- /dev/null +++ b/src/cmd/pkg/mod.rs @@ -0,0 +1,37 @@ +use crate::cmd::cli::CliCommand; +use clap::Subcommand; +use std::error::Error; + +pub mod apply; +pub mod config_file; +mod pack; +mod unpack; +mod unpack_all; + +pub use apply::PkgApplyFeatures; + +#[derive(Debug, Subcommand)] +pub enum PkgCommands { + /// Applies transformations to the contents of a package folder + Apply(apply::ApplyCommand), + + /// Creates a package archive (.zip or .gz) from a package folder + Pack(pack::PackCommand), + + /// Extract a single package from a package archive (.zip or .gz) + Unpack(unpack::UnpackCommand), + + /// Extract all packages from a zip archive + UnpackAll(unpack_all::UnpackAllCommand), +} + +impl CliCommand for PkgCommands { + fn run(self) -> Result<(), Box> { + match self { + PkgCommands::Apply(command) => command.run(), + PkgCommands::Pack(command) => command.run(), + PkgCommands::Unpack(command) => command.run(), + PkgCommands::UnpackAll(command) => command.run(), + } + } +} diff --git a/src/cmd/pkg/pack.rs b/src/cmd/pkg/pack.rs new file mode 100644 index 0000000..6a16d17 --- /dev/null +++ b/src/cmd/pkg/pack.rs @@ -0,0 +1,107 @@ +use crate::cmd::cli::CliCommand; +use crate::pkg::bundling::packer::*; +use clap::{Args, ValueEnum}; +use std::error::Error; +use std::path::PathBuf; +use thiserror::Error; + +#[derive(Debug, Args)] +pub struct PackCommand { + /// Source folder containing the package to be packaged + #[arg(value_hint = clap::ValueHint::DirPath)] + package_folder: PathBuf, + + /// Destination folder where the output package archive will be saved (defaults to the current directory) + #[arg(short = 'f', long, value_hint = clap::ValueHint::DirPath)] + output_folder: Option, + + /// Filename of the output package archive file (optional, will be auto-generated if not specified) + #[arg(short = 'n', long, value_hint = clap::ValueHint::FilePath)] + output_filename: Option, + + #[arg(long, default_value = "zip")] + format: PackFormat, + + #[arg(long, default_value = "fast")] + compression: PackCompression, +} + +#[derive(Debug, Clone, Eq, PartialEq, ValueEnum)] +pub enum PackFormat { + Gzip, + Zip, +} + +#[derive(Debug, Clone, Eq, PartialEq, ValueEnum)] +pub enum PackCompression { + Fast, + Normal, + Best, +} + +#[derive(Error, Debug)] +enum PackCommandError { + #[error("failed to get valid current directory (also you can specify output_folder arg): {0}")] + GetCurrentDir(#[source] std::io::Error), + + #[error("failed to write output package bundle: {0}")] + WriteBundleFile(#[from] std::io::Error), +} + +impl CliCommand for PackCommand { + fn run(self) -> Result<(), Box> { + let output_folder = match self.output_folder { + Some(path) => path, + None => std::env::current_dir().map_err(PackCommandError::GetCurrentDir)?, + }; + + let output_filename = match &self.output_filename { + Some(filename) => filename, + None => { + let pkg_name = + crate::pkg::utils::get_package_name_from_folder(&self.package_folder)?; + + match self.format { + PackFormat::Gzip => &format!("{pkg_name}.gz"), + PackFormat::Zip => &crate::cmd::utils::generate_zip_package_filename(&pkg_name), + } + } + }; + + let output_path = output_folder.join(output_filename); + let output_path = match &self.output_filename.is_none() { + true => crate::cmd::utils::get_next_filename_if_exists(output_path), + false => output_path, + }; + + let gzip_config = GZipPackageFromFolderPackerConfig { + compression: match self.compression { + PackCompression::Fast => Some(flate2::Compression::fast()), + PackCompression::Normal => Some(flate2::Compression::default()), + PackCompression::Best => Some(flate2::Compression::best()), + }, + }; + + let zip_config = ZipPackageFromFolderPackerConfig { + gzip_config, + zip_compression_method: None, + }; + + let gzip_config = &zip_config.gzip_config; + + let file = std::fs::File::create(&output_path)?; + + match self.format { + PackFormat::Gzip => { + pack_gzip_package_from_folder(&self.package_folder, file, gzip_config)? + } + PackFormat::Zip => { + pack_single_zip_package_from_folder(&self.package_folder, file, &zip_config)? + } + } + + println!("{}", output_path.display()); + + Ok(()) + } +} diff --git a/src/cmd/pkg/unpack.rs b/src/cmd/pkg/unpack.rs new file mode 100644 index 0000000..c026bc5 --- /dev/null +++ b/src/cmd/pkg/unpack.rs @@ -0,0 +1,143 @@ +use crate::cmd::cli::CliCommand; +use crate::cmd::pkg::apply::PkgApplyFeatures; +use crate::cmd::pkg::config_file::CrtCliPkgConfig; +use crate::pkg::bundling::extractor::*; +use clap::Args; +use std::error::Error; +use std::io::{Seek, SeekFrom}; +use std::path::PathBuf; +use thiserror::Error; +use zip::result::ZipError; + +#[derive(Debug, Args)] +pub struct UnpackCommand { + /// Path to the package archive file + #[arg(value_hint = clap::ValueHint::FilePath)] + package_filepath: PathBuf, + + /// Destination folder where the extracted package files will be saved + #[arg(value_hint = clap::ValueHint::DirPath)] + destination_folder: Option, + + /// If the archive is a zip file containing multiple packages, specify the name of the package to extract. + #[arg(short, long = "package", value_hint = clap::ValueHint::Other)] + package_name: Option, + + /// If destination folder is not empty, attempt to merge package files (smart merge) + #[arg(short, long)] + merge: bool, + + #[command(flatten)] + apply_features: Option, +} + +#[derive(Error, Debug)] +enum UnpackCommandError { + #[error( + "failed to get valid current directory (also you can specify destination_folder arg): {0}" + )] + GetCurrentDir(#[source] std::io::Error), + + #[error("invalid package filename in path")] + InvalidPackageFilename(), + + #[error("failed to read the package file: {0}")] + ReadPackageFile(#[from] std::io::Error), + + #[error("failed to extract as single zip package: {0}")] + ExtractAsSingleZipPackage(#[from] ExtractSingleZipPackageError), + + #[error("failed to extract as gzip package: {0}")] + ExtractAsGzipPackage(#[from] ExtractGzipPackageError), + + #[error("failed to extract as single zip package: {single_zip_package_error} and failed to extract as gzip package: {gzip_package_error}")] + ExtractAsSingleZipOrGzipPackage { + single_zip_package_error: ExtractSingleZipPackageError, + + gzip_package_error: ExtractGzipPackageError, + }, + + #[error("multiple files found in zip package bundle, please specify file using -p argument or use 'unpack-all' command")] + MultipleFilesInZipPackage(), +} + +impl CliCommand for UnpackCommand { + fn run(self) -> Result<(), Box> { + let destination_folder = match self.destination_folder { + Some(folder) => folder, + None => { + let current_dir = + std::env::current_dir().map_err(UnpackCommandError::GetCurrentDir)?; + let filename = self + .package_filepath + .file_stem() + .ok_or(UnpackCommandError::InvalidPackageFilename())? + .to_str() + .ok_or(UnpackCommandError::InvalidPackageFilename())?; + + crate::cmd::utils::get_next_filename_if_exists(current_dir.join(filename)) + } + }; + + let pkg_config = CrtCliPkgConfig::from_package_folder(&destination_folder)?; + + let apply_features = pkg_config + .map(|c| c.apply().combine(self.apply_features.as_ref())) + .or(self.apply_features) + .unwrap_or_default(); + + let extractor_config = PackageToFolderExtractorConfig::default() + .with_files_already_exists_in_folder_strategy(match self.merge { + true => FilesAlreadyExistsInFolderStrategy::SmartMerge, + false => FilesAlreadyExistsInFolderStrategy::ThrowError, + }) + .with_converter(apply_features.build_combined_converter()); + + let mut file = std::fs::File::open(self.package_filepath) + .map_err(UnpackCommandError::ReadPackageFile)?; + + let zip_result = extract_single_zip_package_to_folder( + &file, + &destination_folder, + self.package_name.as_deref(), + &extractor_config, + ); + + let gzip_result: Option<_> = match &zip_result { + Err(ExtractSingleZipPackageError::MultiplePackageInZipFile) => { + return Err(UnpackCommandError::MultipleFilesInZipPackage().into()); + } + Err(ExtractSingleZipPackageError::OpenZipFileForReading(ZipError::InvalidArchive( + _, + ))) => { + file.seek(SeekFrom::Start(0)) + .map_err(UnpackCommandError::ReadPackageFile)?; + + let gzip_result = + extract_gzip_package_to_folder(file, &destination_folder, &extractor_config); + + Some(gzip_result) + } + _ => None, + }; + + let is_any_success = + zip_result.is_ok() || gzip_result.as_ref().map(|x| x.is_ok()).unwrap_or(false); + + if !is_any_success { + return if let Some(gzip_result) = gzip_result { + Err(UnpackCommandError::ExtractAsSingleZipOrGzipPackage { + single_zip_package_error: zip_result.unwrap_err(), + gzip_package_error: gzip_result.unwrap_err(), + } + .into()) + } else { + Err(UnpackCommandError::ExtractAsSingleZipPackage(zip_result.unwrap_err()).into()) + }; + } + + println!("{}", destination_folder.display()); + + Ok(()) + } +} diff --git a/src/cmd/pkg/unpack_all.rs b/src/cmd/pkg/unpack_all.rs new file mode 100644 index 0000000..02d319d --- /dev/null +++ b/src/cmd/pkg/unpack_all.rs @@ -0,0 +1,78 @@ +use crate::cmd::cli::CliCommand; +use crate::cmd::pkg::apply::PkgApplyFeatures; +use crate::pkg::bundling::extractor::*; +use clap::Args; +use std::error::Error; +use std::path::PathBuf; +use thiserror::Error; + +#[derive(Debug, Args)] +pub struct UnpackAllCommand { + /// Path to the zip package archive file + #[arg(value_hint = clap::ValueHint::FilePath)] + package_filepath: PathBuf, + + /// Destination folder where all extracted package files will be saved + #[arg(value_hint = clap::ValueHint::DirPath)] + destination_folder: Option, + + /// If destination folder is not empty, attempt to merge package files (smart merge) + #[arg(short, long)] + merge: bool, + + #[command(flatten)] + apply_features: PkgApplyFeatures, +} + +#[derive(Error, Debug)] +enum UnpackAllCommandError { + #[error( + "failed to get valid current directory (also you can specify destination_folder arg): {0}" + )] + GetCurrentDir(#[source] std::io::Error), + + #[error("invalid package filename in path")] + InvalidPackageFilename(), + + #[error("file access error: {0}")] + FileAccess(#[from] std::io::Error), + + #[error("{0}")] + ExtractZipPackage(#[from] ExtractZipPackageError), +} + +impl CliCommand for UnpackAllCommand { + fn run(self) -> Result<(), Box> { + let destination_folder = match self.destination_folder { + Some(folder) => folder, + None => { + let current_dir = + std::env::current_dir().map_err(UnpackAllCommandError::GetCurrentDir)?; + let filename = self + .package_filepath + .file_stem() + .ok_or(UnpackAllCommandError::InvalidPackageFilename())? + .to_str() + .ok_or(UnpackAllCommandError::InvalidPackageFilename())?; + + crate::cmd::utils::get_next_filename_if_exists(current_dir.join(filename)) + } + }; + + let file = std::fs::File::open(&self.package_filepath) + .map_err(UnpackAllCommandError::FileAccess)?; + let config = PackageToFolderExtractorConfig::default() + .with_files_already_exists_in_folder_strategy(match self.merge { + true => FilesAlreadyExistsInFolderStrategy::SmartMerge, + false => FilesAlreadyExistsInFolderStrategy::ThrowError, + }) + .with_converter(self.apply_features.build_combined_converter()); + + extract_zip_package_to_folder(file, &destination_folder, &config) + .map_err(UnpackAllCommandError::ExtractZipPackage)?; + + println!("{}", destination_folder.display()); + + Ok(()) + } +} diff --git a/src/cmd/utils.rs b/src/cmd/utils.rs new file mode 100644 index 0000000..bff3fe7 --- /dev/null +++ b/src/cmd/utils.rs @@ -0,0 +1,51 @@ +use std::path::PathBuf; +use time::macros::format_description; + +pub fn get_next_filename_if_exists(path_buf: PathBuf) -> PathBuf { + if !path_buf.exists() { + return path_buf; + }; + + if path_buf.file_name().is_none() { + return path_buf; + }; + + let (path_buf_stem, path_buf_extension) = { + let filename = path_buf.file_name().unwrap().to_str().unwrap(); + let ext_index = if path_buf.is_dir() { + None + } else { + filename.rfind('.') + }; + + if let Some(ext_index) = ext_index { + (&filename[..ext_index], &filename[ext_index..]) + } else { + (filename, "") + } + }; + + let mut i = 1; + loop { + let new_path = + path_buf.with_file_name(format!("{path_buf_stem}_{}{path_buf_extension}", i)); + + if !new_path.exists() { + return new_path; + } + + i += 1; + } +} + +pub fn generate_zip_package_filename(package_name: &str) -> String { + let now = time::OffsetDateTime::now_local().unwrap_or_else(|_| time::OffsetDateTime::now_utc()); + + let now_str = now + .format(format_description!( + "[year]-[month]-[day]_[hour].[minute].[second]" + )) + .expect("failed to format current time"); + + format!("{package_name}_{now_str}.zip") +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..838c59a --- /dev/null +++ b/src/main.rs @@ -0,0 +1,43 @@ +#[macro_use] +extern crate anstream; + +use clap::Parser; +use owo_colors::OwoColorize; +use std::process::ExitCode; + +mod app; +mod cmd; +mod pkg; +mod utils; + +fn main() -> ExitCode { + load_envs(); + + let cli: cmd::Cli = cmd::Cli::parse(); + let is_debug = cli.debug(); + + match cli.run() { + Ok(_) => ExitCode::SUCCESS, + Err(err) => { + eprintln!("{} {:#}", "Error:".red(), err.red()); + + if is_debug { + eprintln!(); + eprintln!("Error (Debug-view): {:?}", err); + } + + ExitCode::FAILURE + } + } +} + +fn load_envs() { + dotenvy::dotenv().ok(); + dotenvy::from_filename(".crtcli.env").ok(); + + if let Ok(env_filenames) = std::env::var("CRTCLI_LOAD_ENV_FILENAME") { + for env_filename in env_filenames.split(";").map(str::trim) { + dotenvy::from_filename(env_filename).ok(); + } + } +} diff --git a/src/pkg/bundling/extractor.rs b/src/pkg/bundling/extractor.rs new file mode 100644 index 0000000..a4461cc --- /dev/null +++ b/src/pkg/bundling/extractor.rs @@ -0,0 +1,370 @@ +use crate::pkg::bundling::utils::{ + remove_dir_all_files_predicate, validate_folder_is_empty, FolderIsEmptyValidationError, +}; +use crate::pkg::bundling::PkgGZipDecoder; +use crate::pkg::converters::*; +use crate::pkg::utils::contains_hidden_path; +use owo_colors::OwoColorize; +use std::collections::HashSet; +use std::io::{Read, Seek}; +use std::path::{Path, PathBuf}; +use thiserror::Error; +use zip::result::ZipError; +use zip::ZipArchive; + +#[derive(Error, Debug)] +pub enum ExtractGzipPackageError { + #[error("destination path {0} is a file, not a folder")] + DestinationPathIsFile(PathBuf), + + #[error("unable to access output folder {0}: {1}")] + AccessOutputFolder(PathBuf, #[source] std::io::Error), + + #[error("{0}")] + FolderIsNotEmpty(#[from] FolderIsEmptyValidationError), + + #[error("failure in decode package process: {0}")] + PkgGZipDecoder(#[from] crate::pkg::bundling::PkgGZipDecoderError), + + #[error("error occurred in apply pkg file conversion/feature: {0}")] + PkgFileConverterError(#[from] CombinedPkgFileConverterError), + + #[error("unable to extract parent folder or file destination path {0}")] + GetParentFolderOrDestinationPath(PathBuf), + + #[error("unable to create out folder or file {0}: {1}")] + CreateFolderOrFile(PathBuf, #[source] std::io::Error), + + #[error("failed to delete files during merge: {0}")] + DeleteFilesDuringMerge(#[source] std::io::Error), +} + +#[derive(Error, Debug)] +pub enum ExtractSingleZipPackageError { + #[error("unable to open zip file for reading: {0}")] + OpenZipFileForReading(#[source] ZipError), + + #[error("unable to get gzip file in zip: {0}")] + GetGZipInZip(#[source] ZipError), + + #[error("multiple package in zip file was found when extracting single gzip package. Consider to specify package filename parameter or use extract_zip_package_to_folder method instead")] + MultiplePackageInZipFile, + + #[error("unable to extract gzip package ({filename}): {source}")] + ExtractGZipPackage { + filename: String, + #[source] + source: ExtractGzipPackageError, + }, +} + +#[derive(Error, Debug)] +pub enum ExtractZipPackageError { + #[error("{0}")] + FolderIsNotEmpty(#[from] FolderIsEmptyValidationError), + + #[error("unable to open zip file for reading: {0}")] + OpenZipFileForReading(#[source] ZipError), + + #[error("unable to get gzip file in zip: {0}")] + GetGZipInZip(#[source] ZipError), + + #[error("unable to extract gzip package ({filename}): {source}")] + ExtractGZipPackage { + filename: String, + #[source] + source: ExtractGzipPackageError, + }, +} + +#[derive(Default, Eq, PartialEq, Debug, Copy, Clone)] +pub enum FilesAlreadyExistsInFolderStrategy { + #[default] + ThrowError, + SmartMerge, +} + +#[derive(Default, Debug)] +pub struct PackageToFolderExtractorConfig { + files_already_exists_in_folder_strategy: FilesAlreadyExistsInFolderStrategy, + + file_converter: CombinedPkgFileConverter, + + print_merge_log: bool, +} + +impl PackageToFolderExtractorConfig { + pub fn with_files_already_exists_in_folder_strategy( + mut self, + strategy: FilesAlreadyExistsInFolderStrategy, + ) -> Self { + self.files_already_exists_in_folder_strategy = strategy; + self + } + + pub fn with_converter(mut self, converter: CombinedPkgFileConverter) -> Self { + self.file_converter = converter; + self + } + + pub fn print_merge_log(mut self, value: bool) -> Self { + self.print_merge_log = value; + self + } +} + +struct SmartMergeContext { + destination_folder: PathBuf, + files: HashSet, +} + +impl SmartMergeContext { + pub fn new(destination_folder: PathBuf) -> Self { + Self { + destination_folder, + files: HashSet::new(), + } + } + + pub fn new_if_needed( + destination_folder: &Path, + config: &PackageToFolderExtractorConfig, + ) -> Option { + (config.files_already_exists_in_folder_strategy + == FilesAlreadyExistsInFolderStrategy::SmartMerge) + .then(|| Self::new(destination_folder.to_path_buf())) + } + + pub fn execute(self, config: &PackageToFolderExtractorConfig) -> Result<(), std::io::Error> { + let pkg_folders = crate::pkg::paths::PKG_FOLDERS + .iter() + .map(|&p| self.destination_folder.join(p)) + .filter(|p| p.exists()); + + for folder in pkg_folders { + remove_dir_all_files_predicate(&folder, |f| { + let path = f.path(); + let path_without_dest = path.strip_prefix(&self.destination_folder).unwrap(); + let result = !self.files.contains(path) && !contains_hidden_path(path_without_dest); + + if result && config.print_merge_log { + eprintln!( + "\t{}\t{}", + "deleted:".red(), + path_without_dest.display().red() + ); + } + + result + })?; + } + + Ok(()) + } +} + +pub fn extract_gzip_package_to_folder( + gzip_reader: impl Read, + destination_folder: &Path, + config: &PackageToFolderExtractorConfig, +) -> Result<(), ExtractGzipPackageError> { + prepare_destination_folder(destination_folder, config)?; + + let mut smart_merge_ctx = SmartMergeContext::new_if_needed(destination_folder, config); + + let decoder = PkgGZipDecoder::from(gzip_reader); + + for file in decoder { + let file = file?; + let filename = &file.get_escaped_filename(); + let file_content = config.file_converter.convert(filename, file.content)?; + + if file_content.is_none() { + continue; + } + + let file_content = file_content.unwrap(); + + let destination_path = destination_folder.join(filename); + let destination_path_parent = destination_path.parent().ok_or_else(|| { + ExtractGzipPackageError::GetParentFolderOrDestinationPath( + destination_path.to_path_buf(), + ) + })?; + + if !destination_path_parent.exists() { + std::fs::create_dir_all(destination_path_parent).map_err(|err| { + ExtractGzipPackageError::AccessOutputFolder( + destination_path_parent.to_path_buf(), + err, + ) + })?; + } + + if should_write_to_file( + filename, + destination_path_parent, + &destination_path, + &file_content, + config, + )? { + std::fs::write(&destination_path, &file_content).map_err(|err| { + ExtractGzipPackageError::CreateFolderOrFile(destination_path.to_path_buf(), err) + })?; + } + + if let Some(x) = smart_merge_ctx.as_mut() { + x.files.insert(destination_path); + } + } + + smart_merge_ctx.map(|ctx| { + ctx.execute(config) + .map_err(ExtractGzipPackageError::DeleteFilesDuringMerge) + }); + + return Ok(()); + + fn prepare_destination_folder( + destination_folder: &Path, + config: &PackageToFolderExtractorConfig, + ) -> Result<(), ExtractGzipPackageError> { + if destination_folder.is_file() { + return Err(ExtractGzipPackageError::DestinationPathIsFile( + destination_folder.to_path_buf(), + )); + } + + if config.files_already_exists_in_folder_strategy + == FilesAlreadyExistsInFolderStrategy::ThrowError + { + validate_folder_is_empty(destination_folder)?; + } + + if !destination_folder.exists() { + std::fs::create_dir_all(destination_folder).map_err(|err| { + ExtractGzipPackageError::AccessOutputFolder(destination_folder.to_path_buf(), err) + })?; + } + + Ok(()) + } + + fn should_write_to_file( + relative_path: &str, + destination_path_parent: &Path, + destination_path: &Path, + content: &Vec, + config: &PackageToFolderExtractorConfig, + ) -> Result { + if !destination_path.exists() { + if config.print_merge_log { + eprintln!("\t{}\t{}", "created:".green(), relative_path.green()); + } + + return Ok(true); + } + + match config.files_already_exists_in_folder_strategy { + FilesAlreadyExistsInFolderStrategy::ThrowError => { + Err(ExtractGzipPackageError::FolderIsNotEmpty( + FolderIsEmptyValidationError::FilesAlreadyExistsInFolder { + folder_path: destination_path_parent.to_path_buf(), + }, + )) + } + FilesAlreadyExistsInFolderStrategy::SmartMerge => { + let result = std::fs::read(destination_path) + .ok() + .map_or(false, |exists_content| exists_content != *content); + + if result && config.print_merge_log { + eprintln!("\t{}\t{}", "modified:", relative_path); + } + + Ok(result) + } + } + } +} + +pub fn extract_single_zip_package_to_folder( + zip_reader: impl Read + Seek, + destination_folder: &Path, + package_name: Option<&str>, + config: &PackageToFolderExtractorConfig, +) -> Result<(), ExtractSingleZipPackageError> { + let mut zip = + ZipArchive::new(zip_reader).map_err(ExtractSingleZipPackageError::OpenZipFileForReading)?; + + let gzip = match package_name { + Some(package_name) => zip_get_file_by_package_name(&mut zip, package_name) + .map_err(ExtractSingleZipPackageError::GetGZipInZip)?, + None => { + if zip.len() > 1 { + return Err(ExtractSingleZipPackageError::MultiplePackageInZipFile); + } + + zip.by_index(0) + .map_err(ExtractSingleZipPackageError::GetGZipInZip)? + } + }; + + let gzip_filename = gzip.name().to_owned(); + + return extract_gzip_package_to_folder(gzip, destination_folder, config).map_err(|err| { + ExtractSingleZipPackageError::ExtractGZipPackage { + filename: gzip_filename, + source: err, + } + }); + + fn zip_get_file_by_package_name<'a, R: Read + Seek>( + zip: &'a mut ZipArchive, + package_name: &str, + ) -> Result, ZipError> { + let index = zip + .index_for_name(package_name) + .or_else(|| zip.index_for_name(&format!("{package_name}.gz"))) + .ok_or(ZipError::FileNotFound)?; + + zip.by_index(index) + } +} + +pub fn extract_zip_package_to_folder( + reader: impl Read + Seek, + destination_folder: &Path, + config: &PackageToFolderExtractorConfig, +) -> Result<(), ExtractZipPackageError> { + let mut zip = ZipArchive::new(reader).map_err(ExtractZipPackageError::OpenZipFileForReading)?; + + if config.files_already_exists_in_folder_strategy + == FilesAlreadyExistsInFolderStrategy::ThrowError + { + validate_folder_is_empty(destination_folder)?; + } + + for i in 0..zip.len() { + let gzip = zip + .by_index(i) + .map_err(ExtractZipPackageError::GetGZipInZip)?; + + let gzip_filename = gzip + .name() + .strip_suffix(".gz") + .unwrap_or(gzip.name()) + .to_owned(); + + let destination_folder = destination_folder.join(&gzip_filename); + + extract_gzip_package_to_folder(gzip, destination_folder.as_path(), config).map_err( + |err| ExtractZipPackageError::ExtractGZipPackage { + filename: gzip_filename, + source: err, + }, + )?; + } + + Ok(()) +} diff --git a/src/pkg/bundling/mod.rs b/src/pkg/bundling/mod.rs new file mode 100644 index 0000000..cc79b65 --- /dev/null +++ b/src/pkg/bundling/mod.rs @@ -0,0 +1,14 @@ +mod utils; + +mod pkg_gzip_decoder; +pub use pkg_gzip_decoder::*; + +mod pkg_gzip_encoder; +pub use pkg_gzip_encoder::*; + +mod pkg_gzip_file; +pub use pkg_gzip_file::*; + +pub mod extractor; + +pub mod packer; diff --git a/src/pkg/bundling/packer.rs b/src/pkg/bundling/packer.rs new file mode 100644 index 0000000..5e7bcad --- /dev/null +++ b/src/pkg/bundling/packer.rs @@ -0,0 +1,91 @@ +use crate::pkg::bundling::{PkgGZipEncoder, PkgGZipEncoderError}; +use crate::pkg::utils::{walk_over_package_files_content, WalkOverPackageFilesContentError}; +use flate2::Compression; +use std::io::{Seek, Write}; +use std::path::Path; +use thiserror::Error; +use zip::write::SimpleFileOptions; +use zip::{CompressionMethod, ZipWriter}; + +#[derive(Error, Debug)] +pub enum PackGzipPackageFromFolderError { + #[error("unable to walk over package files: {0}")] + WalkOverPackageFilesContent(#[from] WalkOverPackageFilesContentError), + + #[error("error during encoding gzip: {0}")] + PkgGZipEncoder(#[from] PkgGZipEncoderError), +} + +#[derive(Error, Debug)] +pub enum PackZipPackageFromFolderError { + #[error("unable to access folder: {0}")] + FolderAccess(#[from] walkdir::Error), + + #[error("failed to detect package name from package folder: {0}")] + DetectPackageName(#[from] crate::pkg::utils::GetPackageNameFromFolderError), + + #[error("zip error occurred: {0}")] + Zip(#[from] zip::result::ZipError), + + #[error("failed to create gzip package: {0}")] + PackGzipPackageFromFolder(#[from] PackGzipPackageFromFolderError), +} + +#[derive(Debug, Clone, Default, Eq, PartialEq)] +pub struct GZipPackageFromFolderPackerConfig { + pub compression: Option, +} + +#[derive(Debug, Clone, Default, Eq, PartialEq)] +pub struct ZipPackageFromFolderPackerConfig { + pub zip_compression_method: Option, + pub gzip_config: GZipPackageFromFolderPackerConfig, +} + +pub fn pack_gzip_package_from_folder( + pkg_folder: &Path, + gzip_writer: impl Write, + config: &GZipPackageFromFolderPackerConfig, +) -> Result<(), PackGzipPackageFromFolderError> { + let mut encoder = PkgGZipEncoder::new(gzip_writer, config.compression); + + for pkg_file in walk_over_package_files_content(pkg_folder.to_path_buf()) { + encoder.write(&pkg_file?)?; + } + + Ok(()) +} + +pub fn pack_zip_package_from_folders>( + pkg_folders: impl AsRef<[P]>, + zip_writer: impl Write + Seek, + config: &ZipPackageFromFolderPackerConfig, +) -> Result<(), PackZipPackageFromFolderError> { + let mut zip = ZipWriter::new(zip_writer); + let zip_file_options = SimpleFileOptions::default().compression_method( + config + .zip_compression_method + .unwrap_or(CompressionMethod::Stored), + ); + + for pkg_folder in pkg_folders.as_ref() { + let filename = format!( + "{pkg_name}.gz", + pkg_name = crate::pkg::utils::get_package_name_from_folder(pkg_folder.as_ref())? + ); + + zip.start_file(filename, zip_file_options)?; + + pack_gzip_package_from_folder(pkg_folder.as_ref(), &mut zip, &config.gzip_config)?; + } + + Ok(()) +} + +pub fn pack_single_zip_package_from_folder( + pkg_folder: impl AsRef, + zip_writer: impl Write + Seek, + config: &ZipPackageFromFolderPackerConfig, +) -> Result<(), PackZipPackageFromFolderError> { + pack_zip_package_from_folders(&[pkg_folder], zip_writer, config) +} diff --git a/src/pkg/bundling/pkg_gzip_decoder.rs b/src/pkg/bundling/pkg_gzip_decoder.rs new file mode 100644 index 0000000..2227480 --- /dev/null +++ b/src/pkg/bundling/pkg_gzip_decoder.rs @@ -0,0 +1,100 @@ +use crate::pkg::bundling::utils::{read_ascii_string_with_len, ReadAsciiStringWithLenError}; +use crate::pkg::bundling::PkgGZipFile; +use flate2::read::GzDecoder; +use std::io::{BufReader, ErrorKind, Read}; +use thiserror::Error; +use zip::unstable::LittleEndianReadExt; + +pub struct PkgGZipDecoder { + gz_decoder: BufReader>, +} + +#[derive(Error, Debug)] +pub enum PkgGZipDecoderError { + #[error("failed to read filename size: {0}")] + FilenameSize(#[source] std::io::Error), + + #[error("failed to read filename: {0}")] + Filename(#[source] ReadAsciiStringWithLenError), + + #[error("failed to read file content size: {0}")] + FileContentSize(#[source] std::io::Error), + + #[error("failed to read file content: {0}")] + FileContent(#[source] std::io::Error), +} + +impl PkgGZipDecoder { + pub fn new(gz_decoder: GzDecoder) -> Self { + Self::from(gz_decoder) + } + + fn next_as_filename(&mut self) -> Result, PkgGZipDecoderError> { + let filename_size = match self.gz_decoder.read_u32_le() { + Ok(size) => size, + Err(err) if err.kind() == ErrorKind::UnexpectedEof => return Ok(None), + Err(err) => return Err(PkgGZipDecoderError::FilenameSize(err)), + }; + + let string = read_ascii_string_with_len(&mut self.gz_decoder, filename_size) + .map_err(PkgGZipDecoderError::Filename)?; + + Ok(Some(string)) + } + + fn next_as_content_vec(&mut self) -> Result, PkgGZipDecoderError> { + let content_size = self + .gz_decoder + .read_u32_le() + .map_err(PkgGZipDecoderError::FileContentSize)?; + + let mut content = vec![0u8; content_size as usize]; + + self.gz_decoder + .read_exact(&mut content) + .map_err(PkgGZipDecoderError::FileContent)?; + + Ok(content) + } +} + +impl From> for PkgGZipDecoder { + fn from(value: GzDecoder) -> Self { + Self { + gz_decoder: BufReader::new(value), + } + } +} + +impl From for PkgGZipDecoder { + fn from(value: R) -> Self { + Self { + gz_decoder: BufReader::new(GzDecoder::new(value)), + } + } +} + +impl From>> for PkgGZipDecoder { + fn from(value: BufReader>) -> Self { + Self { gz_decoder: value } + } +} + +impl Iterator for PkgGZipDecoder { + type Item = Result; + + fn next(&mut self) -> Option { + let filename = match self.next_as_filename() { + Ok(None) => return None, + Ok(Some(filename)) => filename, + Err(err) => return Some(Err(err)), + }; + + let content = match self.next_as_content_vec() { + Ok(content) => content, + Err(err) => return Some(Err(err)), + }; + + Some(Ok(PkgGZipFile { filename, content })) + } +} diff --git a/src/pkg/bundling/pkg_gzip_encoder.rs b/src/pkg/bundling/pkg_gzip_encoder.rs new file mode 100644 index 0000000..2260b5f --- /dev/null +++ b/src/pkg/bundling/pkg_gzip_encoder.rs @@ -0,0 +1,83 @@ +use crate::pkg::bundling::utils::write_ascii_string_with_len; +use crate::pkg::bundling::PkgGZipFile; +use flate2::write::GzEncoder; +use flate2::Compression; +use std::io::{BufWriter, Write}; +use thiserror::Error; +use zip::unstable::LittleEndianWriteExt; + +pub struct PkgGZipEncoder { + gz_encoder: BufWriter>, +} + +#[derive(Error, Debug)] +pub enum PkgGZipEncoderError { + #[error("failed to write filename size: {0}")] + FilenameSize(#[source] std::io::Error), + + #[error("failed to write filename: {0}")] + Filename(#[source] std::io::Error), + + #[error("failed to write content size: {0}")] + ContentSize(#[source] std::io::Error), + + #[error("failed to write content: {0}")] + Content(#[source] std::io::Error), +} + +impl PkgGZipEncoder { + pub fn new(writer: W, compression: Option) -> Self { + Self::from(GzEncoder::new( + writer, + compression.unwrap_or(Compression::fast()), + )) + } + + fn write_as_filename(&mut self, filename: &str) -> Result<(), PkgGZipEncoderError> { + self.gz_encoder + .write_u32_le(filename.len() as u32) + .map_err(PkgGZipEncoderError::FilenameSize)?; + write_ascii_string_with_len(&mut self.gz_encoder, filename) + .map_err(PkgGZipEncoderError::Filename)?; + + Ok(()) + } + + fn write_as_content(&mut self, content: &[u8]) -> Result<(), PkgGZipEncoderError> { + self.gz_encoder + .write_u32_le(content.len() as u32) + .map_err(PkgGZipEncoderError::ContentSize)?; + self.gz_encoder + .write_all(content) + .map_err(PkgGZipEncoderError::Content)?; + + Ok(()) + } + + pub fn write(&mut self, file: &PkgGZipFile) -> Result<(), PkgGZipEncoderError> { + self.write_as_filename(&file.filename)?; + self.write_as_content(&file.content)?; + + Ok(()) + } +} + +impl From> for PkgGZipEncoder { + fn from(value: GzEncoder) -> Self { + Self { + gz_encoder: BufWriter::new(value), + } + } +} + +impl From>> for PkgGZipEncoder { + fn from(value: BufWriter>) -> Self { + Self { gz_encoder: value } + } +} + +impl From for PkgGZipEncoder { + fn from(value: W) -> Self { + Self::new(value, None) + } +} diff --git a/src/pkg/bundling/pkg_gzip_file.rs b/src/pkg/bundling/pkg_gzip_file.rs new file mode 100644 index 0000000..47b32c3 --- /dev/null +++ b/src/pkg/bundling/pkg_gzip_file.rs @@ -0,0 +1,43 @@ +use std::fmt::{Display, Formatter}; +use std::path::Path; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PkgGZipFile { + pub filename: String, + pub content: Vec, +} + +impl PkgGZipFile { + pub fn get_escaped_filename(&self) -> String { + self.filename + .replace(['/', '\\'], std::path::MAIN_SEPARATOR_STR) + } + + pub fn open_fs_file_relative( + pkg_path: impl AsRef, + relative_path: impl AsRef, + ) -> Result { + Ok(Self { + filename: relative_path.as_ref().to_str().unwrap().to_owned(), + content: std::fs::read(pkg_path.as_ref().join(relative_path))?, + }) + } + + pub fn open_fs_file_absolute( + pkg_path: impl AsRef, + absolute_path: impl AsRef, + ) -> Result { + let relative = absolute_path + .as_ref() + .strip_prefix(pkg_path.as_ref()) + .unwrap(); + + Self::open_fs_file_relative(pkg_path, relative) + } +} + +impl Display for PkgGZipFile { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{} ({} bytes)", self.filename, self.content.len()) + } +} diff --git a/src/pkg/bundling/utils.rs b/src/pkg/bundling/utils.rs new file mode 100644 index 0000000..76d1e1f --- /dev/null +++ b/src/pkg/bundling/utils.rs @@ -0,0 +1,141 @@ +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use thiserror::Error; +use walkdir::WalkDir; + +#[derive(Error, Debug)] +pub enum ReadAsciiStringWithLenError { + #[error("reader failure: {0}")] + Read(#[from] std::io::Error), + + #[error("invalid UTF-8 sequence at {index} byte - {byte:x} in {segment:?} (as str \"{}\")", String::from_utf8(segment.clone()).unwrap_or("[error]".to_owned()))] + InvalidUtf8SequenceInAsciiString { + index: usize, + byte: u8, + segment: Vec, + }, + + #[error("error while converting byte array to UTF-8 string")] + StringFromUtf8(#[from] std::string::FromUtf8Error), +} + +pub fn decode_ascii_string_from_byte_array( + bytes: Vec, +) -> Result { + let mut out = vec![0u8; bytes.len() / 2]; + + for (index, &byte) in bytes.iter().enumerate() { + if index % 2 == 0 { + out[index / 2] = byte; + continue; + } + + if byte != 0 { + return Err( + ReadAsciiStringWithLenError::InvalidUtf8SequenceInAsciiString { + index, + byte, + segment: bytes.clone(), + }, + ); + } + } + + String::from_utf8(out).map_err(ReadAsciiStringWithLenError::StringFromUtf8) +} + +pub fn encode_ascii_string_to_byte_array(string: &str) -> Vec { + let mut buffer = vec![0u8; string.len() * 2]; + + for (index, &byte) in string.as_bytes().iter().enumerate() { + buffer[index * 2] = byte; + } + + buffer +} + +pub fn read_ascii_string_with_len( + reader: &mut impl Read, + len: u32, +) -> Result { + let mut buffer = vec![0u8; ((len) * 2) as usize]; + + reader + .read_exact(&mut buffer) + .map_err(ReadAsciiStringWithLenError::Read)?; + + decode_ascii_string_from_byte_array(buffer) +} + +pub fn write_ascii_string_with_len( + writer: &mut impl Write, + string: &str, +) -> Result<(), std::io::Error> { + let buffer = encode_ascii_string_to_byte_array(string); + + writer.write_all(&buffer)?; + + Ok(()) +} + +#[derive(Error, Debug)] +pub enum FolderIsEmptyValidationError { + #[error("unable to access folder {}: {}", .0.display(), 1)] + AccessDenied(PathBuf, #[source] std::io::Error), + + #[error("folder \"{folder_path}\" is not empty, consider to use merge or select empty folder")] + FilesAlreadyExistsInFolder { folder_path: PathBuf }, +} + +pub fn validate_folder_is_empty( + destination_folder: &Path, +) -> Result<(), FolderIsEmptyValidationError> { + if destination_folder.exists() { + let has_entry = std::fs::read_dir(destination_folder) + .map_err(|err| { + FolderIsEmptyValidationError::AccessDenied(destination_folder.to_path_buf(), err) + })? + .next() + .is_some(); + + if has_entry { + return Err(FolderIsEmptyValidationError::FilesAlreadyExistsInFolder { + folder_path: destination_folder.to_path_buf(), + }); + } + } + + Ok(()) +} + +pub fn remove_dir_all_files_predicate( + path: &Path, + delete_file_predicate: impl Fn(&walkdir::DirEntry) -> bool, +) -> Result<(), std::io::Error> { + let mut previous_valid_file: Option = None; + + for dir_entry in WalkDir::new(path).contents_first(true) { + let dir_entry = dir_entry?; + let dir_entry_type = dir_entry.file_type(); + let dir_entry_path = dir_entry.path(); + + if dir_entry_type.is_dir() { + let is_dir_valid = previous_valid_file.as_ref().map_or(false, |p| { + p.path() + .parent() + .map_or(false, |p| p.starts_with(dir_entry_path)) + }); + + if !is_dir_valid { + std::fs::remove_dir_all(dir_entry_path)?; + } + } else { + match delete_file_predicate(&dir_entry) { + true => std::fs::remove_file(dir_entry_path)?, + false => previous_valid_file = Some(dir_entry), + } + } + } + + Ok(()) +} diff --git a/src/pkg/converters/combined_converter.rs b/src/pkg/converters/combined_converter.rs new file mode 100644 index 0000000..89b9fd3 --- /dev/null +++ b/src/pkg/converters/combined_converter.rs @@ -0,0 +1,125 @@ +use crate::pkg::converters::PkgFileConverter; +use std::fmt::{Debug, Display, Formatter}; +use std::ops::Deref; + +#[derive(Default)] +pub struct CombinedPkgFileConverter { + converters: Vec>, +} + +impl CombinedPkgFileConverter { + pub fn new() -> Self { + Self { converters: vec![] } + } + + pub fn add(&mut self, converter: C) -> &mut Self { + self.converters + .push(Box::new(CombinedPkgFileConverterAdapterImpl(converter))); + self + } +} + +impl PkgFileConverter for CombinedPkgFileConverter { + type Error = CombinedPkgFileConverterError; + + fn convert(&self, filename: &str, content: Vec) -> Result>, Self::Error> { + let mut content = content; + + for converter in &self.converters { + if let Some(c) = converter.convert(filename, content)? { + content = c; + } else { + return Ok(None); + } + } + + Ok(Some(content)) + } + + fn is_applicable(&self, filename: &str) -> bool { + self.converters.iter().any(|c| c.is_applicable(filename)) + } +} + +impl Debug for CombinedPkgFileConverter { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CombinedPkgFileConverter") + .field("converters.len()", &self.converters.len()) + .finish() + } +} + +#[derive(Debug)] +pub struct CombinedPkgFileConverterError(Box); + +impl Display for CombinedPkgFileConverterError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + std::fmt::Display::fmt(&self.0, f) + } +} + +impl std::error::Error for CombinedPkgFileConverterError {} + +impl From> for CombinedPkgFileConverterError { + fn from(error: Box) -> Self { + Self(error) + } +} + +impl Deref for CombinedPkgFileConverterError { + type Target = Box; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +trait CombinedPkgFileConverterAdapter { + fn convert( + &self, + filename: &str, + content: Vec, + ) -> Result>, Box>; + + fn is_applicable(&self, filename: &str) -> bool; +} + +impl CombinedPkgFileConverterAdapter + for dyn PkgFileConverter +{ + fn convert( + &self, + filename: &str, + content: Vec, + ) -> Result>, Box> { + self.convert(filename, content).map_err(|err| err.into()) + } + + fn is_applicable(&self, filename: &str) -> bool { + self.is_applicable(filename) + } +} + +struct CombinedPkgFileConverterAdapterImpl(C); + +impl CombinedPkgFileConverterAdapter + for CombinedPkgFileConverterAdapterImpl +{ + fn convert( + &self, + filename: &str, + content: Vec, + ) -> Result>, Box> { + Ok(self.0.convert(filename, content)?) + } + + fn is_applicable(&self, filename: &str) -> bool { + self.0.is_applicable(filename) + } +} + +impl From for CombinedPkgFileConverterAdapterImpl { + fn from(value: C) -> Self { + Self(value) + } +} diff --git a/src/pkg/converters/localization_cleanup.rs b/src/pkg/converters/localization_cleanup.rs new file mode 100644 index 0000000..245c211 --- /dev/null +++ b/src/pkg/converters/localization_cleanup.rs @@ -0,0 +1,41 @@ +use crate::pkg::converters::PkgFileConverter; +use crate::pkg::json_wrappers::{PKG_DATA_LCZ_DATA_PATH_REGEX, PKG_RESOURCE_PATH_REGEX}; +use std::collections::HashSet; +use thiserror::Error; + +pub struct LocalizationCleanupPkgFileConverter { + allow_cultures: HashSet, +} + +impl LocalizationCleanupPkgFileConverter { + pub fn new(allow_cultures: HashSet) -> Self { + Self { allow_cultures } + } +} + +#[derive(Error, Debug)] +pub enum LocalizationCleanupPkgFileConverterError {} + +impl PkgFileConverter for LocalizationCleanupPkgFileConverter { + type Error = LocalizationCleanupPkgFileConverterError; + + fn convert(&self, filename: &str, content: Vec) -> Result>, Self::Error> { + if let Some(caps) = PKG_DATA_LCZ_DATA_PATH_REGEX + .captures(filename) + .or_else(|| PKG_RESOURCE_PATH_REGEX.captures(filename)) + { + return if self.allow_cultures.contains(&caps["culture"]) { + Ok(Some(content)) + } else { + Ok(None) + }; + } + + Ok(Some(content)) + } + + fn is_applicable(&self, filename: &str) -> bool { + PKG_DATA_LCZ_DATA_PATH_REGEX.is_match(filename) + || PKG_RESOURCE_PATH_REGEX.is_match(filename) + } +} diff --git a/src/pkg/converters/mod.rs b/src/pkg/converters/mod.rs new file mode 100644 index 0000000..ebd515d --- /dev/null +++ b/src/pkg/converters/mod.rs @@ -0,0 +1,16 @@ +mod sorting; +pub use sorting::*; + +mod combined_converter; +pub use combined_converter::*; + +mod localization_cleanup; +pub use localization_cleanup::*; + +pub trait PkgFileConverter { + type Error: std::error::Error + 'static; + + fn convert(&self, filename: &str, content: Vec) -> Result>, Self::Error>; + + fn is_applicable(&self, filename: &str) -> bool; +} diff --git a/src/pkg/converters/sorting.rs b/src/pkg/converters/sorting.rs new file mode 100644 index 0000000..9c74c2f --- /dev/null +++ b/src/pkg/converters/sorting.rs @@ -0,0 +1,75 @@ +use crate::pkg::converters::PkgFileConverter; +use crate::pkg::json_wrappers::*; +use crate::pkg::xml_wrappers::*; +use thiserror::Error; + +pub struct SortingPkgFileConverter; + +#[derive(Error, Debug)] +pub enum SortingPkgFileConverterError { + #[error("failed to parse json file: {0}")] + ParseJsonFile(#[from] PkgJsonWrapperCreateError), + + #[error("failed to apply package descriptor sorting: {0}")] + ApplyPackageDescriptorSorting(#[from] PkgPackageDescriptorSortingError), + + #[error("failed to apply json data data sorting: {0}")] + ApplyDataDataSorting(#[from] PkgDataDataSortingError), + + #[error("failed to apply json data descriptor sorting: {0}")] + ApplyDataDescriptorSorting(#[from] PkgDataDescriptorSortingError), + + #[error("failed to apply csproj sorting: {0}")] + ApplyCsprojSorting(#[from] csproj::CsprojProcessingError), + + #[error("failed to serialize/save json: {0}")] + Serialize(#[from] PkgJsonWrapperSerializeError), +} + +impl PkgFileConverter for SortingPkgFileConverter { + type Error = SortingPkgFileConverterError; + + fn convert(&self, filename: &str, content: Vec) -> Result>, Self::Error> { + let mut out = vec![]; + + if filename == crate::pkg::paths::PKG_DESCRIPTOR_FILE { + PkgPackageDescriptorJsonWrapper::from(PkgJsonWrapper::new(&content)?) + .apply_sorting()? + .serialize(&mut out)?; + + return Ok(Some(out)); + } + + if PKG_DATA_DATA_PATH_REGEX.is_match(filename) + || PKG_DATA_LCZ_DATA_PATH_REGEX.is_match(filename) + { + PkgDataDataJsonWrapper::from(PkgJsonWrapper::new(&content)?) + .apply_sorting()? + .serialize(&mut out)?; + + return Ok(Some(out)); + } + + if PKG_DATA_DESCRIPTOR_PATH_REGEX.is_match(filename) { + PkgDataDescriptorJsonWrapper::from(PkgJsonWrapper::new(&content)?) + .apply_sorting()? + .serialize(&mut out)?; + + return Ok(Some(out)); + } + + if csproj::PKG_CSPROJ_PATH_REGEX.is_match(filename) { + return Ok(Some(csproj::apply_sorting(&content)?)); + } + + Ok(Some(content)) + } + + fn is_applicable(&self, filename: &str) -> bool { + filename == crate::pkg::paths::PKG_DESCRIPTOR_FILE + || PKG_DATA_DATA_PATH_REGEX.is_match(filename) + || PKG_DATA_LCZ_DATA_PATH_REGEX.is_match(filename) + || PKG_DATA_DESCRIPTOR_PATH_REGEX.is_match(filename) + || csproj::PKG_CSPROJ_PATH_REGEX.is_match(filename) + } +} diff --git a/src/pkg/json-wrappers/mod.rs b/src/pkg/json-wrappers/mod.rs new file mode 100644 index 0000000..8b08708 --- /dev/null +++ b/src/pkg/json-wrappers/mod.rs @@ -0,0 +1,14 @@ +mod pkg_json_wrapper; +pub use pkg_json_wrapper::*; + +mod pkg_package_descriptor; +pub use pkg_package_descriptor::*; + +mod pkg_data_data; +pub use pkg_data_data::*; + +mod pkg_data_descriptor; +pub use pkg_data_descriptor::*; + +mod pkg_resource; +pub use pkg_resource::*; diff --git a/src/pkg/json-wrappers/pkg_data_data.rs b/src/pkg/json-wrappers/pkg_data_data.rs new file mode 100644 index 0000000..bfe4a91 --- /dev/null +++ b/src/pkg/json-wrappers/pkg_data_data.rs @@ -0,0 +1,82 @@ +use crate::pkg::json_wrappers::PkgJsonWrapper; +use regex::Regex; +use serde_json::Value; +use std::ops::Deref; +use std::sync::LazyLock; +use thiserror::Error; + +pub static PKG_DATA_DATA_PATH_REGEX: LazyLock = LazyLock::new(|| { + Regex::new(&format!( + r"^Data{sep}.+?{sep}data\.json$", + sep = regex::escape(std::path::MAIN_SEPARATOR_STR) + )) + .expect("failed to compile regex for package data data file path regex") +}); + +pub static PKG_DATA_LCZ_DATA_PATH_REGEX: LazyLock = LazyLock::new(|| { + Regex::new(&format!( + r"^Data{sep}.+?{sep}Localization{sep}data\.(?.+?)\.json$", + sep = regex::escape(std::path::MAIN_SEPARATOR_STR) + )) + .expect("failed to compile regex for package data lcz data file path regex") +}); + +pub struct PkgDataDataJsonWrapper { + inner_wrapper: PkgJsonWrapper, +} + +impl From for PkgDataDataJsonWrapper { + fn from(wrapper: PkgJsonWrapper) -> Self { + Self { + inner_wrapper: wrapper, + } + } +} + +impl Deref for PkgDataDataJsonWrapper { + type Target = PkgJsonWrapper; + + fn deref(&self) -> &Self::Target { + &self.inner_wrapper + } +} + +#[derive(Error, Debug)] +pub enum PkgDataDataSortingError { + #[error("failed to get package data array")] + FailedToGetPackageDataArray, + + #[error("failed to get package data row array")] + FailedToGetPackageDataRowArray, +} + +#[allow(dead_code)] +impl PkgDataDataJsonWrapper { + fn package_data(&self) -> &Value { + &self.inner_wrapper.value["PackageData"] + } + + fn package_data_mut(&mut self) -> &mut Value { + &mut self.inner_wrapper.value["PackageData"] + } + + pub fn apply_sorting(&mut self) -> Result<&mut Self, PkgDataDataSortingError> { + for data in self + .package_data_mut() + .as_array_mut() + .ok_or(PkgDataDataSortingError::FailedToGetPackageDataArray)? + { + let row = (*data)["Row"] + .as_array_mut() + .ok_or(PkgDataDataSortingError::FailedToGetPackageDataRowArray)?; + + row.sort_by(|k1, k2| { + k1["SchemaColumnUId"] + .as_str() + .cmp(&k2["SchemaColumnUId"].as_str()) + }); + } + + Ok(self) + } +} diff --git a/src/pkg/json-wrappers/pkg_data_descriptor.rs b/src/pkg/json-wrappers/pkg_data_descriptor.rs new file mode 100644 index 0000000..4da02b3 --- /dev/null +++ b/src/pkg/json-wrappers/pkg_data_descriptor.rs @@ -0,0 +1,70 @@ +use crate::pkg::json_wrappers::PkgJsonWrapper; +use regex::Regex; +use serde_json::Value; +use std::ops::Deref; +use std::sync::LazyLock; +use thiserror::Error; + +pub static PKG_DATA_DESCRIPTOR_PATH_REGEX: LazyLock = LazyLock::new(|| { + Regex::new(&format!( + r"^Data{sep}.+?{sep}descriptor.json$", + sep = regex::escape(std::path::MAIN_SEPARATOR_STR) + )) + .expect("failed to compile regex for package data descriptor path regex") +}); + +pub struct PkgDataDescriptorJsonWrapper { + inner_wrapper: PkgJsonWrapper, +} + +impl From for PkgDataDescriptorJsonWrapper { + fn from(wrapper: PkgJsonWrapper) -> Self { + Self { + inner_wrapper: wrapper, + } + } +} + +impl Deref for PkgDataDescriptorJsonWrapper { + type Target = PkgJsonWrapper; + + fn deref(&self) -> &Self::Target { + &self.inner_wrapper + } +} + +#[derive(Error, Debug)] +pub enum PkgDataDescriptorSortingError { + #[error("failed to get package data array")] + FailedToGetColumnsArray, +} + +#[allow(dead_code)] +impl PkgDataDescriptorJsonWrapper { + fn descriptor(&self) -> &Value { + &self.inner_wrapper.value["Descriptor"] + } + + fn descriptor_mut(&mut self) -> &mut Value { + &mut self.inner_wrapper.value["Descriptor"] + } + + fn columns(&self) -> &Value { + &self.descriptor()["Columns"] + } + + fn columns_mut(&mut self) -> &mut Value { + &mut self.descriptor_mut()["Columns"] + } + + pub fn apply_sorting(&mut self) -> Result<&mut Self, PkgDataDescriptorSortingError> { + let columns = self + .columns_mut() + .as_array_mut() + .ok_or(PkgDataDescriptorSortingError::FailedToGetColumnsArray)?; + + columns.sort_by(|k1, k2| k1["ColumnUId"].as_str().cmp(&k2["ColumnUId"].as_str())); + + Ok(self) + } +} diff --git a/src/pkg/json-wrappers/pkg_json_wrapper.rs b/src/pkg/json-wrappers/pkg_json_wrapper.rs new file mode 100644 index 0000000..fd477ca --- /dev/null +++ b/src/pkg/json-wrappers/pkg_json_wrapper.rs @@ -0,0 +1,59 @@ +use crate::utils::bom::{is_bom, trim_bom, BOM_CHAR_BYTES}; +use crate::utils::JsonMsDatePreserveFormatter; +use serde::Serialize; +use serde_json::{Serializer, Value}; +use std::io::Write; +use std::path::Path; +use thiserror::Error; + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct PkgJsonWrapper { + is_bom: bool, + pub(crate) value: Value, +} + +#[derive(Error, Debug)] +pub enum PkgJsonWrapperCreateError { + #[error("file read error: {0}")] + FileRead(#[from] std::io::Error), + + #[error("deserialization error: {0}")] + Deserialize(#[from] serde_json::Error), +} + +#[derive(Error, Debug)] +pub enum PkgJsonWrapperSerializeError { + #[error("failure on write preserved bom bytes: {0}")] + WritePreservedBomBytes(#[source] std::io::Error), + + #[error("{0}")] + Serialize(#[from] serde_json::Error), +} + +impl PkgJsonWrapper { + pub fn new(bytes: &[u8]) -> Result { + Ok(Self { + is_bom: is_bom(bytes), + value: serde_json::from_slice(trim_bom(bytes))?, + }) + } + + pub fn from_file(path: &Path) -> Result { + Self::new(&std::fs::read(path)?) + } + + pub fn serialize(&self, writer: &mut impl Write) -> Result<(), PkgJsonWrapperSerializeError> { + if self.is_bom { + writer + .write_all(BOM_CHAR_BYTES) + .map_err(PkgJsonWrapperSerializeError::WritePreservedBomBytes)?; + } + + let formatter = JsonMsDatePreserveFormatter::new_pretty(); + let mut serializer = Serializer::with_formatter(writer, formatter); + + self.value.serialize(&mut serializer)?; + + Ok(()) + } +} diff --git a/src/pkg/json-wrappers/pkg_package_descriptor.rs b/src/pkg/json-wrappers/pkg_package_descriptor.rs new file mode 100644 index 0000000..12dc46b --- /dev/null +++ b/src/pkg/json-wrappers/pkg_package_descriptor.rs @@ -0,0 +1,76 @@ +use crate::pkg::json_wrappers::pkg_json_wrapper::PkgJsonWrapper; +use serde_json::Value; +use std::ops::Deref; +use thiserror::Error; + +pub struct PkgPackageDescriptorJsonWrapper { + inner_wrapper: PkgJsonWrapper, +} + +impl From for PkgPackageDescriptorJsonWrapper { + fn from(wrapper: PkgJsonWrapper) -> Self { + Self { + inner_wrapper: wrapper, + } + } +} + +impl Deref for PkgPackageDescriptorJsonWrapper { + type Target = PkgJsonWrapper; + + fn deref(&self) -> &Self::Target { + &self.inner_wrapper + } +} + +#[derive(Error, Debug)] +pub enum PkgPackageDescriptorSortingError { + #[error("failed to get package descriptor DependsOn array")] + FailedToGetDependsOnArray, +} + +#[allow(dead_code)] +impl PkgPackageDescriptorJsonWrapper { + fn descriptor(&self) -> &Value { + &self.inner_wrapper.value["Descriptor"] + } + + fn descriptor_mut(&mut self) -> &mut Value { + &mut self.inner_wrapper.value["Descriptor"] + } + + pub fn name(&self) -> Option<&str> { + (*self.descriptor())["Name"].as_str() + } + + pub fn name_mut(&mut self) -> &mut Value { + &mut (*self.descriptor_mut())["Name"] + } + + pub fn uid(&self) -> Option<&str> { + (*self.descriptor())["UId"].as_str() + } + + pub fn uid_mut(&mut self) -> &mut Value { + &mut (*self.descriptor_mut())["UId"] + } + + fn depends_on(&self) -> &Value { + &self.descriptor()["DependsOn"] + } + + fn depends_on_mut(&mut self) -> &mut Value { + &mut self.descriptor_mut()["DependsOn"] + } + + pub fn apply_sorting(&mut self) -> Result<&mut Self, PkgPackageDescriptorSortingError> { + let columns = self + .depends_on_mut() + .as_array_mut() + .ok_or(PkgPackageDescriptorSortingError::FailedToGetDependsOnArray)?; + + columns.sort_by(|k1, k2| k1["UId"].as_str().cmp(&k2["UId"].as_str())); + + Ok(self) + } +} diff --git a/src/pkg/json-wrappers/pkg_resource.rs b/src/pkg/json-wrappers/pkg_resource.rs new file mode 100644 index 0000000..d526d65 --- /dev/null +++ b/src/pkg/json-wrappers/pkg_resource.rs @@ -0,0 +1,10 @@ +use regex::Regex; +use std::sync::LazyLock; + +pub static PKG_RESOURCE_PATH_REGEX: LazyLock = LazyLock::new(|| { + Regex::new(&format!( + r"^Resources{sep}.+?{sep}resource\.(?.+?)\.xml$", + sep = regex::escape(std::path::MAIN_SEPARATOR_STR) + )) + .expect("failed to compile regex for package resource path regex") +}); diff --git a/src/pkg/mod.rs b/src/pkg/mod.rs new file mode 100644 index 0000000..b5757eb --- /dev/null +++ b/src/pkg/mod.rs @@ -0,0 +1,13 @@ +pub mod bundling; + +pub mod converters; + +pub mod paths; + +pub mod utils; + +#[path = "json-wrappers/mod.rs"] +pub mod json_wrappers; + +#[path = "xml-wrappers/mod.rs"] +mod xml_wrappers; diff --git a/src/pkg/paths.rs b/src/pkg/paths.rs new file mode 100644 index 0000000..5854d24 --- /dev/null +++ b/src/pkg/paths.rs @@ -0,0 +1,21 @@ +const AUTOGENERATED_FOLDER: &str = "Autogenerated"; +const AUTOGENERATED_LIB_FOLDER: &str = "Lib"; +const AUTOGENERATED_SRC_FOLDER: &str = "Src"; + +pub const ASSEMBLIES_FOLDER: &str = "Assemblies"; +pub const DATA_FOLDER: &str = "Data"; +pub const FILES_FOLDER: &str = "Files"; +pub const RESOURCES_FOLDER: &str = "Resources"; +pub const SCHEMAS_FOLDER: &str = "Schemas"; +pub const SQL_SCRIPTS_FOLDER: &str = "SqlScripts"; + +pub const PKG_FOLDERS: [&str; 6] = [ + ASSEMBLIES_FOLDER, + DATA_FOLDER, + FILES_FOLDER, + RESOURCES_FOLDER, + SCHEMAS_FOLDER, + SQL_SCRIPTS_FOLDER, +]; + +pub const PKG_DESCRIPTOR_FILE: &str = "descriptor.json"; diff --git a/src/pkg/utils.rs b/src/pkg/utils.rs new file mode 100644 index 0000000..bb8f875 --- /dev/null +++ b/src/pkg/utils.rs @@ -0,0 +1,209 @@ +use crate::pkg::*; +use flate2::read::GzDecoder; +use std::io::{Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; +use thiserror::Error; +use walkdir::WalkDir; +use zip::unstable::LittleEndianReadExt; +use zip::ZipArchive; + +pub fn walk_over_package_dir( + pkg_folder: &Path, +) -> impl Iterator> { + let valid_folders = paths::PKG_FOLDERS.map(|f| pkg_folder.join(f)); + let valid_files = [pkg_folder.join(paths::PKG_DESCRIPTOR_FILE)]; + + valid_files + .into_iter() + .chain(valid_folders) + .filter(|x| x.exists()) + .flat_map(|x| WalkDir::new(x).into_iter()) +} + +#[derive(Error, Debug)] +pub enum WalkOverPackageFilesContentError { + #[error("unable to access folder: {0}")] + FolderAccess(#[from] walkdir::Error), + + #[error("unable to access file {path}: {source}")] + FileAccess { + path: PathBuf, + + #[source] + source: std::io::Error, + }, +} + +pub fn walk_over_package_files( + pkg_folder: PathBuf, +) -> impl Iterator> { + walk_over_package_dir(&pkg_folder) + .filter(|e| e.as_ref().is_ok_and(|e| !e.file_type().is_dir())) + .filter_map(move |f| -> Option> { + match f { + Err(err) => Some(Err(err)), + Ok(file) => { + let file_path = file.path().to_path_buf(); + let filename = file_path.strip_prefix(&pkg_folder).unwrap(); + + if contains_hidden_path(filename) { + return None; + } + + Some(Ok(file_path)) + } + } + }) +} + +pub fn walk_over_package_files_content( + pkg_folder: PathBuf, +) -> impl Iterator> { + walk_over_package_files(pkg_folder.clone()) + .map(|e| e.map_err(WalkOverPackageFilesContentError::FolderAccess)) + .map(move |f| { + f.and_then(|f| { + bundling::PkgGZipFile::open_fs_file_absolute(&pkg_folder, &f).map_err(|err| { + WalkOverPackageFilesContentError::FileAccess { + path: f, + source: err, + } + }) + }) + }) +} + +pub fn is_gzip_stream(reader: &mut (impl Read + Seek)) -> std::io::Result { + let format = reader.read_u16_le()?; + + reader.seek_relative(-2)?; + + Ok(format == 0x8b1f) +} + +#[derive(Error, Debug)] +pub enum GetPackageNameFromFolderError { + #[error("unable to read package descriptor: {0}")] + PkgJsonWrapperCreate(#[from] json_wrappers::PkgJsonWrapperCreateError), + + #[error("descriptor was correctly read from folder, but filename was not found")] + PackageNameIsNone, +} + +pub fn get_package_name_from_folder( + pkg_folder: &Path, +) -> Result { + let pkg_descriptor = crate::pkg::json_wrappers::PkgPackageDescriptorJsonWrapper::from( + json_wrappers::PkgJsonWrapper::from_file(&pkg_folder.join(paths::PKG_DESCRIPTOR_FILE))?, + ); + + pkg_descriptor + .name() + .ok_or(GetPackageNameFromFolderError::PackageNameIsNone) + .map(|x| x.to_owned()) +} + +pub fn contains_hidden_path(path: &Path) -> bool { + let str = path.to_string_lossy(); + + str.starts_with('.') || str.contains(&format!("{sep}.", sep = std::path::MAIN_SEPARATOR)) +} + +#[derive(Debug, Error)] +pub enum GetPackageDescriptorFromReaderError { + #[error("failed to read from reader: {0}")] + Reader(#[from] std::io::Error), + + #[error("failed to get descriptor from package bundle as gzip: {0}")] + AsGzipError(#[source] GetPackageDescriptorFromGzipReaderError), + + #[error("failed to read package bundle as zip: {0}")] + Zip(#[from] zip::result::ZipError), + + #[error("failed to get descriptor in {filename_in_zip} file in zip: {source}")] + AsZipError { + filename_in_zip: String, + + #[source] + source: GetPackageDescriptorFromGzipReaderError, + }, +} + +#[derive(Debug, Error)] +pub enum GetPackageDescriptorFromGzipReaderError { + #[error("failed to read gzip: {0}")] + Reader(#[from] std::io::Error), + + #[error("failed to decode gzip in package bundle: {0}")] + Decoder(#[from] bundling::PkgGZipDecoderError), + + #[error("failed to find descriptor.json file in package gzip")] + DescriptorNotFound, + + #[error("failed to parse gzip package descriptor: {0}")] + Parsing(#[from] json_wrappers::PkgJsonWrapperCreateError), +} + +pub fn get_package_descriptors_from_package_reader( + mut reader: &mut (impl Read + Seek), +) -> Result, GetPackageDescriptorFromReaderError> +{ + let position = reader.stream_position()?; + + let mut results = vec![]; + + if is_gzip_stream(reader)? { + results.push( + get_package_descriptor_as_gzip(&mut reader) + .map_err(GetPackageDescriptorFromReaderError::AsGzipError)?, + ); + } else { + let mut zip = + ZipArchive::new(&mut reader).map_err(GetPackageDescriptorFromReaderError::Zip)?; + + for i in 0..zip.len() { + let mut gzip = zip + .by_index(i) + .map_err(GetPackageDescriptorFromReaderError::Zip)?; + + results.push(get_package_descriptor_as_gzip(&mut gzip).map_err(|err| { + GetPackageDescriptorFromReaderError::AsZipError { + filename_in_zip: gzip.name().to_owned(), + source: err, + } + })?); + } + } + + reader.seek(SeekFrom::Start(position))?; + + return Ok(results); + + fn get_package_descriptor_as_gzip( + reader: &mut impl Read, + ) -> Result< + json_wrappers::PkgPackageDescriptorJsonWrapper, + GetPackageDescriptorFromGzipReaderError, + > { + let descriptor = bundling::PkgGZipDecoder::new(GzDecoder::new(reader)) + .filter_map(|f| -> Option> { + match f { + Err(error) => Some(Err(error)), + Ok(file) => match file.filename == paths::PKG_DESCRIPTOR_FILE { + true => Some(Ok(file)), + false => None, + }, + } + }) + .next(); + + let descriptor = + descriptor.ok_or(GetPackageDescriptorFromGzipReaderError::DescriptorNotFound)??; + + let descriptor = json_wrappers::PkgPackageDescriptorJsonWrapper::from( + json_wrappers::PkgJsonWrapper::new(&descriptor.content)?, + ); + + Ok(descriptor) + } +} diff --git a/src/pkg/xml-wrappers/csproj.rs b/src/pkg/xml-wrappers/csproj.rs new file mode 100644 index 0000000..633d645 --- /dev/null +++ b/src/pkg/xml-wrappers/csproj.rs @@ -0,0 +1,186 @@ +use quick_xml::events::{BytesEnd, BytesStart, BytesText, Event}; +use quick_xml::{Reader, Writer}; +use regex::Regex; +use std::io::Cursor; +use std::sync::LazyLock; +use thiserror::Error; + +pub static PKG_CSPROJ_PATH_REGEX: LazyLock = LazyLock::new(|| { + Regex::new(&format!( + r"^Files{sep}[^{sep}]+\.csproj$", + sep = regex::escape(std::path::MAIN_SEPARATOR_STR) + )) + .expect("failed to compile regex for package csproj file path regex") +}); + +#[derive(Debug, Error)] +pub enum CsprojProcessingError { + #[error("error reading csproj xml: {0}")] + Reader(#[from] quick_xml::Error), + + #[error("failed to process content of block: {0}")] + PackageReferenceBlockProcessing(#[from] CsprojBlocksProcessingError), +} + +#[derive(Debug, Error)] +pub enum CsprojBlocksProcessingError { + #[error("error reading csproj xml: {0}")] + Reader(#[from] quick_xml::Error), + + #[error("failed to read xml block (tag): {0}")] + ReadingBlock(#[from] XmlReadEventBlockError), +} + +pub fn apply_sorting(content: &[u8]) -> Result, CsprojProcessingError> { + let mut reader = Reader::from_reader(content); + let mut writer = Writer::new(Cursor::new(Vec::::new())); + + loop { + match reader.read_event().map_err(CsprojProcessingError::Reader)? { + Event::Start(e) if is_package_references_item_group_start(&e) => { + process_blocks_sorting(&mut reader, &mut writer, e)?; + } + Event::Eof => break, + e => writer.write_event(e).unwrap(), + } + } + + return Ok(writer.into_inner().into_inner()); + + fn is_package_references_item_group_start(e: &BytesStart) -> bool { + is_item_group_start(e) + && e.try_get_attribute("Label") + .is_ok_and(|x| x.is_some_and(|x| x.value.as_ref() == b"Package References")) + } + + fn process_blocks_sorting( + reader: &mut Reader<&[u8]>, + writer: &mut Writer>>, + start_tag: BytesStart, + ) -> Result<(), CsprojBlocksProcessingError> { + writer.write_event(Event::Start(start_tag)).unwrap(); + + let (mut event_blocks, end_tag) = xml_read_event_blocks(reader)?; + + event_blocks.blocks.sort_by(|a, b| { + let a = a.first().expect("event block cannot be empty"); + let b = b.first().expect("event block cannot be empty"); + + a.cmp(b) + }); + + if let Some(ref open_sep) = event_blocks.separator { + writer.write_event(Event::Text(open_sep.clone())).unwrap(); + } + + xml_write_event_blocks(writer, event_blocks); + + writer.write_event(Event::End(end_tag)).unwrap(); + + Ok(()) + } +} + +#[derive(Debug)] +struct EventBlocks<'a> { + separator: Option>, + final_separator: Option>, + blocks: Vec>>, +} + +fn xml_read_event_blocks<'a>( + reader: &mut Reader<&'a [u8]>, +) -> Result<(EventBlocks<'a>, BytesEnd<'a>), XmlReadEventBlockError> { + let mut separator: Option = None; + let mut final_separator: Option = None; + let mut event_blocks: Vec> = vec![]; + + loop { + match reader.read_event()? { + Event::Text(t) => { + if separator.is_none() { + separator = Some(t) + } else { + final_separator = Some(t) + } + } + Event::Eof => return Err(XmlReadEventBlockError::UnexpectedEof), + Event::End(e) => { + let event_blocks = EventBlocks { + separator, + final_separator, + blocks: event_blocks, + }; + + return Ok((event_blocks, e)); + } + e => event_blocks.push(xml_read_event_block(reader, e)?), + } + } +} + +#[derive(Debug, Error)] +pub enum XmlReadEventBlockError { + #[error("unexpected end of file while reading block (not closed tag?)")] + UnexpectedEof, + + #[error("error while reading block: {0}")] + Reader(#[from] quick_xml::Error), +} + +fn xml_read_event_block<'a>( + reader: &mut Reader<&'a [u8]>, + current_event: Event<'a>, +) -> Result>, XmlReadEventBlockError> { + match current_event { + Event::Start(e) => { + let mut event_block = vec![Event::Start(e)]; + let mut depth = 0; + + loop { + match reader.read_event()? { + Event::Start(e) => { + depth += 1; + event_block.push(Event::Start(e)); + } + Event::End(e) => { + depth -= 1; + + event_block.push(Event::End(e)); + + if depth < 0 { + return Ok(event_block); + } + } + Event::Eof => return Err(XmlReadEventBlockError::UnexpectedEof), + e => event_block.push(e), + } + } + } + e => Ok(vec![e]), + } +} + +fn xml_write_event_blocks(writer: &mut Writer>>, event_blocks: EventBlocks) { + let event_blocks_len = event_blocks.blocks.len(); + + for (i, item) in event_blocks.blocks.into_iter().enumerate() { + for event in item { + writer.write_event(event).unwrap(); + } + + if i < event_blocks_len - 1 { + if let Some(ref separator) = event_blocks.separator { + writer.write_event(Event::Text(separator.clone())).unwrap(); + } + } + } + + if let Some(close_sep) = event_blocks.final_separator { + writer.write_event(Event::Text(close_sep)).unwrap(); + } +} + +fn is_item_group_start(e: &BytesStart) -> bool { + e.name().as_ref() == b"ItemGroup" +} diff --git a/src/pkg/xml-wrappers/mod.rs b/src/pkg/xml-wrappers/mod.rs new file mode 100644 index 0000000..c8f9504 --- /dev/null +++ b/src/pkg/xml-wrappers/mod.rs @@ -0,0 +1,4 @@ +pub mod csproj; + +#[cfg(test)] +mod tests; diff --git a/src/pkg/xml-wrappers/tests.rs b/src/pkg/xml-wrappers/tests.rs new file mode 100644 index 0000000..f4ce4b5 --- /dev/null +++ b/src/pkg/xml-wrappers/tests.rs @@ -0,0 +1,81 @@ +use crate::pkg::xml_wrappers::csproj; + +#[path = "tests_data.rs"] +mod tests_data; + +#[path = "tests_data_1.rs"] +mod tests_data_1; + +#[test] +fn csproj_sorting() { + let input = tests_data::INPUT.as_bytes(); + let expected_output = tests_data::CSPROJ_SORTING_EXPECTED_OUTPUT.as_bytes(); + + let output = csproj::apply_sorting(input).unwrap(); + + pretty_assertions::assert_eq!( + String::from_utf8_lossy(expected_output), + String::from_utf8_lossy(&output) + ); +} + +#[test] +fn csproj_sorting_without_sorting_attributes() { + let input = br#" + + + False + net472 + $(CoreTargetFramework) + + + "#; + + let expected_output = input; + + let output = csproj::apply_sorting(input).unwrap(); + + pretty_assertions::assert_eq!( + String::from_utf8_lossy(expected_output), + String::from_utf8_lossy(&output) + ); +} + +#[test] +fn csproj_sorting_without_blocks_inside_1() { + let input = tests_data_1::INPUT.as_bytes(); + let expected_output = input; // same as input / unchanged + + let output = csproj::apply_sorting(input).unwrap(); + + pretty_assertions::assert_eq!( + String::from_utf8_lossy(expected_output), + String::from_utf8_lossy(&output) + ); +} + +#[test] +fn csproj_sorting_without_blocks_inside_2() { + let input = tests_data_1::INPUT_2.as_bytes(); + let expected_output = input; // same as input / unchanged + + let output = csproj::apply_sorting(input).unwrap(); + + pretty_assertions::assert_eq!( + String::from_utf8_lossy(expected_output), + String::from_utf8_lossy(&output) + ); +} + +#[test] +fn csproj_sorting_without_blocks_inside_3() { + let input = tests_data_1::INPUT_3.as_bytes(); + let expected_output = input; // same as input / unchanged + + let output = csproj::apply_sorting(input).unwrap(); + + pretty_assertions::assert_eq!( + String::from_utf8_lossy(expected_output), + String::from_utf8_lossy(&output) + ); +} diff --git a/src/pkg/xml-wrappers/tests_data.rs b/src/pkg/xml-wrappers/tests_data.rs new file mode 100644 index 0000000..2bdcbe6 --- /dev/null +++ b/src/pkg/xml-wrappers/tests_data.rs @@ -0,0 +1,1357 @@ +pub const INPUT: &str = r#" + + + False + net472 + $(CoreTargetFramework) + + + true + full + false + + + false + pdbonly + true + MSB3277;MSB3245;MSB3243 + CS1522,CS0162 + + + + <_Parameter1>All + + + + ../ + $(RelativeCurrentPkgFolderPath).. + $(RelativePkgFolderPath)/../Lib + + + + + $(RelativePkgFolderPath)/../../bin + Files/Bin + + + + + $(RelativePkgFolderPath)/../.. + MSB3277;MSB3245;MSB3243 + Files/Bin/netstandard + + + + + $(RelativeCurrentPkgFolderPath)$(StandalonePackageAssemblyPath) + + + + + + + + + + + + + + + + + + + + + + + + False + + + False + + + + + + + + + + + + + $(CoreLibPath)/Terrasoft.Reports.dll + False + False + + + $(CoreLibPath)/Terrasoft.GoogleServices.dll + False + False + + + + + + + $(CoreLibPath)/Terrasoft.Messaging.Common.Standard.dll + False + False + + + $(CoreLibPath)/Terrasoft.ServiceModel.Primitives.dll + False + False + + + + + + + $(CoreLibPath)/Terrasoft.Authentication.dll + False + False + + + $(CoreLibPath)/Terrasoft.Authentication.Contract.dll + False + False + + + $(CoreLibPath)/Terrasoft.Common.dll + False + False + + + $(CoreLibPath)/Terrasoft.IO.dll + False + False + + + $(CoreLibPath)/Terrasoft.File.Abstractions.dll + False + False + + + $(CoreLibPath)/Creatio.FeatureToggling.dll + False + False + + + $(CoreLibPath)/Terrasoft.File.dll + False + False + + + $(CoreLibPath)/Terrasoft.ServiceBus.Abstractions.dll + False + False + + + $(CoreLibPath)/Terrasoft.ServiceBus.dll + False + False + + + $(CoreLibPath)/Terrasoft.Core.dll + False + False + + + $(CoreLibPath)/Terrasoft.Core.ConfigurationBuild.dll + False + False + + + $(CoreLibPath)/Terrasoft.Core.Packages.dll + False + False + + + $(CoreLibPath)/Terrasoft.Core.Process.dll + False + False + + + $(CoreLibPath)/Terrasoft.Core.Scheduler.dll + False + False + + + $(CoreLibPath)/Terrasoft.Core.ServiceModelContract.dll + False + False + + + $(CoreLibPath)/Terrasoft.Core.Translation.dll + False + False + + + $(CoreLibPath)/Terrasoft.ElasticSearch.dll + False + False + + + $(CoreLibPath)/Terrasoft.GlobalSearch.dll + False + False + + + $(CoreLibPath)/Terrasoft.GoogleServerConnector.dll + False + False + + + $(CoreLibPath)/Terrasoft.GoogleServices.dll + False + False + + + $(CoreLibPath)/Terrasoft.Messaging.Common.dll + False + False + + + $(CoreLibPath)/Terrasoft.Messaging.Common.Standard.dll + False + False + + + $(CoreLibPath)/Terrasoft.Mobile.dll + False + False + + + $(CoreLibPath)/Terrasoft.Monitoring.dll + False + False + + + $(CoreLibPath)/Terrasoft.NewtonsoftWrapper.dll + False + False + + + $(CoreLibPath)/Terrasoft.Nui.dll + False + False + + + $(CoreLibPath)/Terrasoft.Nui.ServiceModel.dll + False + False + + + $(CoreLibPath)/Terrasoft.Reports.dll + False + False + + + $(CoreLibPath)/Terrasoft.Services.dll + False + False + + + $(CoreLibPath)/Terrasoft.Social.dll + False + False + + + $(CoreLibPath)/Terrasoft.Sync.dll + False + False + + + $(CoreLibPath)/Terrasoft.UI.Common.dll + False + False + + + $(CoreLibPath)/Terrasoft.Web.Common.dll + False + False + + + $(CoreLibPath)/Terrasoft.Web.Http.Abstractions.dll + False + False + + + $(CoreLibPath)/Terrasoft.ComponentSpace.Interfaces.dll + False + False + + + $(CoreLibPath)/Terrasoft.OAuthIntegration.dll + False + False + + + $(CoreLibPath)/Terrasoft.Web.FileSecurity.dll + False + False + + + $(CoreLibPath)/Terrasoft.Web.Security.dll + False + False + + + $(CoreLibPath)/Terrasoft.Core.Applications.dll + False + False + + + $(CoreLibPath)/Terrasoft.SmsIntegration.dll + False + False + + + $(CoreLibPath)/Common.Logging.dll + False + False + + + $(CoreLibPath)/Common.Logging.Core.dll + False + False + + + + + $(RelativePkgFolderPath)/../bin/Terrasoft.Configuration.dll + False + False + + + $(RelativePkgFolderPath)/CrtCore/$(StandalonePackageAssemblyPath)/CrtCore.dll + False + False + + + $(RelativePkgFolderPath)/CrtCustomer360App/$(StandalonePackageAssemblyPath)/CrtCustomer360App.dll + False + False + + + $(RelativePkgFolderPath)/CrtSLAInC360/$(StandalonePackageAssemblyPath)/CrtSLAInC360.dll + False + False + + + $(RelativePkgFolderPath)/WebitelChats/$(StandalonePackageAssemblyPath)/WebitelChats.dll + False + False + + + $(RelativePkgFolderPath)/CrtBaseConsts/$(StandalonePackageAssemblyPath)/CrtBaseConsts.dll + False + False + + + $(RelativePkgFolderPath)/CrtFeatureToggling/$(StandalonePackageAssemblyPath)/CrtFeatureToggling.dll + False + False + + + $(RelativePkgFolderPath)/CrtCoreBase/$(StandalonePackageAssemblyPath)/CrtCoreBase.dll + False + False + + + $(RelativePkgFolderPath)/CrtEmailSender/$(StandalonePackageAssemblyPath)/CrtEmailSender.dll + False + False + + + $(RelativePkgFolderPath)/CrtUIPlatform7x/$(StandalonePackageAssemblyPath)/CrtUIPlatform7x.dll + False + False + + + $(RelativePkgFolderPath)/CrtUIPlatform/$(StandalonePackageAssemblyPath)/CrtUIPlatform.dll + False + False + + + $(RelativePkgFolderPath)/CrtMLangContent/$(StandalonePackageAssemblyPath)/CrtMLangContent.dll + False + False + + + $(RelativePkgFolderPath)/CrtMacrosBase/$(StandalonePackageAssemblyPath)/CrtMacrosBase.dll + False + False + + + $(RelativePkgFolderPath)/CrtMessage/$(StandalonePackageAssemblyPath)/CrtMessage.dll + False + False + + + $(RelativePkgFolderPath)/CalendarBase/$(StandalonePackageAssemblyPath)/CalendarBase.dll + False + False + + + $(RelativePkgFolderPath)/CrtCalendar/$(StandalonePackageAssemblyPath)/CrtCalendar.dll + False + False + + + $(RelativePkgFolderPath)/CrtEmailTemplate/$(StandalonePackageAssemblyPath)/CrtEmailTemplate.dll + False + False + + + $(RelativePkgFolderPath)/CrtML/$(StandalonePackageAssemblyPath)/CrtML.dll + False + False + + + $(RelativePkgFolderPath)/CrtSecurity/$(StandalonePackageAssemblyPath)/CrtSecurity.dll + False + False + + + $(RelativePkgFolderPath)/CrtWebhookServiceBase/$(StandalonePackageAssemblyPath)/CrtWebhookServiceBase.dll + False + False + + + $(RelativePkgFolderPath)/CrtSmsIntegration/$(StandalonePackageAssemblyPath)/CrtSmsIntegration.dll + False + False + + + $(RelativePkgFolderPath)/CrtWebFormBase/$(StandalonePackageAssemblyPath)/CrtWebFormBase.dll + False + False + + + $(RelativePkgFolderPath)/Finesse/$(StandalonePackageAssemblyPath)/Finesse.dll + False + False + + + $(RelativePkgFolderPath)/CSP/$(StandalonePackageAssemblyPath)/CSP.dll + False + False + + + $(RelativePkgFolderPath)/CrtNextStep/$(StandalonePackageAssemblyPath)/CrtNextStep.dll + False + False + + + $(RelativePkgFolderPath)/FeatureToggling/$(StandalonePackageAssemblyPath)/FeatureToggling.dll + False + False + + + $(RelativePkgFolderPath)/SsoSettings/$(StandalonePackageAssemblyPath)/SsoSettings.dll + False + False + + + $(RelativePkgFolderPath)/CrtOmnichannelApp/$(StandalonePackageAssemblyPath)/CrtOmnichannelApp.dll + False + False + + + $(RelativePkgFolderPath)/MLProcessDesigner/$(StandalonePackageAssemblyPath)/MLProcessDesigner.dll + False + False + + + $(RelativePkgFolderPath)/XSSProtection/$(StandalonePackageAssemblyPath)/XSSProtection.dll + False + False + + + $(RelativePkgFolderPath)/CrtUISwitcher/$(StandalonePackageAssemblyPath)/CrtUISwitcher.dll + False + False + + + $(RelativePkgFolderPath)/OpenIdAuth/$(StandalonePackageAssemblyPath)/OpenIdAuth.dll + False + False + + + $(RelativePkgFolderPath)/CrtCopilot/$(StandalonePackageAssemblyPath)/CrtCopilot.dll + False + False + + + $(RelativePkgFolderPath)/CrtProductivityMobile/$(StandalonePackageAssemblyPath)/CrtProductivityMobile.dll + False + False + + + $(RelativePkgFolderPath)/CrtTouchPointBase/$(StandalonePackageAssemblyPath)/CrtTouchPointBase.dll + False + False + + + $(RelativePkgFolderPath)/CrtCall/$(StandalonePackageAssemblyPath)/CrtCall.dll + False + False + + + $(RelativePkgFolderPath)/CrtProductivityApp/$(StandalonePackageAssemblyPath)/CrtProductivityApp.dll + False + False + + + $(RelativePkgFolderPath)/CrtSecurity7x/$(StandalonePackageAssemblyPath)/CrtSecurity7x.dll + False + False + + + $(RelativePkgFolderPath)/CrtTechnicalUsers/$(StandalonePackageAssemblyPath)/CrtTechnicalUsers.dll + False + False + + + $(RelativePkgFolderPath)/CrtJunkFilter/$(StandalonePackageAssemblyPath)/CrtJunkFilter.dll + False + False + + + $(RelativePkgFolderPath)/CrtEmailMessage/$(StandalonePackageAssemblyPath)/CrtEmailMessage.dll + False + False + + + $(RelativePkgFolderPath)/CrtCaseManagmentObject/$(StandalonePackageAssemblyPath)/CrtCaseManagmentObject.dll + False + False + + + $(RelativePkgFolderPath)/CrtCase7x/$(StandalonePackageAssemblyPath)/CrtCase7x.dll + False + False + + + $(RelativePkgFolderPath)/CrtCaseService/$(StandalonePackageAssemblyPath)/CrtCaseService.dll + False + False + + + $(RelativePkgFolderPath)/MLCaseClassification/$(StandalonePackageAssemblyPath)/MLCaseClassification.dll + False + False + + + $(RelativePkgFolderPath)/CrtSLM/$(StandalonePackageAssemblyPath)/CrtSLM.dll + False + False + + + $(RelativePkgFolderPath)/CrtPortal/$(StandalonePackageAssemblyPath)/CrtPortal.dll + False + False + + + $(RelativePkgFolderPath)/CrtSLMITILService/$(StandalonePackageAssemblyPath)/CrtSLMITILService.dll + False + False + + + $(RelativePkgFolderPath)/CrtSLM7x/$(StandalonePackageAssemblyPath)/CrtSLM7x.dll + False + False + + + $(RelativePkgFolderPath)/CrtSLMITILService7x/$(StandalonePackageAssemblyPath)/CrtSLMITILService7x.dll + False + False + + + $(RelativePkgFolderPath)/MLSimilarCaseSearch/$(StandalonePackageAssemblyPath)/MLSimilarCaseSearch.dll + False + False + + + $(RelativePkgFolderPath)/CrtCustomer360Mobile/$(StandalonePackageAssemblyPath)/CrtCustomer360Mobile.dll + False + False + + + $(RelativePkgFolderPath)/CrtKnowledgeManagementObject/$(StandalonePackageAssemblyPath)/CrtKnowledgeManagementObject.dll + False + False + + + $(RelativePkgFolderPath)/CrtCaseService7x/$(StandalonePackageAssemblyPath)/CrtCaseService7x.dll + False + False + + + $(RelativePkgFolderPath)/CrtCaseManagement/$(StandalonePackageAssemblyPath)/CrtCaseManagement.dll + False + False + + + $(RelativePkgFolderPath)/CrtPlaybook/$(StandalonePackageAssemblyPath)/CrtPlaybook.dll + False + False + + + $(RelativePkgFolderPath)/CrtCasesInC360/$(StandalonePackageAssemblyPath)/CrtCasesInC360.dll + False + False + + + $(RelativePkgFolderPath)/CrtCaseManagementApp/$(StandalonePackageAssemblyPath)/CrtCaseManagementApp.dll + False + False + + + $(RelativePkgFolderPath)/CrtKnowledgeManagementApp/$(StandalonePackageAssemblyPath)/CrtKnowledgeManagementApp.dll + False + False + + + $(RelativePkgFolderPath)/CrtC360InUiV2/$(StandalonePackageAssemblyPath)/CrtC360InUiV2.dll + False + False + + + $(RelativePkgFolderPath)/CrtKnowledgeManagementInCaseManagement/$(StandalonePackageAssemblyPath)/CrtKnowledgeManagementInCaseManagement.dll + False + False + + + $(RelativePkgFolderPath)/CrtCustomerInfoInCaseMgmt/$(StandalonePackageAssemblyPath)/CrtCustomerInfoInCaseMgmt.dll + False + False + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +"#; + +pub const CSPROJ_SORTING_EXPECTED_OUTPUT: &str = r#" + + + False + net472 + $(CoreTargetFramework) + + + true + full + false + + + false + pdbonly + true + MSB3277;MSB3245;MSB3243 + CS1522,CS0162 + + + + <_Parameter1>All + + + + ../ + $(RelativeCurrentPkgFolderPath).. + $(RelativePkgFolderPath)/../Lib + + + + + $(RelativePkgFolderPath)/../../bin + Files/Bin + + + + + $(RelativePkgFolderPath)/../.. + MSB3277;MSB3245;MSB3243 + Files/Bin/netstandard + + + + + $(RelativeCurrentPkgFolderPath)$(StandalonePackageAssemblyPath) + + + + + + + + + + + + + + + + + + + + + + + + False + + + False + + + + + + + + + + + + + $(CoreLibPath)/Terrasoft.Reports.dll + False + False + + + $(CoreLibPath)/Terrasoft.GoogleServices.dll + False + False + + + + + + + $(CoreLibPath)/Terrasoft.Messaging.Common.Standard.dll + False + False + + + $(CoreLibPath)/Terrasoft.ServiceModel.Primitives.dll + False + False + + + + + + + $(CoreLibPath)/Terrasoft.Authentication.dll + False + False + + + $(CoreLibPath)/Terrasoft.Authentication.Contract.dll + False + False + + + $(CoreLibPath)/Terrasoft.Common.dll + False + False + + + $(CoreLibPath)/Terrasoft.IO.dll + False + False + + + $(CoreLibPath)/Terrasoft.File.Abstractions.dll + False + False + + + $(CoreLibPath)/Creatio.FeatureToggling.dll + False + False + + + $(CoreLibPath)/Terrasoft.File.dll + False + False + + + $(CoreLibPath)/Terrasoft.ServiceBus.Abstractions.dll + False + False + + + $(CoreLibPath)/Terrasoft.ServiceBus.dll + False + False + + + $(CoreLibPath)/Terrasoft.Core.dll + False + False + + + $(CoreLibPath)/Terrasoft.Core.ConfigurationBuild.dll + False + False + + + $(CoreLibPath)/Terrasoft.Core.Packages.dll + False + False + + + $(CoreLibPath)/Terrasoft.Core.Process.dll + False + False + + + $(CoreLibPath)/Terrasoft.Core.Scheduler.dll + False + False + + + $(CoreLibPath)/Terrasoft.Core.ServiceModelContract.dll + False + False + + + $(CoreLibPath)/Terrasoft.Core.Translation.dll + False + False + + + $(CoreLibPath)/Terrasoft.ElasticSearch.dll + False + False + + + $(CoreLibPath)/Terrasoft.GlobalSearch.dll + False + False + + + $(CoreLibPath)/Terrasoft.GoogleServerConnector.dll + False + False + + + $(CoreLibPath)/Terrasoft.GoogleServices.dll + False + False + + + $(CoreLibPath)/Terrasoft.Messaging.Common.dll + False + False + + + $(CoreLibPath)/Terrasoft.Messaging.Common.Standard.dll + False + False + + + $(CoreLibPath)/Terrasoft.Mobile.dll + False + False + + + $(CoreLibPath)/Terrasoft.Monitoring.dll + False + False + + + $(CoreLibPath)/Terrasoft.NewtonsoftWrapper.dll + False + False + + + $(CoreLibPath)/Terrasoft.Nui.dll + False + False + + + $(CoreLibPath)/Terrasoft.Nui.ServiceModel.dll + False + False + + + $(CoreLibPath)/Terrasoft.Reports.dll + False + False + + + $(CoreLibPath)/Terrasoft.Services.dll + False + False + + + $(CoreLibPath)/Terrasoft.Social.dll + False + False + + + $(CoreLibPath)/Terrasoft.Sync.dll + False + False + + + $(CoreLibPath)/Terrasoft.UI.Common.dll + False + False + + + $(CoreLibPath)/Terrasoft.Web.Common.dll + False + False + + + $(CoreLibPath)/Terrasoft.Web.Http.Abstractions.dll + False + False + + + $(CoreLibPath)/Terrasoft.ComponentSpace.Interfaces.dll + False + False + + + $(CoreLibPath)/Terrasoft.OAuthIntegration.dll + False + False + + + $(CoreLibPath)/Terrasoft.Web.FileSecurity.dll + False + False + + + $(CoreLibPath)/Terrasoft.Web.Security.dll + False + False + + + $(CoreLibPath)/Terrasoft.Core.Applications.dll + False + False + + + $(CoreLibPath)/Terrasoft.SmsIntegration.dll + False + False + + + $(CoreLibPath)/Common.Logging.dll + False + False + + + $(CoreLibPath)/Common.Logging.Core.dll + False + False + + + + + $(RelativePkgFolderPath)/CSP/$(StandalonePackageAssemblyPath)/CSP.dll + False + False + + + $(RelativePkgFolderPath)/CalendarBase/$(StandalonePackageAssemblyPath)/CalendarBase.dll + False + False + + + $(RelativePkgFolderPath)/CrtBaseConsts/$(StandalonePackageAssemblyPath)/CrtBaseConsts.dll + False + False + + + $(RelativePkgFolderPath)/CrtC360InUiV2/$(StandalonePackageAssemblyPath)/CrtC360InUiV2.dll + False + False + + + $(RelativePkgFolderPath)/CrtCalendar/$(StandalonePackageAssemblyPath)/CrtCalendar.dll + False + False + + + $(RelativePkgFolderPath)/CrtCall/$(StandalonePackageAssemblyPath)/CrtCall.dll + False + False + + + $(RelativePkgFolderPath)/CrtCase7x/$(StandalonePackageAssemblyPath)/CrtCase7x.dll + False + False + + + $(RelativePkgFolderPath)/CrtCaseManagement/$(StandalonePackageAssemblyPath)/CrtCaseManagement.dll + False + False + + + $(RelativePkgFolderPath)/CrtCaseManagementApp/$(StandalonePackageAssemblyPath)/CrtCaseManagementApp.dll + False + False + + + $(RelativePkgFolderPath)/CrtCaseManagmentObject/$(StandalonePackageAssemblyPath)/CrtCaseManagmentObject.dll + False + False + + + $(RelativePkgFolderPath)/CrtCaseService/$(StandalonePackageAssemblyPath)/CrtCaseService.dll + False + False + + + $(RelativePkgFolderPath)/CrtCaseService7x/$(StandalonePackageAssemblyPath)/CrtCaseService7x.dll + False + False + + + $(RelativePkgFolderPath)/CrtCasesInC360/$(StandalonePackageAssemblyPath)/CrtCasesInC360.dll + False + False + + + $(RelativePkgFolderPath)/CrtCopilot/$(StandalonePackageAssemblyPath)/CrtCopilot.dll + False + False + + + $(RelativePkgFolderPath)/CrtCore/$(StandalonePackageAssemblyPath)/CrtCore.dll + False + False + + + $(RelativePkgFolderPath)/CrtCoreBase/$(StandalonePackageAssemblyPath)/CrtCoreBase.dll + False + False + + + $(RelativePkgFolderPath)/CrtCustomer360App/$(StandalonePackageAssemblyPath)/CrtCustomer360App.dll + False + False + + + $(RelativePkgFolderPath)/CrtCustomer360Mobile/$(StandalonePackageAssemblyPath)/CrtCustomer360Mobile.dll + False + False + + + $(RelativePkgFolderPath)/CrtCustomerInfoInCaseMgmt/$(StandalonePackageAssemblyPath)/CrtCustomerInfoInCaseMgmt.dll + False + False + + + $(RelativePkgFolderPath)/CrtEmailMessage/$(StandalonePackageAssemblyPath)/CrtEmailMessage.dll + False + False + + + $(RelativePkgFolderPath)/CrtEmailSender/$(StandalonePackageAssemblyPath)/CrtEmailSender.dll + False + False + + + $(RelativePkgFolderPath)/CrtEmailTemplate/$(StandalonePackageAssemblyPath)/CrtEmailTemplate.dll + False + False + + + $(RelativePkgFolderPath)/CrtFeatureToggling/$(StandalonePackageAssemblyPath)/CrtFeatureToggling.dll + False + False + + + $(RelativePkgFolderPath)/CrtJunkFilter/$(StandalonePackageAssemblyPath)/CrtJunkFilter.dll + False + False + + + $(RelativePkgFolderPath)/CrtKnowledgeManagementApp/$(StandalonePackageAssemblyPath)/CrtKnowledgeManagementApp.dll + False + False + + + $(RelativePkgFolderPath)/CrtKnowledgeManagementInCaseManagement/$(StandalonePackageAssemblyPath)/CrtKnowledgeManagementInCaseManagement.dll + False + False + + + $(RelativePkgFolderPath)/CrtKnowledgeManagementObject/$(StandalonePackageAssemblyPath)/CrtKnowledgeManagementObject.dll + False + False + + + $(RelativePkgFolderPath)/CrtML/$(StandalonePackageAssemblyPath)/CrtML.dll + False + False + + + $(RelativePkgFolderPath)/CrtMLangContent/$(StandalonePackageAssemblyPath)/CrtMLangContent.dll + False + False + + + $(RelativePkgFolderPath)/CrtMacrosBase/$(StandalonePackageAssemblyPath)/CrtMacrosBase.dll + False + False + + + $(RelativePkgFolderPath)/CrtMessage/$(StandalonePackageAssemblyPath)/CrtMessage.dll + False + False + + + $(RelativePkgFolderPath)/CrtNextStep/$(StandalonePackageAssemblyPath)/CrtNextStep.dll + False + False + + + $(RelativePkgFolderPath)/CrtOmnichannelApp/$(StandalonePackageAssemblyPath)/CrtOmnichannelApp.dll + False + False + + + $(RelativePkgFolderPath)/CrtPlaybook/$(StandalonePackageAssemblyPath)/CrtPlaybook.dll + False + False + + + $(RelativePkgFolderPath)/CrtPortal/$(StandalonePackageAssemblyPath)/CrtPortal.dll + False + False + + + $(RelativePkgFolderPath)/CrtProductivityApp/$(StandalonePackageAssemblyPath)/CrtProductivityApp.dll + False + False + + + $(RelativePkgFolderPath)/CrtProductivityMobile/$(StandalonePackageAssemblyPath)/CrtProductivityMobile.dll + False + False + + + $(RelativePkgFolderPath)/CrtSLAInC360/$(StandalonePackageAssemblyPath)/CrtSLAInC360.dll + False + False + + + $(RelativePkgFolderPath)/CrtSLM/$(StandalonePackageAssemblyPath)/CrtSLM.dll + False + False + + + $(RelativePkgFolderPath)/CrtSLM7x/$(StandalonePackageAssemblyPath)/CrtSLM7x.dll + False + False + + + $(RelativePkgFolderPath)/CrtSLMITILService/$(StandalonePackageAssemblyPath)/CrtSLMITILService.dll + False + False + + + $(RelativePkgFolderPath)/CrtSLMITILService7x/$(StandalonePackageAssemblyPath)/CrtSLMITILService7x.dll + False + False + + + $(RelativePkgFolderPath)/CrtSecurity/$(StandalonePackageAssemblyPath)/CrtSecurity.dll + False + False + + + $(RelativePkgFolderPath)/CrtSecurity7x/$(StandalonePackageAssemblyPath)/CrtSecurity7x.dll + False + False + + + $(RelativePkgFolderPath)/CrtSmsIntegration/$(StandalonePackageAssemblyPath)/CrtSmsIntegration.dll + False + False + + + $(RelativePkgFolderPath)/CrtTechnicalUsers/$(StandalonePackageAssemblyPath)/CrtTechnicalUsers.dll + False + False + + + $(RelativePkgFolderPath)/CrtTouchPointBase/$(StandalonePackageAssemblyPath)/CrtTouchPointBase.dll + False + False + + + $(RelativePkgFolderPath)/CrtUIPlatform/$(StandalonePackageAssemblyPath)/CrtUIPlatform.dll + False + False + + + $(RelativePkgFolderPath)/CrtUIPlatform7x/$(StandalonePackageAssemblyPath)/CrtUIPlatform7x.dll + False + False + + + $(RelativePkgFolderPath)/CrtUISwitcher/$(StandalonePackageAssemblyPath)/CrtUISwitcher.dll + False + False + + + $(RelativePkgFolderPath)/CrtWebFormBase/$(StandalonePackageAssemblyPath)/CrtWebFormBase.dll + False + False + + + $(RelativePkgFolderPath)/CrtWebhookServiceBase/$(StandalonePackageAssemblyPath)/CrtWebhookServiceBase.dll + False + False + + + $(RelativePkgFolderPath)/FeatureToggling/$(StandalonePackageAssemblyPath)/FeatureToggling.dll + False + False + + + $(RelativePkgFolderPath)/Finesse/$(StandalonePackageAssemblyPath)/Finesse.dll + False + False + + + $(RelativePkgFolderPath)/MLCaseClassification/$(StandalonePackageAssemblyPath)/MLCaseClassification.dll + False + False + + + $(RelativePkgFolderPath)/MLProcessDesigner/$(StandalonePackageAssemblyPath)/MLProcessDesigner.dll + False + False + + + $(RelativePkgFolderPath)/MLSimilarCaseSearch/$(StandalonePackageAssemblyPath)/MLSimilarCaseSearch.dll + False + False + + + $(RelativePkgFolderPath)/OpenIdAuth/$(StandalonePackageAssemblyPath)/OpenIdAuth.dll + False + False + + + $(RelativePkgFolderPath)/SsoSettings/$(StandalonePackageAssemblyPath)/SsoSettings.dll + False + False + + + $(RelativePkgFolderPath)/../bin/Terrasoft.Configuration.dll + False + False + + + $(RelativePkgFolderPath)/WebitelChats/$(StandalonePackageAssemblyPath)/WebitelChats.dll + False + False + + + $(RelativePkgFolderPath)/XSSProtection/$(StandalonePackageAssemblyPath)/XSSProtection.dll + False + False + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +"#; diff --git a/src/pkg/xml-wrappers/tests_data_1.rs b/src/pkg/xml-wrappers/tests_data_1.rs new file mode 100644 index 0000000..8a5d026 --- /dev/null +++ b/src/pkg/xml-wrappers/tests_data_1.rs @@ -0,0 +1,1104 @@ +pub const INPUT: &str = r#" + + + False + net472 + $(CoreTargetFramework) + + + true + full + false + + + false + pdbonly + true + MSB3277;MSB3245;MSB3243 + CS1522,CS0162 + + + + <_Parameter1>All + + + + ../ + $(RelativeCurrentPkgFolderPath).. + $(RelativePkgFolderPath)/../Lib + + + + + $(RelativePkgFolderPath)/../../bin + Files/Bin + + + + + $(RelativePkgFolderPath)/../.. + MSB3277;MSB3245;MSB3243 + Files/Bin/netstandard + + + + + $(RelativeCurrentPkgFolderPath)$(StandalonePackageAssemblyPath) + + + + + + + + + + + + + + + + + + + + + + + + False + + + False + + + + + + + + + + + + + $(CoreLibPath)/Terrasoft.Reports.dll + False + False + + + $(CoreLibPath)/Terrasoft.GoogleServices.dll + False + False + + + + + + + $(CoreLibPath)/Terrasoft.Messaging.Common.Standard.dll + False + False + + + $(CoreLibPath)/Terrasoft.ServiceModel.Primitives.dll + False + False + + + + + + + $(CoreLibPath)/Terrasoft.Authentication.dll + False + False + + + $(CoreLibPath)/Terrasoft.Authentication.Contract.dll + False + False + + + $(CoreLibPath)/Terrasoft.Common.dll + False + False + + + $(CoreLibPath)/Terrasoft.IO.dll + False + False + + + $(CoreLibPath)/Terrasoft.File.Abstractions.dll + False + False + + + $(CoreLibPath)/Creatio.FeatureToggling.dll + False + False + + + $(CoreLibPath)/Terrasoft.File.dll + False + False + + + $(CoreLibPath)/Terrasoft.ServiceBus.Abstractions.dll + False + False + + + $(CoreLibPath)/Terrasoft.ServiceBus.dll + False + False + + + $(CoreLibPath)/Terrasoft.Core.dll + False + False + + + $(CoreLibPath)/Terrasoft.Core.ConfigurationBuild.dll + False + False + + + $(CoreLibPath)/Terrasoft.Core.Packages.dll + False + False + + + $(CoreLibPath)/Terrasoft.Core.Process.dll + False + False + + + $(CoreLibPath)/Terrasoft.Core.Scheduler.dll + False + False + + + $(CoreLibPath)/Terrasoft.Core.ServiceModelContract.dll + False + False + + + $(CoreLibPath)/Terrasoft.Core.Translation.dll + False + False + + + $(CoreLibPath)/Terrasoft.ElasticSearch.dll + False + False + + + $(CoreLibPath)/Terrasoft.GlobalSearch.dll + False + False + + + $(CoreLibPath)/Terrasoft.GoogleServerConnector.dll + False + False + + + $(CoreLibPath)/Terrasoft.GoogleServices.dll + False + False + + + $(CoreLibPath)/Terrasoft.Messaging.Common.dll + False + False + + + $(CoreLibPath)/Terrasoft.Messaging.Common.Standard.dll + False + False + + + $(CoreLibPath)/Terrasoft.Mobile.dll + False + False + + + $(CoreLibPath)/Terrasoft.Monitoring.dll + False + False + + + $(CoreLibPath)/Terrasoft.NewtonsoftWrapper.dll + False + False + + + $(CoreLibPath)/Terrasoft.Nui.dll + False + False + + + $(CoreLibPath)/Terrasoft.Nui.ServiceModel.dll + False + False + + + $(CoreLibPath)/Terrasoft.Reports.dll + False + False + + + $(CoreLibPath)/Terrasoft.Services.dll + False + False + + + $(CoreLibPath)/Terrasoft.Social.dll + False + False + + + $(CoreLibPath)/Terrasoft.Sync.dll + False + False + + + $(CoreLibPath)/Terrasoft.UI.Common.dll + False + False + + + $(CoreLibPath)/Terrasoft.Web.Common.dll + False + False + + + $(CoreLibPath)/Terrasoft.Web.Http.Abstractions.dll + False + False + + + $(CoreLibPath)/Terrasoft.ComponentSpace.Interfaces.dll + False + False + + + $(CoreLibPath)/Terrasoft.OAuthIntegration.dll + False + False + + + $(CoreLibPath)/Terrasoft.Web.FileSecurity.dll + False + False + + + $(CoreLibPath)/Terrasoft.Web.Security.dll + False + False + + + $(CoreLibPath)/Terrasoft.Core.Applications.dll + False + False + + + $(CoreLibPath)/Terrasoft.SmsIntegration.dll + False + False + + + $(CoreLibPath)/Common.Logging.dll + False + False + + + $(CoreLibPath)/Common.Logging.Core.dll + False + False + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +"#; + +pub const INPUT_2: &str = r#" + + + False + net472 + $(CoreTargetFramework) + + + true + full + false + + + false + pdbonly + true + MSB3277;MSB3245;MSB3243 + CS1522,CS0162 + + + + <_Parameter1>All + + + + ../ + $(RelativeCurrentPkgFolderPath).. + $(RelativePkgFolderPath)/../Lib + + + + + $(RelativePkgFolderPath)/../../bin + Files/Bin + + + + + $(RelativePkgFolderPath)/../.. + MSB3277;MSB3245;MSB3243 + Files/Bin/netstandard + + + + + $(RelativeCurrentPkgFolderPath)$(StandalonePackageAssemblyPath) + + + + + + + + + + + + + + + + + + + + + + + + False + + + False + + + + + + + + + + + + + $(CoreLibPath)/Terrasoft.Reports.dll + False + False + + + $(CoreLibPath)/Terrasoft.GoogleServices.dll + False + False + + + + + + + $(CoreLibPath)/Terrasoft.Messaging.Common.Standard.dll + False + False + + + $(CoreLibPath)/Terrasoft.ServiceModel.Primitives.dll + False + False + + + + + + + $(CoreLibPath)/Terrasoft.Authentication.dll + False + False + + + $(CoreLibPath)/Terrasoft.Authentication.Contract.dll + False + False + + + $(CoreLibPath)/Terrasoft.Common.dll + False + False + + + $(CoreLibPath)/Terrasoft.IO.dll + False + False + + + $(CoreLibPath)/Terrasoft.File.Abstractions.dll + False + False + + + $(CoreLibPath)/Creatio.FeatureToggling.dll + False + False + + + $(CoreLibPath)/Terrasoft.File.dll + False + False + + + $(CoreLibPath)/Terrasoft.ServiceBus.Abstractions.dll + False + False + + + $(CoreLibPath)/Terrasoft.ServiceBus.dll + False + False + + + $(CoreLibPath)/Terrasoft.Core.dll + False + False + + + $(CoreLibPath)/Terrasoft.Core.ConfigurationBuild.dll + False + False + + + $(CoreLibPath)/Terrasoft.Core.Packages.dll + False + False + + + $(CoreLibPath)/Terrasoft.Core.Process.dll + False + False + + + $(CoreLibPath)/Terrasoft.Core.Scheduler.dll + False + False + + + $(CoreLibPath)/Terrasoft.Core.ServiceModelContract.dll + False + False + + + $(CoreLibPath)/Terrasoft.Core.Translation.dll + False + False + + + $(CoreLibPath)/Terrasoft.ElasticSearch.dll + False + False + + + $(CoreLibPath)/Terrasoft.GlobalSearch.dll + False + False + + + $(CoreLibPath)/Terrasoft.GoogleServerConnector.dll + False + False + + + $(CoreLibPath)/Terrasoft.GoogleServices.dll + False + False + + + $(CoreLibPath)/Terrasoft.Messaging.Common.dll + False + False + + + $(CoreLibPath)/Terrasoft.Messaging.Common.Standard.dll + False + False + + + $(CoreLibPath)/Terrasoft.Mobile.dll + False + False + + + $(CoreLibPath)/Terrasoft.Monitoring.dll + False + False + + + $(CoreLibPath)/Terrasoft.NewtonsoftWrapper.dll + False + False + + + $(CoreLibPath)/Terrasoft.Nui.dll + False + False + + + $(CoreLibPath)/Terrasoft.Nui.ServiceModel.dll + False + False + + + $(CoreLibPath)/Terrasoft.Reports.dll + False + False + + + $(CoreLibPath)/Terrasoft.Services.dll + False + False + + + $(CoreLibPath)/Terrasoft.Social.dll + False + False + + + $(CoreLibPath)/Terrasoft.Sync.dll + False + False + + + $(CoreLibPath)/Terrasoft.UI.Common.dll + False + False + + + $(CoreLibPath)/Terrasoft.Web.Common.dll + False + False + + + $(CoreLibPath)/Terrasoft.Web.Http.Abstractions.dll + False + False + + + $(CoreLibPath)/Terrasoft.ComponentSpace.Interfaces.dll + False + False + + + $(CoreLibPath)/Terrasoft.OAuthIntegration.dll + False + False + + + $(CoreLibPath)/Terrasoft.Web.FileSecurity.dll + False + False + + + $(CoreLibPath)/Terrasoft.Web.Security.dll + False + False + + + $(CoreLibPath)/Terrasoft.Core.Applications.dll + False + False + + + $(CoreLibPath)/Terrasoft.SmsIntegration.dll + False + False + + + $(CoreLibPath)/Common.Logging.dll + False + False + + + $(CoreLibPath)/Common.Logging.Core.dll + False + False + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +"#; + +pub const INPUT_3: &str = r#" + + + False + net472 + $(CoreTargetFramework) + + + true + full + false + + + false + pdbonly + true + MSB3277;MSB3245;MSB3243 + CS1522,CS0162 + + + + <_Parameter1>All + + + + ../ + $(RelativeCurrentPkgFolderPath).. + $(RelativePkgFolderPath)/../Lib + + + + + $(RelativePkgFolderPath)/../../bin + Files/Bin + + + + + $(RelativePkgFolderPath)/../.. + MSB3277;MSB3245;MSB3243 + Files/Bin/netstandard + + + + + $(RelativeCurrentPkgFolderPath)$(StandalonePackageAssemblyPath) + + + + + + + + + + + + + + + + + + + + + + + + False + + + False + + + + + + + + + + + + + $(CoreLibPath)/Terrasoft.Reports.dll + False + False + + + $(CoreLibPath)/Terrasoft.GoogleServices.dll + False + False + + + + + + + $(CoreLibPath)/Terrasoft.Messaging.Common.Standard.dll + False + False + + + $(CoreLibPath)/Terrasoft.ServiceModel.Primitives.dll + False + False + + + + + + + $(CoreLibPath)/Terrasoft.Authentication.dll + False + False + + + $(CoreLibPath)/Terrasoft.Authentication.Contract.dll + False + False + + + $(CoreLibPath)/Terrasoft.Common.dll + False + False + + + $(CoreLibPath)/Terrasoft.IO.dll + False + False + + + $(CoreLibPath)/Terrasoft.File.Abstractions.dll + False + False + + + $(CoreLibPath)/Creatio.FeatureToggling.dll + False + False + + + $(CoreLibPath)/Terrasoft.File.dll + False + False + + + $(CoreLibPath)/Terrasoft.ServiceBus.Abstractions.dll + False + False + + + $(CoreLibPath)/Terrasoft.ServiceBus.dll + False + False + + + $(CoreLibPath)/Terrasoft.Core.dll + False + False + + + $(CoreLibPath)/Terrasoft.Core.ConfigurationBuild.dll + False + False + + + $(CoreLibPath)/Terrasoft.Core.Packages.dll + False + False + + + $(CoreLibPath)/Terrasoft.Core.Process.dll + False + False + + + $(CoreLibPath)/Terrasoft.Core.Scheduler.dll + False + False + + + $(CoreLibPath)/Terrasoft.Core.ServiceModelContract.dll + False + False + + + $(CoreLibPath)/Terrasoft.Core.Translation.dll + False + False + + + $(CoreLibPath)/Terrasoft.ElasticSearch.dll + False + False + + + $(CoreLibPath)/Terrasoft.GlobalSearch.dll + False + False + + + $(CoreLibPath)/Terrasoft.GoogleServerConnector.dll + False + False + + + $(CoreLibPath)/Terrasoft.GoogleServices.dll + False + False + + + $(CoreLibPath)/Terrasoft.Messaging.Common.dll + False + False + + + $(CoreLibPath)/Terrasoft.Messaging.Common.Standard.dll + False + False + + + $(CoreLibPath)/Terrasoft.Mobile.dll + False + False + + + $(CoreLibPath)/Terrasoft.Monitoring.dll + False + False + + + $(CoreLibPath)/Terrasoft.NewtonsoftWrapper.dll + False + False + + + $(CoreLibPath)/Terrasoft.Nui.dll + False + False + + + $(CoreLibPath)/Terrasoft.Nui.ServiceModel.dll + False + False + + + $(CoreLibPath)/Terrasoft.Reports.dll + False + False + + + $(CoreLibPath)/Terrasoft.Services.dll + False + False + + + $(CoreLibPath)/Terrasoft.Social.dll + False + False + + + $(CoreLibPath)/Terrasoft.Sync.dll + False + False + + + $(CoreLibPath)/Terrasoft.UI.Common.dll + False + False + + + $(CoreLibPath)/Terrasoft.Web.Common.dll + False + False + + + $(CoreLibPath)/Terrasoft.Web.Http.Abstractions.dll + False + False + + + $(CoreLibPath)/Terrasoft.ComponentSpace.Interfaces.dll + False + False + + + $(CoreLibPath)/Terrasoft.OAuthIntegration.dll + False + False + + + $(CoreLibPath)/Terrasoft.Web.FileSecurity.dll + False + False + + + $(CoreLibPath)/Terrasoft.Web.Security.dll + False + False + + + $(CoreLibPath)/Terrasoft.Core.Applications.dll + False + False + + + $(CoreLibPath)/Terrasoft.SmsIntegration.dll + False + False + + + $(CoreLibPath)/Common.Logging.dll + False + False + + + $(CoreLibPath)/Common.Logging.Core.dll + False + False + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +"#; diff --git a/src/utils/bom.rs b/src/utils/bom.rs new file mode 100644 index 0000000..50d3502 --- /dev/null +++ b/src/utils/bom.rs @@ -0,0 +1,13 @@ +pub const BOM_CHAR: &str = "\u{FEFF}"; +pub const BOM_CHAR_BYTES: &[u8] = BOM_CHAR.as_bytes(); + +pub fn is_bom(s: &[u8]) -> bool { + s.starts_with(BOM_CHAR_BYTES) +} + +pub fn trim_bom(s: &[u8]) -> &[u8] { + match is_bom(s) { + true => &s[BOM_CHAR_BYTES.len()..s.len()], + false => s, + } +} diff --git a/src/utils/json_msdate_preserve_formatter.rs b/src/utils/json_msdate_preserve_formatter.rs new file mode 100644 index 0000000..13bca85 --- /dev/null +++ b/src/utils/json_msdate_preserve_formatter.rs @@ -0,0 +1,267 @@ +use serde_json::ser::{CharEscape, Formatter}; +use std::io::Write; + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct JsonMsDatePreserveFormatter +where + F: Formatter, +{ + inner_formatter: F, +} + +impl JsonMsDatePreserveFormatter { + pub fn new(inner_formatter: F) -> Self { + Self { inner_formatter } + } +} + +#[allow(dead_code)] +impl JsonMsDatePreserveFormatter { + pub fn new_compact() -> Self { + Self::new(serde_json::ser::CompactFormatter) + } +} + +impl JsonMsDatePreserveFormatter> { + pub fn new_pretty() -> Self { + Self::new(serde_json::ser::PrettyFormatter::new()) + } +} + +impl Formatter for JsonMsDatePreserveFormatter { + fn write_null(&mut self, writer: &mut W) -> std::io::Result<()> + where + W: ?Sized + Write, + { + self.inner_formatter.write_null(writer) + } + + fn write_bool(&mut self, writer: &mut W, value: bool) -> std::io::Result<()> + where + W: ?Sized + Write, + { + self.inner_formatter.write_bool(writer, value) + } + + fn write_i8(&mut self, writer: &mut W, value: i8) -> std::io::Result<()> + where + W: ?Sized + Write, + { + self.inner_formatter.write_i8(writer, value) + } + + fn write_i16(&mut self, writer: &mut W, value: i16) -> std::io::Result<()> + where + W: ?Sized + Write, + { + self.inner_formatter.write_i16(writer, value) + } + + fn write_i32(&mut self, writer: &mut W, value: i32) -> std::io::Result<()> + where + W: ?Sized + Write, + { + self.inner_formatter.write_i32(writer, value) + } + + fn write_i64(&mut self, writer: &mut W, value: i64) -> std::io::Result<()> + where + W: ?Sized + Write, + { + self.inner_formatter.write_i64(writer, value) + } + + fn write_i128(&mut self, writer: &mut W, value: i128) -> std::io::Result<()> + where + W: ?Sized + Write, + { + self.inner_formatter.write_i128(writer, value) + } + + fn write_u8(&mut self, writer: &mut W, value: u8) -> std::io::Result<()> + where + W: ?Sized + Write, + { + self.inner_formatter.write_u8(writer, value) + } + + fn write_u16(&mut self, writer: &mut W, value: u16) -> std::io::Result<()> + where + W: ?Sized + Write, + { + self.inner_formatter.write_u16(writer, value) + } + + fn write_u32(&mut self, writer: &mut W, value: u32) -> std::io::Result<()> + where + W: ?Sized + Write, + { + self.inner_formatter.write_u32(writer, value) + } + + fn write_u64(&mut self, writer: &mut W, value: u64) -> std::io::Result<()> + where + W: ?Sized + Write, + { + self.inner_formatter.write_u64(writer, value) + } + + fn write_u128(&mut self, writer: &mut W, value: u128) -> std::io::Result<()> + where + W: ?Sized + Write, + { + self.inner_formatter.write_u128(writer, value) + } + + fn write_f32(&mut self, writer: &mut W, value: f32) -> std::io::Result<()> + where + W: ?Sized + Write, + { + self.inner_formatter.write_f32(writer, value) + } + + fn write_f64(&mut self, writer: &mut W, value: f64) -> std::io::Result<()> + where + W: ?Sized + Write, + { + self.inner_formatter.write_f64(writer, value) + } + + fn write_number_str(&mut self, writer: &mut W, value: &str) -> std::io::Result<()> + where + W: ?Sized + Write, + { + self.inner_formatter.write_number_str(writer, value) + } + + fn begin_string(&mut self, writer: &mut W) -> std::io::Result<()> + where + W: ?Sized + Write, + { + self.inner_formatter.begin_string(writer) + } + + fn end_string(&mut self, writer: &mut W) -> std::io::Result<()> + where + W: ?Sized + Write, + { + self.inner_formatter.end_string(writer) + } + + fn write_string_fragment(&mut self, writer: &mut W, fragment: &str) -> std::io::Result<()> + where + W: ?Sized + Write, + { + if fragment.starts_with("/Date(") && fragment.ends_with(")/") { + let timestamp = &fragment[6..(fragment.len() - 2)]; + + // \/Date(1729879200000+0300)\/ + let is_timestamp_valid = timestamp.chars().all(|c| c.is_ascii_digit() || c == '+'); + + if is_timestamp_valid { + return self + .inner_formatter + .write_string_fragment(writer, &format!("\\/Date({timestamp})\\/")); + } + } + + self.inner_formatter.write_string_fragment(writer, fragment) + } + + fn write_char_escape( + &mut self, + writer: &mut W, + char_escape: CharEscape, + ) -> std::io::Result<()> + where + W: ?Sized + Write, + { + self.inner_formatter.write_char_escape(writer, char_escape) + } + + fn write_byte_array(&mut self, writer: &mut W, value: &[u8]) -> std::io::Result<()> + where + W: ?Sized + Write, + { + self.inner_formatter.write_byte_array(writer, value) + } + + fn begin_array(&mut self, writer: &mut W) -> std::io::Result<()> + where + W: ?Sized + Write, + { + self.inner_formatter.begin_array(writer) + } + + fn end_array(&mut self, writer: &mut W) -> std::io::Result<()> + where + W: ?Sized + Write, + { + self.inner_formatter.end_array(writer) + } + + fn begin_array_value(&mut self, writer: &mut W, first: bool) -> std::io::Result<()> + where + W: ?Sized + Write, + { + self.inner_formatter.begin_array_value(writer, first) + } + + fn end_array_value(&mut self, _writer: &mut W) -> std::io::Result<()> + where + W: ?Sized + Write, + { + self.inner_formatter.end_array_value(_writer) + } + + fn begin_object(&mut self, writer: &mut W) -> std::io::Result<()> + where + W: ?Sized + Write, + { + self.inner_formatter.begin_object(writer) + } + + fn end_object(&mut self, writer: &mut W) -> std::io::Result<()> + where + W: ?Sized + Write, + { + self.inner_formatter.end_object(writer) + } + + fn begin_object_key(&mut self, writer: &mut W, first: bool) -> std::io::Result<()> + where + W: ?Sized + Write, + { + self.inner_formatter.begin_object_key(writer, first) + } + + fn end_object_key(&mut self, _writer: &mut W) -> std::io::Result<()> + where + W: ?Sized + Write, + { + self.inner_formatter.end_object_key(_writer) + } + + fn begin_object_value(&mut self, writer: &mut W) -> std::io::Result<()> + where + W: ?Sized + Write, + { + self.inner_formatter.begin_object_value(writer) + } + + fn end_object_value(&mut self, _writer: &mut W) -> std::io::Result<()> + where + W: ?Sized + Write, + { + self.inner_formatter.end_object_value(_writer) + } + + fn write_raw_fragment(&mut self, writer: &mut W, fragment: &str) -> std::io::Result<()> + where + W: ?Sized + Write, + { + self.inner_formatter.write_raw_fragment(writer, fragment) + } + + //#endregion +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..55ecf07 --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1,4 @@ +pub mod bom; + +mod json_msdate_preserve_formatter; +pub use json_msdate_preserve_formatter::*;