diff --git a/.github/workflows/jstruct-cli.yml b/.github/workflows/jstruct-cli.yml new file mode 100644 index 0000000..4b61aa2 --- /dev/null +++ b/.github/workflows/jstruct-cli.yml @@ -0,0 +1,849 @@ +name: jstruct CLI + +on: + push: + branches: [master, main] + paths: + - 'rust/**' + - '.github/workflows/jstruct-cli.yml' + tags: + - 'jstruct-v[0-9]+.[0-9]+.[0-9]+' + pull_request: + branches: [master, main] + paths: + - 'rust/**' + - '.github/workflows/jstruct-cli.yml' + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + CLI_NAME: jstruct + +jobs: + # Build CLI binaries for all platforms + build: + name: Build ${{ matrix.target }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + # Linux x86_64 + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + artifact: jstruct + # Linux ARM64 + - target: aarch64-unknown-linux-gnu + os: ubuntu-latest + artifact: jstruct + cross: true + # Windows x86_64 + - target: x86_64-pc-windows-msvc + os: windows-latest + artifact: jstruct.exe + # Windows ARM64 + - target: aarch64-pc-windows-msvc + os: windows-latest + artifact: jstruct.exe + # macOS x86_64 (Intel) + - target: x86_64-apple-darwin + os: macos-latest + artifact: jstruct + # macOS ARM64 (Apple Silicon) + - target: aarch64-apple-darwin + os: macos-latest + artifact: jstruct + + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Install cross-compilation tools (Linux ARM64) + if: matrix.cross && matrix.target == 'aarch64-unknown-linux-gnu' + run: | + sudo apt-get update + sudo apt-get install -y gcc-aarch64-linux-gnu + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + rust/target + key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('rust/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-${{ matrix.target }}-cargo- + + - name: Build CLI (native) + if: ${{ !matrix.cross }} + working-directory: rust + run: cargo build --release --features cli --target ${{ matrix.target }} + + - name: Build CLI (cross) + if: matrix.cross + working-directory: rust + env: + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc + run: cargo build --release --features cli --target ${{ matrix.target }} + + - name: Upload binary + uses: actions/upload-artifact@v4 + with: + name: ${{ env.CLI_NAME }}-${{ matrix.target }} + path: rust/target/${{ matrix.target }}/release/${{ matrix.artifact }} + if-no-files-found: error + + # Run CLI tests + test-cli: + name: Test CLI + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + + - name: Run CLI tests + working-directory: rust + run: cargo test --features cli --test cli_tests + + # Smoke test the generated binaries + test-binaries: + name: Test Binary ${{ matrix.target }} + needs: build + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + # Native tests (can run directly) + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + artifact: jstruct + - target: x86_64-pc-windows-msvc + os: windows-latest + artifact: jstruct.exe + - target: x86_64-apple-darwin + os: macos-13 + artifact: jstruct + - target: aarch64-apple-darwin + os: macos-latest + artifact: jstruct + + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Download binary + uses: actions/download-artifact@v4 + with: + name: ${{ env.CLI_NAME }}-${{ matrix.target }} + path: bin + + - name: Make binary executable (Unix) + if: runner.os != 'Windows' + run: chmod +x bin/${{ matrix.artifact }} + + - name: Test --version + run: bin/${{ matrix.artifact }} --version + + - name: Test --help + run: bin/${{ matrix.artifact }} --help + + - name: Test check --help + run: bin/${{ matrix.artifact }} check --help + + - name: Test validate --help + run: bin/${{ matrix.artifact }} validate --help + + - name: Test check valid schema + run: bin/${{ matrix.artifact }} check test-assets/schemas/validation/numeric-minimum-with-uses.struct.json + + - name: Test check invalid schema (expect exit 1) + shell: bash + run: | + if bin/${{ matrix.artifact }} check test-assets/schemas/invalid/missing-type.struct.json; then + echo "Expected exit code 1 but got 0" + exit 1 + fi + + - name: Test validate valid instance + shell: bash + run: | + echo '10' | bin/${{ matrix.artifact }} validate -s test-assets/schemas/validation/numeric-minimum-with-uses.struct.json - + + - name: Test validate invalid instance (expect exit 1) + shell: bash + run: | + # Use a string where int32 is expected - type mismatch should fail + if echo '"not-a-number"' | bin/${{ matrix.artifact }} validate -s test-assets/schemas/validation/numeric-minimum-with-uses.struct.json -; then + echo "Expected exit code 1 but got 0" + exit 1 + fi + + - name: Test JSON output format + shell: bash + run: | + OUTPUT=$(bin/${{ matrix.artifact }} check --format json test-assets/schemas/validation/numeric-minimum-with-uses.struct.json) + echo "$OUTPUT" | grep -q '"valid": true' || (echo "JSON output missing valid field"; exit 1) + + - name: Test TAP output format + shell: bash + run: | + OUTPUT=$(bin/${{ matrix.artifact }} check --format tap test-assets/schemas/validation/numeric-minimum-with-uses.struct.json) + echo "$OUTPUT" | grep -q '^ok 1' || (echo "TAP output missing ok line"; exit 1) + + - name: Test multiple files + run: bin/${{ matrix.artifact }} check test-assets/schemas/validation/numeric-minimum-with-uses.struct.json test-assets/schemas/validation/string-pattern-with-uses.struct.json + + - name: Test quiet mode + run: bin/${{ matrix.artifact }} check -q test-assets/schemas/validation/numeric-minimum-with-uses.struct.json + + # Create DEB package for Debian/Ubuntu + package-deb: + name: Package DEB + needs: build + runs-on: ubuntu-latest + strategy: + matrix: + arch: [amd64, arm64] + include: + - arch: amd64 + target: x86_64-unknown-linux-gnu + - arch: arm64 + target: aarch64-unknown-linux-gnu + + steps: + - uses: actions/checkout@v4 + + - name: Download binary + uses: actions/download-artifact@v4 + with: + name: ${{ env.CLI_NAME }}-${{ matrix.target }} + path: binary + + - name: Extract version + id: version + run: | + if [[ "$GITHUB_REF" == refs/tags/jstruct-v* ]]; then + VERSION=${GITHUB_REF#refs/tags/jstruct-v} + else + VERSION="0.1.0-dev" + fi + echo "VERSION=$VERSION" >> $GITHUB_OUTPUT + + - name: Create DEB package structure + run: | + mkdir -p pkg/DEBIAN + mkdir -p pkg/usr/bin + mkdir -p pkg/usr/share/doc/jstruct + mkdir -p pkg/usr/share/man/man1 + + # Copy binary + cp binary/jstruct pkg/usr/bin/ + chmod 755 pkg/usr/bin/jstruct + + # Create control file + cat > pkg/DEBIAN/control << EOF + Package: jstruct + Version: ${{ steps.version.outputs.VERSION }} + Section: utils + Priority: optional + Architecture: ${{ matrix.arch }} + Maintainer: JSON Structure Contributors + Homepage: https://json-structure.org + Description: JSON Structure schema validation CLI + A command-line tool for validating JSON Structure schemas and + validating JSON instances against JSON Structure schemas. + . + Features: + - Schema validation against the JSON Structure meta-schema + - Instance validation against user schemas + - Multiple output formats: text, JSON, TAP + EOF + + # Create copyright file + cat > pkg/usr/share/doc/jstruct/copyright << EOF + Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ + Upstream-Name: jstruct + Source: https://github.com/json-structure/sdk + + Files: * + Copyright: 2024 JSON Structure Contributors + License: MIT + EOF + + # Build package + dpkg-deb --build pkg jstruct_${{ steps.version.outputs.VERSION }}_${{ matrix.arch }}.deb + + - name: Upload DEB package + uses: actions/upload-artifact@v4 + with: + name: jstruct-deb-${{ matrix.arch }} + path: "*.deb" + + # Create RPM package for Fedora/RHEL + package-rpm: + name: Package RPM + needs: build + runs-on: ubuntu-latest + strategy: + matrix: + arch: [x86_64, aarch64] + include: + - arch: x86_64 + target: x86_64-unknown-linux-gnu + - arch: aarch64 + target: aarch64-unknown-linux-gnu + + steps: + - uses: actions/checkout@v4 + + - name: Download binary + uses: actions/download-artifact@v4 + with: + name: ${{ env.CLI_NAME }}-${{ matrix.target }} + path: binary + + - name: Install RPM tools + run: sudo apt-get update && sudo apt-get install -y rpm + + - name: Extract version + id: version + run: | + if [[ "$GITHUB_REF" == refs/tags/jstruct-v* ]]; then + VERSION=${GITHUB_REF#refs/tags/jstruct-v} + else + VERSION="0.1.0" + fi + echo "VERSION=$VERSION" >> $GITHUB_OUTPUT + + - name: Create RPM package + run: | + mkdir -p rpmbuild/{BUILD,RPMS,SOURCES,SPECS,SRPMS} + mkdir -p rpmbuild/BUILDROOT/jstruct-${{ steps.version.outputs.VERSION }}-1.${{ matrix.arch }}/usr/bin + + # Copy binary + cp binary/jstruct rpmbuild/BUILDROOT/jstruct-${{ steps.version.outputs.VERSION }}-1.${{ matrix.arch }}/usr/bin/ + chmod 755 rpmbuild/BUILDROOT/jstruct-${{ steps.version.outputs.VERSION }}-1.${{ matrix.arch }}/usr/bin/jstruct + + # Create spec file + cat > rpmbuild/SPECS/jstruct.spec << 'EOF' + Name: jstruct + Version: ${{ steps.version.outputs.VERSION }} + Release: 1 + Summary: JSON Structure schema validation CLI + License: MIT + URL: https://json-structure.org + + %description + A command-line tool for validating JSON Structure schemas and + validating JSON instances against JSON Structure schemas. + + Features: + - Schema validation against the JSON Structure meta-schema + - Instance validation against user schemas + - Multiple output formats: text, JSON, TAP + + %files + %attr(755, root, root) /usr/bin/jstruct + EOF + + # Build RPM + rpmbuild --define "_topdir $(pwd)/rpmbuild" \ + --define "_rpmdir $(pwd)" \ + --target ${{ matrix.arch }} \ + -bb rpmbuild/SPECS/jstruct.spec + + - name: Upload RPM package + uses: actions/upload-artifact@v4 + with: + name: jstruct-rpm-${{ matrix.arch }} + path: "**/*.rpm" + + # Create MSIX package for Windows + package-msix: + name: Package MSIX + needs: build + runs-on: windows-latest + strategy: + matrix: + arch: [x64, arm64] + include: + - arch: x64 + target: x86_64-pc-windows-msvc + - arch: arm64 + target: aarch64-pc-windows-msvc + + steps: + - uses: actions/checkout@v4 + + - name: Download binary + uses: actions/download-artifact@v4 + with: + name: ${{ env.CLI_NAME }}-${{ matrix.target }} + path: binary + + - name: Extract version + id: version + shell: pwsh + run: | + if ($env:GITHUB_REF -match 'refs/tags/jstruct-v(.+)') { + $version = $matches[1] + } else { + $version = "0.1.0.0" + } + # Ensure 4-part version for MSIX + $parts = $version -split '\.' + while ($parts.Count -lt 4) { $parts += "0" } + $version = ($parts[0..3] -join '.') + echo "VERSION=$version" >> $env:GITHUB_OUTPUT + + - name: Create MSIX package structure + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path msix + Copy-Item binary/jstruct.exe msix/ + + # Create AppxManifest.xml + @" + + + + + + + jstruct + JSON Structure Contributors + JSON Structure schema validation CLI + assets\logo.png + + + + + + + + + + + + + + + + + + + + + + + + + + + + + "@ | Out-File -Encoding utf8 msix/AppxManifest.xml + + # Create placeholder logo (44x44 and 150x150 PNG required) + New-Item -ItemType Directory -Force -Path msix/assets + + # Create a simple placeholder PNG using PowerShell + Add-Type -AssemblyName System.Drawing + $bmp = New-Object System.Drawing.Bitmap(150, 150) + $graphics = [System.Drawing.Graphics]::FromImage($bmp) + $graphics.Clear([System.Drawing.Color]::FromArgb(0, 120, 212)) + $font = New-Object System.Drawing.Font("Arial", 48, [System.Drawing.FontStyle]::Bold) + $brush = [System.Drawing.Brushes]::White + $graphics.DrawString("JS", $font, $brush, 30, 40) + $bmp.Save("msix/assets/logo.png", [System.Drawing.Imaging.ImageFormat]::Png) + $graphics.Dispose() + $bmp.Dispose() + + - name: Create MSIX package + shell: pwsh + run: | + # Use makeappx to create the package + $sdkPath = Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin\*\x64\makeappx.exe" | + Sort-Object { [version]($_.Directory.Parent.Name) } -Descending | + Select-Object -First 1 + + if (-not $sdkPath) { + Write-Error "makeappx.exe not found" + exit 1 + } + + & $sdkPath.FullName pack /d msix /p jstruct-${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}.msix /o + + - name: Upload MSIX package + uses: actions/upload-artifact@v4 + with: + name: jstruct-msix-${{ matrix.arch }} + path: "*.msix" + + # Create macOS packages (DMG with signed binary, and pkg installer) + package-macos: + name: Package macOS + needs: build + runs-on: macos-latest + strategy: + matrix: + arch: [x86_64, arm64] + include: + - arch: x86_64 + target: x86_64-apple-darwin + - arch: arm64 + target: aarch64-apple-darwin + + steps: + - uses: actions/checkout@v4 + + - name: Download binary + uses: actions/download-artifact@v4 + with: + name: ${{ env.CLI_NAME }}-${{ matrix.target }} + path: binary + + - name: Extract version + id: version + run: | + if [[ "$GITHUB_REF" == refs/tags/jstruct-v* ]]; then + VERSION=${GITHUB_REF#refs/tags/jstruct-v} + else + VERSION="0.1.0" + fi + echo "VERSION=$VERSION" >> $GITHUB_OUTPUT + + - name: Create PKG installer + run: | + chmod +x binary/jstruct + + # Create pkg structure + mkdir -p pkg-root/usr/local/bin + cp binary/jstruct pkg-root/usr/local/bin/ + + # Create component plist + cat > component.plist << EOF + + + + + + BundleHasStrictIdentifier + + BundleIsRelocatable + + BundleIsVersionChecked + + BundleOverwriteAction + upgrade + RootRelativeBundlePath + usr/local/bin/jstruct + + + + EOF + + # Build pkg + pkgbuild --root pkg-root \ + --identifier org.json-structure.jstruct \ + --version ${{ steps.version.outputs.VERSION }} \ + --install-location / \ + jstruct-${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}.pkg + + - name: Create tarball + run: | + mkdir -p dist + cp binary/jstruct dist/ + chmod +x dist/jstruct + tar -czvf jstruct-${{ steps.version.outputs.VERSION }}-darwin-${{ matrix.arch }}.tar.gz -C dist jstruct + + - name: Upload PKG + uses: actions/upload-artifact@v4 + with: + name: jstruct-pkg-${{ matrix.arch }} + path: "*.pkg" + + - name: Upload tarball + uses: actions/upload-artifact@v4 + with: + name: jstruct-tarball-darwin-${{ matrix.arch }} + path: "*.tar.gz" + + # Create universal macOS binary + package-macos-universal: + name: Package macOS Universal + needs: build + runs-on: macos-latest + + steps: + - uses: actions/checkout@v4 + + - name: Download x86_64 binary + uses: actions/download-artifact@v4 + with: + name: ${{ env.CLI_NAME }}-x86_64-apple-darwin + path: binary-x86_64 + + - name: Download ARM64 binary + uses: actions/download-artifact@v4 + with: + name: ${{ env.CLI_NAME }}-aarch64-apple-darwin + path: binary-arm64 + + - name: Extract version + id: version + run: | + if [[ "$GITHUB_REF" == refs/tags/jstruct-v* ]]; then + VERSION=${GITHUB_REF#refs/tags/jstruct-v} + else + VERSION="0.1.0" + fi + echo "VERSION=$VERSION" >> $GITHUB_OUTPUT + + - name: Create universal binary + run: | + chmod +x binary-x86_64/jstruct binary-arm64/jstruct + lipo -create -output jstruct binary-x86_64/jstruct binary-arm64/jstruct + chmod +x jstruct + + # Verify it's universal + file jstruct + + - name: Create universal PKG + run: | + mkdir -p pkg-root/usr/local/bin + cp jstruct pkg-root/usr/local/bin/ + + pkgbuild --root pkg-root \ + --identifier org.json-structure.jstruct \ + --version ${{ steps.version.outputs.VERSION }} \ + --install-location / \ + jstruct-${{ steps.version.outputs.VERSION }}-darwin-universal.pkg + + - name: Create universal tarball + run: | + tar -czvf jstruct-${{ steps.version.outputs.VERSION }}-darwin-universal.tar.gz jstruct + + - name: Upload universal PKG + uses: actions/upload-artifact@v4 + with: + name: jstruct-pkg-universal + path: "*.pkg" + + - name: Upload universal tarball + uses: actions/upload-artifact@v4 + with: + name: jstruct-tarball-darwin-universal + path: "*.tar.gz" + + # Create Linux tarballs + package-linux-tarball: + name: Package Linux tarball + needs: build + runs-on: ubuntu-latest + strategy: + matrix: + arch: [x86_64, aarch64] + include: + - arch: x86_64 + target: x86_64-unknown-linux-gnu + - arch: aarch64 + target: aarch64-unknown-linux-gnu + + steps: + - uses: actions/checkout@v4 + + - name: Download binary + uses: actions/download-artifact@v4 + with: + name: ${{ env.CLI_NAME }}-${{ matrix.target }} + path: binary + + - name: Extract version + id: version + run: | + if [[ "$GITHUB_REF" == refs/tags/jstruct-v* ]]; then + VERSION=${GITHUB_REF#refs/tags/jstruct-v} + else + VERSION="0.1.0" + fi + echo "VERSION=$VERSION" >> $GITHUB_OUTPUT + + - name: Create tarball + run: | + chmod +x binary/jstruct + mkdir -p dist + cp binary/jstruct dist/ + tar -czvf jstruct-${{ steps.version.outputs.VERSION }}-linux-${{ matrix.arch }}.tar.gz -C dist jstruct + + - name: Upload tarball + uses: actions/upload-artifact@v4 + with: + name: jstruct-tarball-linux-${{ matrix.arch }} + path: "*.tar.gz" + + # Create Windows ZIP + package-windows-zip: + name: Package Windows ZIP + needs: build + runs-on: windows-latest + strategy: + matrix: + arch: [x64, arm64] + include: + - arch: x64 + target: x86_64-pc-windows-msvc + - arch: arm64 + target: aarch64-pc-windows-msvc + + steps: + - uses: actions/checkout@v4 + + - name: Download binary + uses: actions/download-artifact@v4 + with: + name: ${{ env.CLI_NAME }}-${{ matrix.target }} + path: binary + + - name: Extract version + id: version + shell: pwsh + run: | + if ($env:GITHUB_REF -match 'refs/tags/jstruct-v(.+)') { + $version = $matches[1] + } else { + $version = "0.1.0" + } + echo "VERSION=$version" >> $env:GITHUB_OUTPUT + + - name: Create ZIP + shell: pwsh + run: | + Compress-Archive -Path binary/jstruct.exe -DestinationPath jstruct-${{ steps.version.outputs.VERSION }}-windows-${{ matrix.arch }}.zip + + - name: Upload ZIP + uses: actions/upload-artifact@v4 + with: + name: jstruct-zip-windows-${{ matrix.arch }} + path: "*.zip" + + # Create GitHub Release with all artifacts + release: + name: Create Release + needs: + - test-cli + - test-binaries + - package-deb + - package-rpm + - package-msix + - package-macos + - package-macos-universal + - package-linux-tarball + - package-windows-zip + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/jstruct-v') + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Extract version + id: version + run: | + VERSION=${GITHUB_REF#refs/tags/jstruct-v} + echo "VERSION=$VERSION" >> $GITHUB_OUTPUT + + - name: Prepare release assets + run: | + mkdir -p release + + # Collect all packages + find artifacts -type f \( -name "*.deb" -o -name "*.rpm" -o -name "*.msix" -o -name "*.pkg" -o -name "*.tar.gz" -o -name "*.zip" \) -exec cp {} release/ \; + + # Generate checksums + cd release + sha256sum * > SHA256SUMS.txt + + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + name: jstruct v${{ steps.version.outputs.VERSION }} + body: | + ## jstruct CLI v${{ steps.version.outputs.VERSION }} + + JSON Structure schema validation command-line tool. + + ### Installation + + #### Linux (Debian/Ubuntu) + ```bash + sudo dpkg -i jstruct_${{ steps.version.outputs.VERSION }}_amd64.deb + ``` + + #### Linux (Fedora/RHEL) + ```bash + sudo rpm -i jstruct-${{ steps.version.outputs.VERSION }}-1.x86_64.rpm + ``` + + #### macOS + ```bash + # Universal binary (Intel + Apple Silicon) + sudo installer -pkg jstruct-${{ steps.version.outputs.VERSION }}-darwin-universal.pkg -target / + + # Or via tarball + tar xzf jstruct-${{ steps.version.outputs.VERSION }}-darwin-universal.tar.gz + sudo mv jstruct /usr/local/bin/ + ``` + + #### Windows + Download and extract the ZIP, or install the MSIX package. + + ### Usage + ```bash + # Validate a schema + jstruct check schema.json + + # Validate an instance against a schema + jstruct validate --schema schema.json data.json + ``` + + See `jstruct --help` for more options. + files: release/* + draft: false + prerelease: false diff --git a/rust/CLI.md b/rust/CLI.md new file mode 100644 index 0000000..2289b45 --- /dev/null +++ b/rust/CLI.md @@ -0,0 +1,331 @@ +# jstruct - JSON Structure CLI + +A command-line tool for validating JSON Structure schemas and instances. + +## Installation + +### From Cargo (Rust) + +```bash +cargo install json-structure --features cli +``` + +### From Source + +```bash +git clone https://github.com/json-structure/sdk.git +cd sdk/rust +cargo build --release --features cli +# Binary at: target/release/jstruct +``` + +### Pre-built Binaries + +Download from [GitHub Releases](https://github.com/json-structure/sdk/releases): + +| Platform | Architecture | File | +|----------|--------------|------| +| Linux | x86_64 | `jstruct-x86_64-unknown-linux-gnu.tar.gz` | +| Linux | ARM64 | `jstruct-aarch64-unknown-linux-gnu.tar.gz` | +| Windows | x86_64 | `jstruct-x86_64-pc-windows-msvc.zip` | +| Windows | ARM64 | `jstruct-aarch64-pc-windows-msvc.zip` | +| macOS | Intel | `jstruct-x86_64-apple-darwin.tar.gz` | +| macOS | Apple Silicon | `jstruct-aarch64-apple-darwin.tar.gz` | + +### Package Managers + +**Homebrew (macOS/Linux):** +```bash +brew tap json-structure/tap +brew install jstruct +``` + +**Chocolatey (Windows):** +```powershell +choco install jstruct +``` + +**Debian/Ubuntu:** +```bash +sudo dpkg -i jstruct_*.deb +``` + +**RHEL/Fedora:** +```bash +sudo rpm -i jstruct-*.rpm +``` + +### One-liner Installers + +**Linux/macOS:** +```bash +curl -fsSL https://json-structure.org/install.sh | sh +``` + +**Windows PowerShell:** +```powershell +iwr -useb https://json-structure.org/install.ps1 | iex +``` + +## Commands + +### `jstruct check` - Validate Schema(s) + +Validate one or more JSON Structure schema files for correctness. + +```bash +jstruct check [OPTIONS] ... +``` + +**Arguments:** +- `...` - Schema file(s) to check. Use `-` for stdin. + +**Options:** +- `-b, --bundle ` - Bundle file(s) containing schemas for `$import` resolution. Can be specified multiple times. +- `-f, --format ` - Output format: `text` (default), `json`, `tap` +- `-q, --quiet` - Suppress output, use exit code only +- `-v, --verbose` - Show detailed validation information + +**Examples:** + +```bash +# Check a single schema +jstruct check person.struct.json + +# Check multiple schemas +jstruct check schemas/*.struct.json + +# Check schema with external dependencies +jstruct check --bundle common-types.json --bundle address.json order.struct.json + +# Read from stdin +cat schema.json | jstruct check - + +# JSON output for CI integration +jstruct check --format json schema.json + +# Quiet mode for scripts +jstruct check -q schema.json && echo "Valid" +``` + +### `jstruct validate` - Validate Instance(s) + +Validate JSON instance files against a schema. + +```bash +jstruct validate [OPTIONS] --schema ... +``` + +**Arguments:** +- `...` - Instance file(s) to validate. Use `-` for stdin. + +**Options:** +- `-s, --schema ` - Schema file to validate against (required) +- `-b, --bundle ` - Bundle file(s) containing schemas for `$import` resolution in the schema. Can be specified multiple times. +- `-f, --format ` - Output format: `text` (default), `json`, `tap` +- `-q, --quiet` - Suppress output, use exit code only +- `-v, --verbose` - Show detailed validation information + +**Examples:** + +```bash +# Validate a single instance +jstruct validate --schema person.struct.json alice.json + +# Validate multiple instances +jstruct validate -s schema.json data/*.json + +# Validate with schema that uses $import +jstruct validate -s order.struct.json -b common-types.json -b address.json order.json + +# Read instance from stdin +cat data.json | jstruct validate -s schema.json - + +# JSON output +jstruct validate -s schema.json --format json data.json +``` + +## Exit Codes + +| Code | Meaning | +|------|---------| +| `0` | All files valid | +| `1` | One or more files invalid | +| `2` | Error (file not found, JSON parse error, etc.) | + +## Output Formats + +### Text (Default) + +Human-readable output with symbols: + +``` +✓ person.struct.json: valid +✗ bad-schema.struct.json: invalid + - /$id: Missing required property "$id" + - /type: Unknown type "invalid_type" +``` + +### JSON + +Machine-readable JSON array: + +```json +[ + { + "file": "person.struct.json", + "valid": true, + "errors": [] + }, + { + "file": "bad-schema.struct.json", + "valid": false, + "errors": [ + { + "path": "/$id", + "code": "SCHEMA_MISSING_ID", + "message": "Missing required property \"$id\"" + } + ] + } +] +``` + +### TAP (Test Anything Protocol) + +Compatible with TAP consumers for CI/CD: + +```tap +1..2 +ok 1 - person.struct.json +not ok 2 - bad-schema.struct.json + - /$id: Missing required property "$id" + - /type: Unknown type "invalid_type" +``` + +## Schema Bundles for $import Resolution + +JSON Structure schemas can use `$import` and `$importdefs` to reference definitions from +external schemas. When validating schemas that use these keywords, you need to provide +the referenced schemas as a bundle. + +### How Bundles Work + +1. Each bundle schema must have a `$id` property with a URI +2. When the main schema uses `$import: "https://example.com/types.json"`, the validator + looks for a bundled schema with `$id: "https://example.com/types.json"` +3. If found, the definitions from the bundled schema are available for resolution + +### Example + +Given these files: + +**common-types.json:** +```json +{ + "$schema": "https://json-structure.org/meta/core/v0/#", + "$id": "https://example.com/common-types", + "definitions": { + "Address": { + "type": "object", + "properties": { + "street": { "type": "string" }, + "city": { "type": "string" } + } + } + } +} +``` + +**order.struct.json:** +```json +{ + "$schema": "https://json-structure.org/meta/core/v0/#", + "$id": "https://example.com/order", + "$import": "https://example.com/common-types", + "type": "object", + "properties": { + "orderId": { "type": "string" }, + "shippingAddress": { "type": { "$ref": "#/definitions/Address" } } + } +} +``` + +**Validate with bundle:** +```bash +jstruct check --bundle common-types.json order.struct.json +``` + +## CI/CD Integration + +### GitHub Actions + +```yaml +- name: Validate schemas + run: | + jstruct check --format tap schemas/*.struct.json +``` + +### GitLab CI + +```yaml +validate: + script: + - jstruct check --format json schemas/ > schema-results.json + artifacts: + reports: + dotenv: schema-results.json +``` + +### Pre-commit Hook + +```bash +#!/bin/sh +# .git/hooks/pre-commit + +SCHEMAS=$(git diff --cached --name-only --diff-filter=ACM | grep '\.struct\.json$') +if [ -n "$SCHEMAS" ]; then + jstruct check $SCHEMAS || exit 1 +fi +``` + +## Examples + +### Validate All Schemas in a Directory + +```bash +find . -name "*.struct.json" -exec jstruct check {} + +``` + +### Validate API Request/Response + +```bash +# Validate request body +echo '{"name": "Alice", "age": 30}' | jstruct validate -s person.struct.json - + +# Validate API response +curl -s https://api.example.com/person/1 | jstruct validate -s person.struct.json - +``` + +### Batch Validation with Summary + +```bash +jstruct check --format json schemas/*.json | jq '.[] | select(.valid == false)' +``` + +### Watch Mode (with external tool) + +```bash +# Using entr +ls schemas/*.json | entr -c jstruct check schemas/*.json +``` + +## Related + +- [JSON Structure Specification](https://json-structure.org) +- [Rust SDK Documentation](./README.md) +- [SDK Guidelines](../SDK-GUIDELINES.md) + +## License + +MIT License - see [LICENSE](LICENSE) for details. diff --git a/rust/Cargo.lock b/rust/Cargo.lock index f18a1a9..7a10e2a 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -20,6 +20,71 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "assert_cmd" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcbb6924530aa9e0432442af08bbcafdad182db80d2e560da42a6d442535bf85" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -32,6 +97,23 @@ version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + [[package]] name = "bumpalo" version = "3.19.0" @@ -68,6 +150,52 @@ dependencies = [ "windows-link", ] +[[package]] +name = "clap" +version = "4.5.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -80,6 +208,12 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "displaydoc" version = "0.2.5" @@ -97,12 +231,37 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "find-msvc-tools" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -130,6 +289,12 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "iana-time-zone" version = "0.1.64" @@ -268,6 +433,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.15" @@ -288,13 +459,17 @@ dependencies = [ name = "json-structure" version = "0.1.0" dependencies = [ + "assert_cmd", "base64", "chrono", + "clap", "indexmap", + "predicates", "pretty_assertions", "regex", "serde", "serde_json", + "tempfile", "test-case", "thiserror", "url", @@ -307,6 +482,12 @@ version = "0.2.177" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + [[package]] name = "litemap" version = "0.8.1" @@ -325,6 +506,12 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + [[package]] name = "num-traits" version = "0.2.19" @@ -340,6 +527,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -355,6 +548,36 @@ dependencies = [ "zerovec", ] +[[package]] +name = "predicates" +version = "3.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa" + +[[package]] +name = "predicates-tree" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "pretty_assertions" version = "1.4.1" @@ -418,6 +641,19 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -491,6 +727,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "syn" version = "2.0.111" @@ -513,6 +755,25 @@ dependencies = [ "syn", ] +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "test-case" version = "3.3.1" @@ -600,6 +861,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.18.1" @@ -612,6 +879,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "wasip2" version = "1.0.1+wasi-0.2.4" @@ -725,6 +1001,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "wit-bindgen" version = "0.46.0" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 4845d93..9ea7179 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -21,15 +21,25 @@ uuid = { version = "1.6", features = ["v4", "serde"] } url = { version = "2.5", features = ["serde"] } base64 = "0.21" indexmap = { version = "2.1", features = ["serde"] } +clap = { version = "4.4", features = ["derive"], optional = true } [dev-dependencies] pretty_assertions = "1.4" test-case = "3.3" +assert_cmd = "2.0" +predicates = "3.0" +tempfile = "3.10" [features] default = [] extended = [] # Enable extended validation keywords +cli = ["clap"] [lib] name = "json_structure" path = "src/lib.rs" + +[[bin]] +name = "jstruct" +path = "src/bin/jstruct.rs" +required-features = ["cli"] diff --git a/rust/README.md b/rust/README.md index 4457c01..479b071 100644 --- a/rust/README.md +++ b/rust/README.md @@ -245,6 +245,93 @@ fn validate_something() -> Result<(), Box> { MIT License - see [LICENSE](LICENSE) for details. +## Command Line Interface + +The SDK includes `jstruct`, a CLI tool for validating schemas and instances. + +### Installation + +```bash +cargo install json-structure --features cli +``` + +Or build from source: + +```bash +cargo build --release --features cli +``` + +### Commands + +#### Check Schema(s) + +Validate one or more JSON Structure schema files: + +```bash +# Check a single schema +jstruct check schema.struct.json + +# Check multiple schemas +jstruct check schema1.json schema2.json + +# Use quiet mode (no output, just exit code) +jstruct check -q schema.json + +# Output as JSON +jstruct check --format json schema.json + +# Output as TAP (Test Anything Protocol) +jstruct check --format tap schema.json +``` + +#### Validate Instance(s) + +Validate JSON instances against a schema: + +```bash +# Validate instance against schema +jstruct validate --schema schema.json data.json + +# Validate multiple instances +jstruct validate -s schema.json data1.json data2.json + +# With extended validation (constraint keywords) +jstruct validate --extended -s schema.json data.json + +# Output as JSON +jstruct validate -s schema.json --format json data.json +``` + +### Exit Codes + +| Code | Meaning | +|------|---------| +| 0 | All files valid | +| 1 | One or more files invalid | +| 2 | Error (file not found, etc.) | + +### Output Formats + +**Text (default):** +``` +✓ schema.json: valid +✗ bad-schema.json: invalid + - /$id: Missing required property "$id" +``` + +**JSON:** +```json +[{"file":"schema.json","valid":true,"errors":[]}] +``` + +**TAP:** +```tap +1..2 +ok 1 - schema.json +not ok 2 - bad-schema.json + - /$id: Missing required property "$id" +``` + ## Related - [JSON Structure Specification](https://json-structure.github.io/core/) diff --git a/rust/src/bin/jstruct.rs b/rust/src/bin/jstruct.rs new file mode 100644 index 0000000..0c57279 --- /dev/null +++ b/rust/src/bin/jstruct.rs @@ -0,0 +1,454 @@ +//! jstruct - JSON Structure CLI validator +//! +//! A command-line tool for validating JSON Structure schemas and instances. + +use std::fs; +use std::io::{self, Read}; +use std::path::PathBuf; +use std::process::ExitCode; + +use clap::{Args, Parser, Subcommand, ValueEnum}; +use serde::Serialize; + +use json_structure::{InstanceValidator, SchemaValidator, SchemaValidatorOptions, ValidationResult}; + +/// Exit codes +const EXIT_SUCCESS: u8 = 0; +const EXIT_INVALID: u8 = 1; +const EXIT_ERROR: u8 = 2; + +/// Output format for validation results +#[derive(Debug, Clone, Copy, Default, ValueEnum)] +enum OutputFormat { + /// Human-readable text output (default) + #[default] + Text, + /// Machine-readable JSON output + Json, + /// Test Anything Protocol output + Tap, +} + +/// jstruct - JSON Structure schema and instance validator +#[derive(Parser)] +#[command(name = "jstruct")] +#[command(author = "JSON Structure Contributors")] +#[command(version)] +#[command(about = "JSON Structure schema and instance validator", long_about = None)] +#[command(propagate_version = true)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Check schema file(s) for validity + #[command(alias = "c")] + Check(CheckArgs), + + /// Validate instance file(s) against a schema + #[command(alias = "v")] + Validate(ValidateArgs), +} + +#[derive(Args)] +struct CheckArgs { + /// Schema file(s) to check. Use '-' to read from stdin. + #[arg(required = true)] + files: Vec, + + /// Bundle file(s) containing schemas for $import resolution + #[arg(short, long)] + bundle: Vec, + + /// Output format + #[arg(short, long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, + + /// Suppress output, use exit code only + #[arg(short, long)] + quiet: bool, + + /// Show detailed validation information + #[arg(short, long)] + verbose: bool, +} + +#[derive(Args)] +struct ValidateArgs { + /// Schema file to validate against + #[arg(short, long, required = true)] + schema: PathBuf, + + /// Instance file(s) to validate. Use '-' to read from stdin. + #[arg(required = true)] + files: Vec, + + /// Bundle file(s) containing schemas for $import resolution + #[arg(short, long)] + bundle: Vec, + + /// Output format + #[arg(short, long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, + + /// Suppress output, use exit code only + #[arg(short, long)] + quiet: bool, + + /// Show detailed validation information + #[arg(short, long)] + verbose: bool, +} + +/// Result for a single file validation +#[derive(Debug, Serialize)] +struct FileResult { + file: String, + valid: bool, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, + errors: Vec, +} + +/// Error information for JSON output +#[derive(Debug, Serialize)] +struct ErrorInfo { + path: String, + message: String, + code: String, + #[serde(skip_serializing_if = "Option::is_none")] + line: Option, + #[serde(skip_serializing_if = "Option::is_none")] + column: Option, +} + +fn main() -> ExitCode { + let cli = Cli::parse(); + + let exit_code = match cli.command { + Commands::Check(args) => cmd_check(args), + Commands::Validate(args) => cmd_validate(args), + }; + + ExitCode::from(exit_code) +} + +/// Check schema files for validity +fn cmd_check(args: CheckArgs) -> u8 { + // Load bundle schemas if provided + let external_schemas = match load_bundle_schemas(&args.bundle, args.quiet) { + Ok(schemas) => schemas, + Err(_) => return EXIT_ERROR, + }; + + let options = SchemaValidatorOptions { + allow_import: !external_schemas.is_empty(), + external_schemas, + ..SchemaValidatorOptions::default() + }; + let validator = SchemaValidator::with_options(options); + let mut results = Vec::new(); + let mut has_invalid = false; + let mut has_error = false; + + for file in &args.files { + let result = check_schema(&validator, file); + + if result.error.is_some() { + has_error = true; + } else if !result.valid { + has_invalid = true; + } + + results.push(result); + } + + if !args.quiet { + output_results(&results, args.format, args.verbose); + } + + if has_error { + EXIT_ERROR + } else if has_invalid { + EXIT_INVALID + } else { + EXIT_SUCCESS + } +} + +/// Validate instance files against a schema +fn cmd_validate(args: ValidateArgs) -> u8 { + // Load bundle schemas if provided + let external_schemas = match load_bundle_schemas(&args.bundle, args.quiet) { + Ok(schemas) => schemas, + Err(_) => return EXIT_ERROR, + }; + + let has_bundle = !external_schemas.is_empty(); + let schema_options = SchemaValidatorOptions { + allow_import: has_bundle, + external_schemas, + ..SchemaValidatorOptions::default() + }; + + // Load and validate the schema first + let schema_content = match read_file(&args.schema) { + Ok(content) => content, + Err(e) => { + if !args.quiet { + eprintln!("jstruct: cannot read schema '{}': {}", args.schema.display(), e); + } + return EXIT_ERROR; + } + }; + + // Parse the schema + let schema: serde_json::Value = match serde_json::from_str(&schema_content) { + Ok(v) => v, + Err(e) => { + if !args.quiet { + eprintln!("jstruct: invalid JSON in schema '{}': {}", args.schema.display(), e); + } + return EXIT_ERROR; + } + }; + + // Validate the schema first + let schema_validator = SchemaValidator::with_options(schema_options); + let schema_result = schema_validator.validate(&schema_content); + if !schema_result.is_valid() { + if !args.quiet { + let first_error = schema_result.errors().next() + .map(|e| e.message.as_str()) + .unwrap_or("unknown error"); + eprintln!("jstruct: invalid schema '{}': {}", args.schema.display(), first_error); + } + return EXIT_ERROR; + } + + let instance_validator = InstanceValidator::new(); + let mut results = Vec::new(); + let mut has_invalid = false; + let mut has_error = false; + + for file in &args.files { + let result = validate_instance(&instance_validator, file, &schema); + + if result.error.is_some() { + has_error = true; + } else if !result.valid { + has_invalid = true; + } + + results.push(result); + } + + if !args.quiet { + output_results(&results, args.format, args.verbose); + } + + if has_error { + EXIT_ERROR + } else if has_invalid { + EXIT_INVALID + } else { + EXIT_SUCCESS + } +} + +/// Load schemas from bundle files for $import resolution +fn load_bundle_schemas(bundle_files: &[PathBuf], quiet: bool) -> Result, ()> { + let mut schemas = Vec::new(); + + for file in bundle_files { + let content = match read_file(file) { + Ok(c) => c, + Err(e) => { + if !quiet { + eprintln!("jstruct: cannot read bundle file '{}': {}", file.display(), e); + } + return Err(()); + } + }; + + let schema: serde_json::Value = match serde_json::from_str(&content) { + Ok(v) => v, + Err(e) => { + if !quiet { + eprintln!("jstruct: invalid JSON in bundle file '{}': {}", file.display(), e); + } + return Err(()); + } + }; + + schemas.push(schema); + } + + Ok(schemas) +} + +/// Check a single schema file +fn check_schema(validator: &SchemaValidator, file: &PathBuf) -> FileResult { + let file_name = if file.as_os_str() == "-" { + "".to_string() + } else { + file.display().to_string() + }; + + let content = match read_file(file) { + Ok(c) => c, + Err(e) => { + return FileResult { + file: file_name, + valid: false, + error: Some(e.to_string()), + errors: vec![], + }; + } + }; + + let result = validator.validate(&content); + validation_result_to_file_result(&file_name, result) +} + +/// Validate a single instance file +fn validate_instance( + validator: &InstanceValidator, + file: &PathBuf, + schema: &serde_json::Value, +) -> FileResult { + let file_name = if file.as_os_str() == "-" { + "".to_string() + } else { + file.display().to_string() + }; + + let content = match read_file(file) { + Ok(c) => c, + Err(e) => { + return FileResult { + file: file_name, + valid: false, + error: Some(e.to_string()), + errors: vec![], + }; + } + }; + + let result = validator.validate(&content, schema); + validation_result_to_file_result(&file_name, result) +} + +/// Convert ValidationResult to FileResult +fn validation_result_to_file_result(file: &str, result: ValidationResult) -> FileResult { + let errors: Vec = result + .errors() + .map(|e| ErrorInfo { + path: e.path.clone(), + message: e.message.clone(), + code: e.code.clone(), + line: if e.location.is_unknown() { + None + } else { + Some(e.location.line) + }, + column: if e.location.is_unknown() { + None + } else { + Some(e.location.column) + }, + }) + .collect(); + + FileResult { + file: file.to_string(), + valid: result.is_valid(), + error: None, + errors, + } +} + +/// Read file contents, handling stdin ("-") +fn read_file(path: &PathBuf) -> io::Result { + if path.as_os_str() == "-" { + let mut buffer = String::new(); + io::stdin().read_to_string(&mut buffer)?; + Ok(buffer) + } else { + fs::read_to_string(path) + } +} + +/// Output results in the specified format +fn output_results(results: &[FileResult], format: OutputFormat, verbose: bool) { + match format { + OutputFormat::Text => output_text(results, verbose), + OutputFormat::Json => output_json(results), + OutputFormat::Tap => output_tap(results, verbose), + } +} + +/// Output results as human-readable text +fn output_text(results: &[FileResult], verbose: bool) { + for result in results { + if let Some(ref error) = result.error { + println!("\u{2717} {}: {}", result.file, error); + } else if result.valid { + println!("\u{2713} {}: valid", result.file); + } else { + println!("\u{2717} {}: invalid", result.file); + for error in &result.errors { + let path = if error.path.is_empty() { "/" } else { &error.path }; + let loc = if verbose { + error.line.map(|l| { + format!(" (line {}, col {})", l, error.column.unwrap_or(0)) + }).unwrap_or_default() + } else { + String::new() + }; + println!(" - {}: {}{}", path, error.message, loc); + } + } + } +} + +/// Output results as JSON +fn output_json(results: &[FileResult]) { + let output = if results.len() == 1 { + serde_json::to_string_pretty(&results[0]).unwrap() + } else { + serde_json::to_string_pretty(results).unwrap() + }; + println!("{}", output); +} + +/// Output results in TAP format +fn output_tap(results: &[FileResult], verbose: bool) { + println!("1..{}", results.len()); + + for (i, result) in results.iter().enumerate() { + let n = i + 1; + + if let Some(ref error) = result.error { + println!("not ok {} - {}", n, result.file); + println!(" # {}", error); + } else if result.valid { + println!("ok {} - {}", n, result.file); + } else { + println!("not ok {} - {}", n, result.file); + for error in &result.errors { + let path = if error.path.is_empty() { "/" } else { &error.path }; + let loc = if verbose { + error.line.map(|l| { + format!(" (line {}, col {})", l, error.column.unwrap_or(0)) + }).unwrap_or_default() + } else { + String::new() + }; + println!(" # {}: {}{}", path, error.message, loc); + } + } + } +} diff --git a/rust/tests/cli_tests.rs b/rust/tests/cli_tests.rs new file mode 100644 index 0000000..1854d31 --- /dev/null +++ b/rust/tests/cli_tests.rs @@ -0,0 +1,282 @@ +//! Integration tests for the jstruct CLI +//! +//! These tests require the cli feature to be enabled: +//! cargo test --features cli + +#![cfg(feature = "cli")] + +use std::fs::File; +use std::io::Write; + +use assert_cmd::Command; +use predicates::prelude::*; +use tempfile::TempDir; + +/// Helper to get the jstruct command +fn jstruct() -> Command { + Command::cargo_bin("jstruct").unwrap() +} + +/// Helper to create a temp directory with test files +fn create_test_files() -> TempDir { + let dir = TempDir::new().unwrap(); + + // Valid schema + let schema_path = dir.path().join("valid.struct.json"); + let mut f = File::create(&schema_path).unwrap(); + writeln!(f, r#"{{ + "$schema": "https://json-structure.org/meta/core/v0/#", + "$id": "https://example.com/test", + "name": "Person", + "type": "object", + "properties": {{ + "name": {{ "type": "string" }}, + "age": {{ "type": "int32" }} + }}, + "required": ["name"] + }}"#).unwrap(); + + // Invalid schema (missing $id) + let invalid_schema_path = dir.path().join("invalid.struct.json"); + let mut f = File::create(&invalid_schema_path).unwrap(); + writeln!(f, r#"{{ + "name": "Test", + "type": "string" + }}"#).unwrap(); + + // Valid instance + let valid_instance_path = dir.path().join("valid.json"); + let mut f = File::create(&valid_instance_path).unwrap(); + writeln!(f, r#"{{"name": "Alice", "age": 30}}"#).unwrap(); + + // Invalid instance + let invalid_instance_path = dir.path().join("invalid.json"); + let mut f = File::create(&invalid_instance_path).unwrap(); + writeln!(f, r#"{{"age": "not a number"}}"#).unwrap(); + + // Invalid JSON + let bad_json_path = dir.path().join("bad.json"); + let mut f = File::create(&bad_json_path).unwrap(); + writeln!(f, "{{ invalid json }}").unwrap(); + + dir +} + +#[test] +fn test_version() { + jstruct() + .arg("--version") + .assert() + .success() + .stdout(predicate::str::contains("jstruct")); +} + +#[test] +fn test_help() { + jstruct() + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("check")) + .stdout(predicate::str::contains("validate")); +} + +#[test] +fn test_check_help() { + jstruct() + .args(["check", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("Schema file(s)")); +} + +#[test] +fn test_validate_help() { + jstruct() + .args(["validate", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--schema")); +} + +#[test] +fn test_check_valid_schema() { + let dir = create_test_files(); + let schema = dir.path().join("valid.struct.json"); + + jstruct() + .arg("check") + .arg(&schema) + .assert() + .success() + .stdout(predicate::str::contains("valid")); +} + +#[test] +fn test_check_invalid_schema() { + let dir = create_test_files(); + let schema = dir.path().join("invalid.struct.json"); + + jstruct() + .arg("check") + .arg(&schema) + .assert() + .code(1) + .stdout(predicate::str::contains("invalid")); +} + +#[test] +fn test_check_json_format() { + let dir = create_test_files(); + let schema = dir.path().join("valid.struct.json"); + + jstruct() + .args(["check", "--format", "json"]) + .arg(&schema) + .assert() + .success() + .stdout(predicate::str::contains("\"valid\": true")); +} + +#[test] +fn test_check_tap_format() { + let dir = create_test_files(); + let schema = dir.path().join("valid.struct.json"); + + jstruct() + .args(["check", "--format", "tap"]) + .arg(&schema) + .assert() + .success() + .stdout(predicate::str::starts_with("1..1")) + .stdout(predicate::str::contains("ok 1")); +} + +#[test] +fn test_check_quiet_mode() { + let dir = create_test_files(); + let schema = dir.path().join("valid.struct.json"); + + jstruct() + .args(["check", "-q"]) + .arg(&schema) + .assert() + .success() + .stdout(predicate::str::is_empty()); +} + +#[test] +fn test_validate_valid_instance() { + let dir = create_test_files(); + let schema = dir.path().join("valid.struct.json"); + let instance = dir.path().join("valid.json"); + + jstruct() + .args(["validate", "-s"]) + .arg(&schema) + .arg(&instance) + .assert() + .success() + .stdout(predicate::str::contains("valid")); +} + +#[test] +fn test_validate_invalid_instance() { + let dir = create_test_files(); + let schema = dir.path().join("valid.struct.json"); + let instance = dir.path().join("invalid.json"); + + jstruct() + .args(["validate", "-s"]) + .arg(&schema) + .arg(&instance) + .assert() + .code(1) + .stdout(predicate::str::contains("invalid")); +} + +#[test] +fn test_validate_json_format() { + let dir = create_test_files(); + let schema = dir.path().join("valid.struct.json"); + let instance = dir.path().join("valid.json"); + + jstruct() + .args(["validate", "-s"]) + .arg(&schema) + .args(["--format", "json"]) + .arg(&instance) + .assert() + .success() + .stdout(predicate::str::contains("\"valid\": true")); +} + +#[test] +fn test_validate_missing_schema_option() { + let dir = create_test_files(); + let instance = dir.path().join("valid.json"); + + jstruct() + .arg("validate") + .arg(&instance) + .assert() + .failure() + .stderr(predicate::str::contains("--schema")); +} + +#[test] +fn test_file_not_found() { + jstruct() + .args(["check", "nonexistent.json"]) + .assert() + .code(2) // Exit code 2 for errors (file not found) + .stdout(predicate::str::contains("nonexistent.json")); +} + +#[test] +fn test_invalid_json() { + let dir = create_test_files(); + let bad_json = dir.path().join("bad.json"); + + jstruct() + .arg("check") + .arg(&bad_json) + .assert() + .code(1); // Invalid schema content (parse error) +} + +#[test] +fn test_check_multiple_files() { + let dir = create_test_files(); + let schema1 = dir.path().join("valid.struct.json"); + let schema2 = dir.path().join("invalid.struct.json"); + + jstruct() + .arg("check") + .arg(&schema1) + .arg(&schema2) + .assert() + .code(1); // One invalid = exit 1 +} + +#[test] +fn test_command_aliases() { + let dir = create_test_files(); + let schema = dir.path().join("valid.struct.json"); + let instance = dir.path().join("valid.json"); + + // 'c' is alias for 'check' + jstruct() + .arg("c") + .arg(&schema) + .assert() + .success(); + + // 'v' is alias for 'validate' + jstruct() + .args(["v", "-s"]) + .arg(&schema) + .arg(&instance) + .assert() + .success(); +}